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

-568
View File
@@ -269,18 +269,6 @@ const LED_GROW = 1.4; // how much the dots grow toward the center
let ledDots = [];
let ledWallTime = 0;
// Decides whether the LED wall square should show the track's album art.
// Music and conversation are mutually exclusive: while a conversation is
// active the music is paused, so the wall pulses (agent audio) and any loaded
// track's cover art returns once the talk ends.
function updateAvatarMedia() {
const inConversation = !!(room && room.localParticipant);
const showArt = !inConversation && !!mediaCurrentTrackId && !!mediaCurrentArtUrl;
ledWallEl.classList.toggle("show-art", showArt);
if (showArt && ledArtEl.getAttribute("src") !== mediaCurrentArtUrl) {
ledArtEl.src = mediaCurrentArtUrl;
}
}
function buildLedWall() {
const inner = Math.floor(LED_COLS / 2);
@@ -781,7 +769,6 @@ document.addEventListener("keydown", (event) => {
if (event.key !== "Escape") return;
if (settingsSheet.classList.contains("open")) closeSheet();
if (tasksPanel.classList.contains("open")) setTasksOpen(false);
if (mediaPanel.classList.contains("open")) setMediaOpen(false);
});
// ── Wake lock ───────────────────────────────────────────────────────────────
async function acquireWakeLock() {
@@ -905,8 +892,6 @@ function createRoom(token) {
stopBtn.disabled = true;
agentParticipant = null;
agentReady = false;
// Leaving the conversation hands the room back to the music player.
resumePlaybackAfterTalk();
});
return newRoom.connect(LIVEKIT_URL, token, {
@@ -965,7 +950,6 @@ async function connectConversation() {
// Get a signed access token from the token endpoint.
const tokenData = await fetchToken(ROOM_NAME);
await enterMediaScope(tokenData.room);
room = await createRoom(tokenData.token);
// The agent is dispatched when we join. If it misses the dispatch
@@ -979,7 +963,6 @@ async function connectConversation() {
agentReady = false;
await stale.disconnect();
const freshTokenData = await fetchToken(ROOM_NAME);
await enterMediaScope(freshTokenData.room);
room = await createRoom(freshTokenData.token);
if (!(await waitForAgent(15000))) {
throw new Error("Assistant did not join — reload the page and try again");
@@ -1011,7 +994,6 @@ async function connectConversation() {
muteBtn.disabled = conversationMode !== "voice";
updateMuteIcon();
setState("listening");
updateAvatarMedia();
return room;
} catch (err) {
console.error("Connection failed:", err);
@@ -1093,9 +1075,6 @@ stopBtn.addEventListener("click", () => {
muteBtn.disabled = true;
micMuted = false;
updateMuteIcon();
// Ending the conversation resumes music that was paused for the talk.
resumePlaybackAfterTalk();
updateAvatarMedia();
});
// ── Mic mute toggle ─────────────────────────────────────────────────────────
@@ -1162,551 +1141,4 @@ function sendModelMessage(model, targetRoom) {
});
}
// ── Media player ────────────────────────────────────────────────────────────
// The browser <audio> element is the player. The media server owns library +
// per-room desired state; this UI reconciles against GET /media/api/state
// (polling) and reports real playback back. Hope drives the same state via
// her media tools, so voice commands and panel clicks stay in sync.
//
// Conversation rule (mirrored in agent.py): while a track plays the agent's
// mic is off. Connecting to Hope pauses music; ending the conversation
// resumes what was playing.
const mediaBtn = document.getElementById("mediaBtn");
const mediaPanel = document.getElementById("mediaPanel");
const closeMediaBtn = document.getElementById("closeMediaBtn");
const mediaBadge = document.getElementById("mediaBadge");
const mediaArt = document.getElementById("mediaArt");
const mediaArtPlaceholder = document.getElementById("mediaArtPlaceholder");
const mediaTitleEl = document.getElementById("mediaTitle");
const mediaChannelEl = document.getElementById("mediaChannel");
const mediaProgressWrap = document.getElementById("mediaProgressWrap");
const mediaProgressFill = document.getElementById("mediaProgressFill");
const mediaProgressLabel = document.getElementById("mediaProgressLabel");
const rewBtn = document.getElementById("rewBtn");
const fwdBtn = document.getElementById("fwdBtn");
const playPauseBtn = document.getElementById("playPauseBtn");
const playIcon = document.getElementById("playIcon");
const pauseIcon = document.getElementById("pauseIcon");
const stopMediaBtn = document.getElementById("stopMediaBtn");
const seekBar = document.getElementById("seekBar");
const mediaCurEl = document.getElementById("mediaCur");
const mediaDurEl = document.getElementById("mediaDur");
const volumeBar = document.getElementById("volumeBar");
const mediaSearchForm = document.getElementById("mediaSearchForm");
const mediaSearchInput = document.getElementById("mediaSearchInput");
const mediaResultsEl = document.getElementById("mediaResults");
const mediaChoicesWrap = document.getElementById("mediaChoicesWrap");
const mediaChoicesEl = document.getElementById("mediaChoices");
const mediaLibraryEl = document.getElementById("mediaLibrary");
const streamForm = document.getElementById("streamForm");
const streamNameInput = document.getElementById("streamName");
const streamUrlInput = document.getElementById("streamUrl");
const mediaAudio = document.getElementById("mediaAudio");
const ledArtEl = document.getElementById("ledArt");
let mediaRoom = null; // LiveKit room name (player state scope)
let mediaCurrentTrackId = null; // track id loaded into the audio element
let mediaCurrentArtUrl = null; // current track's cover art
let mediaAppliedSeekTs = 0; // last applied seek_hint timestamp
let mediaWasPlayingBeforeTalk = false;
let mediaPollTimer = null;
let mediaLastReport = 0;
function mediaScope() {
// The LiveKit room is single-source of truth for the player scope. If a
// room is connected, poll the room the agent actually joined (room.name),
// so a "play X" request from the agent can never land in a stale scope.
if (room && room.name) return room.name;
return mediaRoom || "default";
}
async function mediaPost(path, body) {
const resp = await fetch(`/media/api${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ room: mediaScope(), ...body }),
});
if (!resp.ok) {
const detail = await resp.json().catch(() => ({}));
throw new Error(detail.error || `media API ${path} -> ${resp.status}`);
}
return resp.json();
}
// Called just before joining the LiveKit room: carry whatever the audio
// element holds into the room-scoped player state, paused (talking pauses
// music). resumePlaybackAfterTalk() reverses this when the talk ends.
async function enterMediaScope(newRoom) {
if (!newRoom || newRoom === mediaRoom) return;
const wasPlaying = !!mediaCurrentTrackId && !mediaAudio.paused;
mediaWasPlayingBeforeTalk = wasPlaying;
const carriedTrack = mediaCurrentTrackId;
mediaRoom = newRoom;
if (carriedTrack) {
try {
await mediaPost("/play", { room: mediaRoom, track_id: carriedTrack, start_paused: true });
} catch (e) { console.warn("media scope transfer failed:", e); }
mediaAudio.pause();
}
}
async function resumePlaybackAfterTalk() {
if (!mediaWasPlayingBeforeTalk) return;
mediaWasPlayingBeforeTalk = false;
try { await mediaPost("/control", { action: "resume" }); } catch (e) { /* poll retries */ }
}
function publishMediaState(state) {
if (!room || !room.localParticipant) return;
const payload = new TextEncoder().encode(
JSON.stringify({ type: "media_state", state, track_id: mediaCurrentTrackId })
);
room.localParticipant.publishData(payload, { topic: "media-control", reliable: true });
}
function reportMediaPlayback(state) {
const body = {
state,
track_id: mediaCurrentTrackId,
position: isFinite(mediaAudio.currentTime) ? mediaAudio.currentTime : 0,
};
if (isFinite(mediaAudio.duration)) body.duration = mediaAudio.duration;
mediaPost("/report", body).catch(() => {});
}
// ── Polling + reconciliation ────────────────────────────────────────────────
function startMediaPolling() {
if (mediaPollTimer) return;
mediaPollTimer = setInterval(() => {
pollMediaState();
}, 1500);
}
async function pollMediaState() {
let s;
try {
const resp = await fetch(`/media/api/state?room=${encodeURIComponent(mediaScope())}`);
if (!resp.ok) return;
s = await resp.json();
} catch (e) {
return; // media server briefly unavailable; try again next tick
}
reconcileMedia(s);
}
function reconcileMedia(s) {
renderMediaChoices(s.choices || []);
updateMediaBadge(s);
const track = s.track;
const pendingJob = (s.pending || []).find(j => !track || j.track_id === track.id);
updateMediaProgress(track, pendingJob);
if (track && track.id !== mediaCurrentTrackId) {
loadMediaTrack(track);
} else if (track && track.audio_url && mediaAudio.getAttribute("src") !== track.audio_url) {
// The track was loaded before it had a stream (still downloading) and
// now has one — wire it up so playback can start without a reload.
mediaAudio.src = track.audio_url;
mediaAudio.load();
updateMediaNowCard(track);
}
if (!track && mediaCurrentTrackId && s.desired === "stopped") clearMediaAudio();
applyDesiredMediaState(s, track);
if (s.seek_hint && s.seek_hint.ts !== mediaAppliedSeekTs && isFinite(mediaAudio.duration)) {
mediaAppliedSeekTs = s.seek_hint.ts;
const target = Math.min(s.seek_hint.position, mediaAudio.duration);
if (Number.isFinite(target)) {
mediaAudio.currentTime = Math.max(0, target);
updateSeekUI();
}
}
}
function applyDesiredMediaState(s, track) {
if (s.desired === "playing") {
// audio_url presence means a playable stream exists — including a
// still-growing download, which plays progressively.
if (track && track.audio_url && mediaAudio.src && mediaAudio.paused) {
mediaAudio.play().catch((e) => console.warn("playback blocked:", e));
}
} else if (!mediaAudio.paused) {
mediaAudio.pause();
}
}
function loadMediaTrack(track) {
mediaCurrentTrackId = track.id;
updateMediaNowCard(track);
if (track.audio_url) {
if (mediaAudio.getAttribute("src") !== track.audio_url) {
mediaAudio.src = track.audio_url;
mediaAudio.load();
}
} else {
// Downloading or failed: no stream yet. reconcileMedia wires the
// stream URL in via polling as soon as one is available.
mediaAudio.removeAttribute("src");
mediaAudio.load();
updateMediaControls();
}
}
function clearMediaAudio() {
mediaCurrentTrackId = null;
mediaCurrentArtUrl = null;
mediaAudio.pause();
mediaAudio.removeAttribute("src");
mediaAudio.load();
mediaTitleEl.textContent = "Nothing loaded";
mediaChannelEl.textContent = "";
mediaArt.hidden = true;
mediaArtPlaceholder.hidden = false;
updateMediaControls();
updateAvatarMedia();
}
function updateMediaNowCard(track) {
mediaTitleEl.textContent = track.title || track.id;
mediaCurrentArtUrl = track.art_url || null;
if (track.status === "failed") {
mediaChannelEl.textContent = "Download failed — pick another result or try again.";
} else if (track.status === "downloading") {
// Progressive streaming: the track plays while the download runs.
mediaChannelEl.textContent = track.channel
? `${track.channel} · downloading` : "Downloading…";
} else {
mediaChannelEl.textContent = track.channel || (track.kind === "stream" ? "Live stream" : "");
}
if (track.art_url) {
mediaArt.src = track.art_url;
mediaArt.hidden = false;
mediaArtPlaceholder.hidden = true;
} else {
mediaArt.hidden = true;
mediaArtPlaceholder.hidden = false;
}
updateAvatarMedia();
}
function updateMediaProgress(track, job) {
if (!job) {
mediaProgressWrap.hidden = true;
return;
}
mediaProgressWrap.hidden = false;
const pct = Math.round(job.progress || 0);
mediaProgressFill.style.width = `${pct}%`;
const stage = job.stage === "queued" ? "Queued…" : "Downloading…";
mediaProgressLabel.textContent = `${stage} ${pct}%`;
if (track) updateMediaNowCard(track);
}
function updateMediaBadge(s) {
const pending = (s.pending || []).length;
const playing = !mediaAudio.paused && !!mediaCurrentTrackId;
if (pending > 0) {
mediaBadge.hidden = false;
mediaBadge.textContent = String(pending);
} else if (playing) {
mediaBadge.hidden = false;
mediaBadge.innerHTML = "&#9835;";
} else {
mediaBadge.hidden = true;
}
}
// ── Playback controls ───────────────────────────────────────────────────────
function updateMediaControls() {
const playing = !mediaAudio.paused && !!mediaCurrentTrackId;
playPauseBtn.classList.toggle("playing", playing);
playPauseBtn.setAttribute("aria-label", playing ? "Pause" : "Play");
updateMediaBadge({ pending: [] });
}
function fmtTime(sec) {
if (!isFinite(sec) || sec < 0) return "0:00";
const s = Math.floor(sec % 60);
const m = Math.floor((sec / 60) % 60);
const h = Math.floor(sec / 3600);
return h ? `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` : `${m}:${String(s).padStart(2, "0")}`;
}
function updateSeekUI() {
if (!isFinite(mediaAudio.duration) || mediaAudio.duration <= 0) {
seekBar.value = 0;
mediaDurEl.textContent = "0:00";
mediaCurEl.textContent = fmtTime(mediaAudio.currentTime);
return;
}
seekBar.max = Math.floor(mediaAudio.duration);
seekBar.value = Math.floor(mediaAudio.currentTime);
mediaCurEl.textContent = fmtTime(mediaAudio.currentTime);
mediaDurEl.textContent = fmtTime(mediaAudio.duration);
}
playPauseBtn.addEventListener("click", () => {
if (!mediaCurrentTrackId || !mediaAudio.src) return;
if (mediaAudio.paused) {
mediaAudio.play().catch((e) => console.warn("play failed:", e));
mediaPost("/control", { action: "resume" }).catch(() => {});
} else {
mediaAudio.pause();
mediaPost("/control", { action: "pause" }).catch(() => {});
}
});
rewBtn.addEventListener("click", () => {
if (!mediaAudio.src) return;
mediaAudio.currentTime = Math.max(0, mediaAudio.currentTime - 10);
reportMediaPlayback(!mediaAudio.paused ? "playing" : "paused");
});
fwdBtn.addEventListener("click", () => {
if (!mediaAudio.src || !isFinite(mediaAudio.duration)) return;
mediaAudio.currentTime = Math.min(mediaAudio.duration - 0.5, mediaAudio.currentTime + 10);
reportMediaPlayback(!mediaAudio.paused ? "playing" : "paused");
});
stopMediaBtn.addEventListener("click", () => {
clearMediaAudio();
mediaPost("/control", { action: "stop" }).catch(() => {});
publishMediaState("stopped");
});
seekBar.addEventListener("change", () => {
if (!mediaAudio.src || !isFinite(mediaAudio.duration)) return;
mediaAudio.currentTime = Number(seekBar.value);
reportMediaPlayback(!mediaAudio.paused ? "playing" : "paused");
});
volumeBar.addEventListener("input", () => {
mediaAudio.volume = Number(volumeBar.value) / 100;
try { localStorage.setItem("media_volume", volumeBar.value); } catch (e) { /* ok */ }
});
mediaAudio.volume = Number(localStorage.getItem("media_volume") ?? 90) / 100;
volumeBar.value = Math.round(mediaAudio.volume * 100);
// Browsers block audio until a user gestured on the page. Hope often starts a
// cached song from a poll (voice command, no click on the player), so the
// <audio> element sits outside the LiveKit room's audio-unlock flow and its
// play() is rejected. Capture the first gesture and unlock the element.
let mediaAudioUnlocked = false;
function unlockMediaAudio() {
if (mediaAudioUnlocked) return;
mediaAudioUnlocked = true;
removeEventListener("pointerdown", unlockMediaAudio, true);
removeEventListener("keydown", unlockMediaAudio, true);
removeEventListener("touchstart", unlockMediaAudio, true);
// Play the current src (if any) inside the gesture to whitelist the
// element for autoplay, so poll-driven play() later isn't blocked.
if (mediaAudio.getAttribute("src") && mediaAudio.paused) {
const p = mediaAudio.play();
if (p) p.catch(() => {});
}
}
addEventListener("pointerdown", unlockMediaAudio, true);
addEventListener("keydown", unlockMediaAudio, true);
addEventListener("touchstart", unlockMediaAudio, true);
mediaAudio.addEventListener("play", () => {
updateMediaControls();
reportMediaPlayback("playing");
publishMediaState("playing");
});
mediaAudio.addEventListener("pause", () => {
updateMediaControls();
if (mediaCurrentTrackId) {
reportMediaPlayback("paused");
publishMediaState("paused");
}
});
mediaAudio.addEventListener("ended", () => {
updateMediaControls();
reportMediaPlayback("stopped");
publishMediaState("stopped");
mediaPost("/control", { action: "stop" }).catch(() => {});
});
mediaAudio.addEventListener("timeupdate", () => {
updateSeekUI();
const now = Date.now();
if (!mediaAudio.paused && now - mediaLastReport > 5000) {
mediaLastReport = now;
reportMediaPlayback("playing");
}
});
mediaAudio.addEventListener("durationchange", updateSeekUI);
mediaAudio.addEventListener("error", () => {
// An empty src (track still downloading) also fires this — only surface
// real playback failures.
if (mediaCurrentTrackId && mediaAudio.getAttribute("src")) {
mediaChannelEl.textContent = "Playback error — the track may still be preparing.";
}
});
// ── Search / choices / library ──────────────────────────────────────────────
function fmtListDuration(sec) {
const s = Number(sec);
return isFinite(s) && s > 0 ? fmtTime(s) : "";
}
function mediaRowActions() {
const play = document.createElement("button");
play.className = "btn btn-secondary media-row-play";
play.type = "button";
play.textContent = "Play";
return play;
}
function renderMediaResults(results) {
mediaResultsEl.innerHTML = "";
if (!results || results.length === 0) {
mediaResultsEl.innerHTML = '<div class="media-empty">No results.</div>';
return;
}
for (const r of results) {
const row = document.createElement("div");
row.className = "media-row";
const info = document.createElement("div");
info.className = "media-row-info";
const dur = fmtListDuration(r.duration);
info.innerHTML = `<strong>${escapeHtml(r.title)}</strong>` +
`<span>${escapeHtml(r.channel || "")}${dur ? " · " + dur : ""}` +
`${r.in_library ? " · in library" : ""}</span>`;
const play = mediaRowActions();
play.addEventListener("click", () => playVideoId(r));
row.appendChild(info);
row.appendChild(play);
mediaResultsEl.appendChild(row);
}
}
async function playVideoId(r) {
try {
await mediaPost("/play", {
video_id: r.video_id,
title: r.title,
channel: r.channel || "",
});
} catch (e) {
mediaChannelEl.textContent = `Could not start: ${e.message}`;
}
}
mediaSearchForm.addEventListener("submit", async (event) => {
event.preventDefault();
const query = mediaSearchInput.value.trim();
if (!query) return;
mediaResultsEl.innerHTML = '<div class="media-empty">Searching YouTube Music…</div>';
try {
const data = await mediaPost("/search", { query, limit: 6 });
renderMediaResults(data.results);
} catch (e) {
mediaResultsEl.innerHTML = `<div class="media-empty">Search failed: ${escapeHtml(e.message)}</div>`;
}
});
function renderMediaChoices(choices) {
mediaChoicesWrap.hidden = choices.length === 0;
mediaChoicesEl.innerHTML = "";
for (const c of choices) {
const row = document.createElement("div");
row.className = "media-row media-choice";
const info = document.createElement("div");
info.className = "media-row-info";
const dur = fmtListDuration(c.duration);
info.innerHTML = `<strong>${escapeHtml(c.title)}</strong>` +
`<span>${escapeHtml(c.channel || "")}${dur ? " · " + dur : ""}</span>`;
const play = mediaRowActions();
play.addEventListener("click", () => playVideoId(c));
row.appendChild(info);
row.appendChild(play);
mediaChoicesEl.appendChild(row);
}
}
async function refreshLibrary() {
let data;
try {
data = await fetch("/media/api/library").then((r) => r.json());
} catch (e) {
return;
}
mediaLibraryEl.innerHTML = "";
const tracks = data.tracks || [];
if (tracks.length === 0) {
mediaLibraryEl.innerHTML = '<div class="media-empty">Downloaded songs and streams live here.</div>';
return;
}
for (const t of tracks) {
const row = document.createElement("div");
row.className = "media-row";
const info = document.createElement("div");
info.className = "media-row-info";
const dur = fmtListDuration(t.duration);
const statusTag = t.status === "ready" ? "" : ` · ${t.status}`;
info.innerHTML = `<strong>${escapeHtml(t.title)}</strong>` +
`<span>${escapeHtml(t.channel || (t.kind === "stream" ? "Live stream" : ""))}` +
`${dur ? " · " + dur : ""}${statusTag}</span>`;
const play = mediaRowActions();
play.disabled = t.status !== "ready";
play.addEventListener("click", () => {
mediaPost("/play", { track_id: t.id }).catch((e) => {
mediaChannelEl.textContent = `Could not play: ${e.message}`;
});
});
const del = document.createElement("button");
del.className = "btn btn-icon media-row-delete";
del.type = "button";
del.setAttribute("aria-label", `Remove ${t.title}`);
del.title = "Remove from library";
del.innerHTML = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"></path></svg>';
del.addEventListener("click", async () => {
try {
await fetch(`/media/api/tracks/${encodeURIComponent(t.id)}`, { method: "DELETE" });
if (t.id === mediaCurrentTrackId) clearMediaAudio();
refreshLibrary();
} catch (e) { /* leave the row; next refresh decides */ }
});
row.appendChild(info);
row.appendChild(play);
row.appendChild(del);
mediaLibraryEl.appendChild(row);
}
}
streamForm.addEventListener("submit", async (event) => {
event.preventDefault();
const name = streamNameInput.value.trim() || streamUrlInput.value.trim();
const url = streamUrlInput.value.trim();
if (!url) return;
try {
await mediaPost("/streams", { name, url });
streamNameInput.value = "";
streamUrlInput.value = "";
refreshLibrary();
} catch (e) {
mediaChannelEl.textContent = `Could not add stream: ${e.message}`;
}
});
// ── Panel open/close ────────────────────────────────────────────────────────
function setMediaOpen(open) {
mediaPanel.classList.toggle("open", open);
mediaBtn.setAttribute("aria-expanded", String(open));
if (open) {
refreshLibrary();
pollMediaState();
}
}
mediaBtn.setAttribute("aria-expanded", "false");
mediaBtn.addEventListener("click", () => setMediaOpen(!mediaPanel.classList.contains("open")));
closeMediaBtn.addEventListener("click", () => setMediaOpen(false));
startMediaPolling();
updateMediaControls();
buildLedWall();
-85
View File
@@ -42,11 +42,6 @@
<span>Tasks</span>
<span class="tasks-badge" id="tasksBadge" hidden>0</span>
</button>
<button id="mediaBtn" class="btn btn-secondary tasks-trigger" aria-label="Music player" title="Music player">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 18V5l12-2v13"></path><circle cx="6" cy="18" r="3"></circle><circle cx="18" cy="16" r="3"></circle></svg>
<span>Music</span>
<span class="tasks-badge" id="mediaBadge" hidden>&#9835;</span>
</button>
</div>
</header>
@@ -59,7 +54,6 @@
<div class="orb-stage">
<div class="led-wall" id="ledWall" data-state="idle" aria-hidden="true">
<img id="ledArt" class="led-art" alt="">
</div>
<div class="orb-labels" aria-live="polite">
<span class="state-label" id="stateLabel">Ready when you are</span>
@@ -151,85 +145,6 @@
</div>
</div>
<div class="tasks-panel media-panel" id="mediaPanel" aria-label="Music player">
<div class="tasks-header">
<div>
<h2>Music</h2>
<p>YouTube Music downloads and live streams, streamed to you.</p>
</div>
<button id="closeMediaBtn" class="btn btn-icon" aria-label="Close music player">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"></path></svg>
</button>
</div>
<div class="media-now">
<img id="mediaArt" class="media-art" alt="" hidden>
<div class="media-art-placeholder" id="mediaArtPlaceholder" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M9 18V5l12-2v13"></path><circle cx="6" cy="18" r="3"></circle><circle cx="18" cy="16" r="3"></circle></svg>
</div>
<div class="media-now-info">
<strong id="mediaTitle">Nothing loaded</strong>
<span id="mediaChannel"></span>
<div class="media-progress" id="mediaProgressWrap" hidden>
<div class="media-progress-fill" id="mediaProgressFill"></div>
<span class="media-progress-label" id="mediaProgressLabel">Fetching&hellip;</span>
</div>
</div>
</div>
<div class="media-controls" role="group" aria-label="Playback controls">
<button id="rewBtn" class="btn btn-icon" aria-label="Back 10 seconds" title="Back 10s">
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 17a5 5 0 1 0-5-5"></path><path d="M11 17H6v-5"></path><path d="m19 5-4 4h-3"></path><path d="m15 9 4-4"></path></svg>
</button>
<button id="playPauseBtn" class="btn btn-icon media-play" aria-label="Play or pause" title="Play / pause">
<svg id="playIcon" class="play-icon" viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7Z"></path></svg>
<svg id="pauseIcon" class="pause-icon" viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true"><path d="M7 5h4v14H7zM13 5h4v14h-4z"></path></svg>
</button>
<button id="fwdBtn" class="btn btn-icon" aria-label="Forward 10 seconds" title="Forward 10s">
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13 17a5 5 0 1 1 5-5"></path><path d="M13 17h5v-5"></path><path d="m5 5 4 4h3"></path><path d="m9 9 4-4"></path></svg>
</button>
<button id="stopMediaBtn" class="btn btn-icon" aria-label="Stop" title="Stop">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"></rect></svg>
</button>
</div>
<div class="media-seek">
<span class="media-time" id="mediaCur">0:00</span>
<input type="range" id="seekBar" min="0" max="1000" value="0" step="1" aria-label="Seek position">
<span class="media-time" id="mediaDur">0:00</span>
</div>
<div class="media-volume">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 5 6 9H2v6h4l5 4V5Z"></path><path d="M15.5 8.5a5 5 0 0 1 0 7"></path></svg>
<input type="range" id="volumeBar" min="0" max="100" value="90" aria-label="Volume">
</div>
<h3 class="sheet-section-title">Search YouTube Music</h3>
<form class="media-search" id="mediaSearchForm">
<input id="mediaSearchInput" type="search" placeholder="Song, artist, lyrics&hellip;" autocomplete="off">
<button class="btn btn-secondary" type="submit">Find</button>
</form>
<div class="media-list" id="mediaResults"></div>
<div id="mediaChoicesWrap" hidden>
<h3 class="sheet-section-title">Hope&rsquo;s suggestions</h3>
<div class="media-list" id="mediaChoices"></div>
</div>
<h3 class="sheet-section-title">Library</h3>
<div class="media-list" id="mediaLibrary">
<div class="media-empty">Downloaded songs and streams live here.</div>
</div>
<h3 class="sheet-section-title">Add live stream</h3>
<form class="media-search media-stream-form" id="streamForm">
<input id="streamName" type="text" placeholder="Station name" autocomplete="off">
<input id="streamUrl" type="url" placeholder="https://&hellip; audio stream URL" required>
<button class="btn btn-secondary" type="submit">Add</button>
</form>
</div>
<audio id="mediaAudio" preload="metadata"></audio>
<script src="livekit-client.umd.js"></script>
<script src="app.js"></script>
</body>
-810
View File
@@ -1,810 +0,0 @@
#!/usr/bin/env python3
"""Media server for the Hope voice assistant.
A small FastAPI service that owns the permanent music library and player
coordination:
- Library: downloaded YouTube Music tracks (raw best-audio files, saved by
yt-dlp with no transcode) and user-added live audio stream URLs, persisted
in library.json.
- Search: yt-dlp ``ytsearch`` (YouTube Music pages share video ids), used by
Hope's media MCP toolset and by the web UI search box. Ready library tracks
that match the query are returned first so Hope prefers a downloaded copy.
- Download jobs: one-at-a-time worker thread; progress is parsed from
yt-dlp stderr and surfaced through GET /media/api/state. Playback starts
while the file is still growing (progressive streaming), and the finished
file stays in the library for instant replay.
- Player state: per-room desired state (loaded track, playing/paused/stopped,
seek hints). The browser is the actual player and reconciles against this;
it reports real playback state back so Hope's voice tools can answer
"what's playing".
Security posture matches the token server: bind 127.0.0.1 only, reachability
exclusively through the nginx HTTPS proxy (/media/api/). Growing downloads
are streamed by this service (chunked, no Content-Length); finished files are
served here too with native Range support for seeking. Live stream URLs are
proxied (mixed-content: an http:// radio URL would be blocked by the browser,
so it is fetched server-side and re-served over HTTPS).
"""
from __future__ import annotations
import asyncio
import json
import os
import re
import subprocess
import sys
import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Any
import httpx
import uvicorn
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
MEDIA_DIR = Path(os.environ.get("MEDIA_DIR", "/data/media"))
FILES_DIR = MEDIA_DIR / "files"
THUMBS_DIR = MEDIA_DIR / "thumbs"
JOBS_DIR = MEDIA_DIR / "jobs"
LIBRARY_FILE = MEDIA_DIR / "library.json"
COOKIES_FILE = os.environ.get("YTDLP_COOKIES", "")
MEDIA_HOST = os.environ.get("MEDIA_SERVER_HOST", "127.0.0.1")
# 8093, NOT 8092: 8092 is occupied by an Apache/YOURLS stack on the Unraid
# deployment host (xNAS) — a bind conflict there looks like a healthy
# deployment because the healthcheck curl (without -f) accepts any response.
MEDIA_PORT = int(os.environ.get("MEDIA_SERVER_PORT", "8093"))
DOWNLOAD_TIMEOUT = int(os.environ.get("MEDIA_DOWNLOAD_TIMEOUT", "600"))
SEARCH_TIMEOUT = int(os.environ.get("MEDIA_SEARCH_TIMEOUT", "45"))
app = FastAPI(title="hope-media", docs_url=None, redoc_url=None, openapi_url=None)
_lock = threading.Lock() # guards library + rooms below
_library: dict[str, Any] = {"tracks": {}, "version": 1}
_rooms: dict[str, dict[str, Any]] = {}
_jobs: dict[str, dict[str, Any]] = {}
_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="media-dl")
for _d in (FILES_DIR, THUMBS_DIR, JOBS_DIR):
_d.mkdir(parents=True, exist_ok=True)
# The bind mount may be inspected/cleaned from the host by a non-root
# user (Unraid share convention) — keep the dirs world-traversable.
try:
os.chmod(_d, 0o777)
except OSError:
pass
# ── Library store ───────────────────────────────────────────────────────────
def _load_library() -> None:
if LIBRARY_FILE.exists():
try:
data = json.loads(LIBRARY_FILE.read_text(encoding="utf-8"))
tracks = data.get("tracks", {})
if isinstance(tracks, dict):
_library["tracks"] = tracks
_library["version"] = int(data.get("version", 1))
except (json.JSONDecodeError, OSError):
# Corrupt library: keep the broken file aside, start fresh.
try:
LIBRARY_FILE.replace(LIBRARY_FILE.with_suffix(".json.broken"))
except OSError:
pass
def _save_library() -> None:
tmp = LIBRARY_FILE.with_suffix(".json.tmp")
tmp.write_text(json.dumps(_library), encoding="utf-8")
tmp.replace(LIBRARY_FILE)
def _bump_version() -> int:
_library["version"] = int(_library.get("version", 1)) + 1
return _library["version"]
def _track_dict(track: dict) -> dict:
out = dict(track)
if track["kind"] == "youtube":
# The stream endpoint serves the growing file during download and the
# finished file afterwards; the URL is stable across the transition
# so the browser never has to reload mid-playback.
if track.get("status") in ("downloading", "ready"):
out["audio_url"] = f"/media/api/stream/{track['id']}"
out["art_url"] = (
f"/media/thumbs/{track['id']}.jpg"
if (THUMBS_DIR / f"{track['id']}.jpg").exists()
else None
)
else:
out["audio_url"] = f"/media/api/stream/{track['id']}"
return out
def _get_track(track_id: str) -> dict | None:
return _library["tracks"].get(track_id)
def _upsert_track(track: dict) -> None:
_library["tracks"][track["id"]] = track
_bump_version()
_save_library()
_load_library()
# ── Player state (per room) ─────────────────────────────────────────────────
def _room_state(room: str) -> dict:
return _rooms.setdefault(
room,
{"track_id": None, "desired": "stopped", "reported": None,
"seek_hint": None, "choices": []},
)
def _room_snapshot(room: str) -> dict:
st = _room_state(room)
track = _get_track(st["track_id"]) if st["track_id"] else None
pending = [
{
"job_id": j["id"],
"track_id": j["track_id"],
"stage": j["stage"],
"progress": j["progress"],
"error": j.get("error"),
}
for j in _jobs.values()
if j["stage"] in ("queued", "downloading", "transcoding")
]
return {
"room": room,
"track": _track_dict(track) if track else None,
"desired": st["desired"],
"reported": st["reported"],
"seek_hint": st["seek_hint"],
"choices": st["choices"],
"pending": pending,
"version": _library["version"],
}
# ── yt-dlp helpers ──────────────────────────────────────────────────────────
def _ytdlp_base_args() -> list[str]:
args = [sys.executable, "-m", "yt_dlp", "--no-playlist", "--no-warnings"]
if COOKIES_FILE and Path(COOKIES_FILE).exists():
args += ["--cookies", COOKIES_FILE]
return args
_PROGRESS_RE = re.compile(r"\[download\]\s+([\d.]+)%")
# Audio containers yt-dlp can produce for a raw best-audio download.
_AUDIO_EXTS = {
".webm", ".opus", ".ogg", ".m4a", ".mp4", ".m4b", ".aac",
".mp3", ".mka", ".flac", ".wav",
}
_AUDIO_MIME = {
".webm": "audio/webm",
".opus": "audio/opus",
".ogg": "audio/ogg",
".m4a": "audio/mp4",
".mp4": "audio/mp4",
".m4b": "audio/mp4",
".aac": "audio/aac",
".mp3": "audio/mpeg",
".mka": "audio/x-matroska",
".flac": "audio/flac",
".wav": "audio/wav",
}
def _media_type_for(name: str) -> str:
if name.endswith(".part"):
name = name[:-5]
return _AUDIO_MIME.get(Path(name).suffix.lower(), "audio/mpeg")
def _youtube_media_file(track_id: str) -> Path | None:
"""The on-disk audio file for a youtube track, if any.
During an active download yt-dlp writes ``{id}.{ext}.part`` and renames it
to ``{id}.{ext}`` on completion — return whichever currently exists (the
partial file wins, thumbnails and other sidecars are ignored).
"""
complete: Path | None = None
for p in FILES_DIR.glob(f"{track_id}.*"):
suffix = p.suffix.lower()
if suffix == ".part":
if p.stat().st_size > 0:
return p
elif suffix in _AUDIO_EXTS:
if complete is None or p.name < complete.name:
complete = p
return complete
async def _wait_for_media_file(track_id: str, timeout: float = 15.0) -> Path | None:
"""Wait for yt-dlp to create the output file after a play request."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
path = _youtube_media_file(track_id)
if path is not None:
return path
await asyncio.sleep(0.5)
return None
async def _growing_file(track_id: str, job: dict, chunk_size: int = 64 * 1024):
"""Yield bytes from a file that yt-dlp is still writing.
The response stays open with no Content-Length until the download
finishes, so the browser plays the track as it grows. The open descriptor
keeps reading the old inode across yt-dlp's .part -> final rename, and the
path is re-resolved if the file disappears between resolution and open.
"""
fd = -1
pos = 0
stall = 0
try:
while True:
if fd == -1:
path = _youtube_media_file(track_id)
if path is not None:
try:
fd = os.open(path, os.O_RDONLY)
except FileNotFoundError:
fd = -1 # renamed between glob and open — retry
if fd != -1:
data = await asyncio.to_thread(os.pread, fd, chunk_size, pos)
if data:
pos += len(data)
stall = 0
yield data
continue
with _lock:
active = job.get("stage") == "downloading"
if not active:
break
stall += 1
if stall > 60: # ~30s with no new bytes — stop waiting
break
await asyncio.sleep(0.5)
finally:
if fd != -1:
os.close(fd)
def _run_search(query: str, limit: int) -> list[dict]:
"""Flat ytsearch lookup — cheap, no download, works anonymously."""
cmd = _ytdlp_base_args() + [
"--flat-playlist", "--dump-json",
f"ytsearch{limit}:{query}",
]
try:
proc = subprocess.run(
cmd, capture_output=True, text=True, timeout=SEARCH_TIMEOUT, check=False
)
except subprocess.TimeoutExpired:
raise HTTPException(504, "YouTube Music search timed out")
if proc.returncode != 0 and not proc.stdout.strip():
detail = (proc.stderr or "").strip().splitlines()[-1:] or ["unknown error"]
raise HTTPException(502, f"Search failed: {detail[0][:300]}")
results = []
for line in proc.stdout.splitlines():
line = line.strip()
if not line.startswith("{"):
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
video_id = entry.get("id")
if not video_id:
continue
existing = _get_track(video_id)
results.append({
"video_id": video_id,
"title": entry.get("title") or video_id,
"channel": entry.get("uploader") or entry.get("channel") or "",
"duration": entry.get("duration"),
"view_count": entry.get("view_count"),
"in_library": bool(existing),
"track_id": existing["id"] if existing else None,
"library_status": existing["status"] if existing else None,
})
return results
def _job_dict(job: dict) -> dict:
return {k: job.get(k) for k in
("id", "track_id", "stage", "progress", "error", "started_at")}
def _persist_job(job: dict) -> None:
try:
(JOBS_DIR / f"{job['id']}.json").write_text(
json.dumps(_job_dict(job)), encoding="utf-8"
)
except OSError:
pass
def _fail_job(job: dict, track: dict, message: str) -> None:
job["stage"] = "error"
job["error"] = message[:500]
_persist_job(job)
with _lock:
track["status"] = "failed"
track["error"] = message[:500]
_bump_version()
_save_library()
def _download_worker(job: dict, track: dict, video_id: str) -> None:
"""Runs in the single-worker executor: raw best-audio download.
No mp3 transcode — the audio container is streamed straight into the
library so playback can begin while the file is still growing (the stream
endpoint serves the partial file), and the finished file stays playable
offline. Thumbnail handling is unchanged.
"""
cmd = _ytdlp_base_args() + [
"-f", "bestaudio/best",
"--newline", "--progress",
"--print-json",
"--write-thumbnail", "--convert-thumbnails", "jpg",
"--retries", "3", "--fragment-retries", "3",
"-P", str(FILES_DIR),
"-o", f"{track['id']}.%(ext)s",
f"https://www.youtube.com/watch?v={video_id}",
]
job["stage"] = "downloading"
info: dict = {}
# Progress lines go to stderr, the metadata JSON to stdout. Drain stderr
# in a thread so the stdout read loop can never deadlock the pipe; the
# tail is kept for the failure detail.
stderr_tail: list[str] = []
proc = None
def _drain_stderr() -> None:
assert proc is not None and proc.stderr is not None
for line in proc.stderr:
stderr_tail.append(line)
if len(stderr_tail) > 200:
stderr_tail.pop(0)
m = _PROGRESS_RE.search(line)
if m:
job["progress"] = min(99.0, float(m.group(1)))
_persist_job(job)
try:
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, errors="replace",
)
threading.Thread(target=_drain_stderr, daemon=True).start()
deadline = time.monotonic() + DOWNLOAD_TIMEOUT
assert proc.stdout is not None
for line in proc.stdout:
if time.monotonic() > deadline:
proc.kill()
proc.wait(timeout=30)
raise RuntimeError(f"Download exceeded {DOWNLOAD_TIMEOUT}s")
line = line.strip()
if line.startswith("{"):
try:
info = json.loads(line)
except json.JSONDecodeError:
pass
proc.wait(timeout=30)
if proc.returncode != 0:
detail = stderr_tail[-1] if stderr_tail else f"exit code {proc.returncode}"
raise RuntimeError(detail.strip())
media = _youtube_media_file(track["id"])
if media is None:
raise RuntimeError("yt-dlp finished but no audio file was produced")
except Exception as exc: # noqa: BLE001 — any failure marks the track failed
_fail_job(job, track, str(exc))
return
thumb_src = FILES_DIR / f"{track['id']}.jpg"
try:
if thumb_src.exists():
thumb_src.replace(THUMBS_DIR / f"{track['id']}.jpg")
except OSError:
pass
job["stage"] = "ready"
job["progress"] = 100.0
_persist_job(job)
with _lock:
track["status"] = "ready"
track["error"] = None
track["ext"] = media.suffix.lstrip(".").lower()
if info:
if info.get("title"):
track["title"] = info["title"]
if info.get("uploader") or info.get("channel"):
track["channel"] = info.get("uploader") or info.get("channel")
if info.get("duration"):
track["duration"] = round(float(info["duration"]))
_bump_version()
_save_library()
def _start_download(track: dict, video_id: str) -> dict:
job = {
"id": uuid.uuid4().hex[:12],
"track_id": track["id"],
"stage": "queued",
"progress": 0.0,
"error": None,
"started_at": time.time(),
}
_jobs[job["id"]] = job
_persist_job(job)
_executor.submit(_download_worker, job, track, video_id)
return job
# ── API: health, library ────────────────────────────────────────────────────
@app.get("/media/api/healthz")
def healthz() -> dict:
return {"ok": True, "version": _library["version"]}
@app.get("/media/api/library")
def get_library() -> dict:
with _lock:
tracks = sorted(
(_track_dict(t) for t in _library["tracks"].values()),
key=lambda t: t.get("added_at", 0),
reverse=True,
)
return {"tracks": tracks, "version": _library["version"]}
def _delete_track_files(track: dict) -> None:
if track["kind"] != "youtube":
return
paths = [
FILES_DIR / f"{track['id']}.{ext}" for ext in
(s.lstrip(".") for s in _AUDIO_EXTS)
] + [FILES_DIR / f"{track['id']}.jpg", THUMBS_DIR / f"{track['id']}.jpg"]
for path in paths:
try:
path.unlink(missing_ok=True)
except OSError:
pass
@app.delete("/media/api/tracks/{track_id}")
def delete_track(track_id: str) -> dict:
with _lock:
track = _get_track(track_id)
if not track:
raise HTTPException(404, "unknown track")
del _library["tracks"][track_id]
_bump_version()
_save_library()
for room, st in _rooms.items():
if st["track_id"] == track_id:
st["track_id"] = None
st["desired"] = "stopped"
_delete_track_files(track)
return {"ok": True}
# ── API: search + play + control ────────────────────────────────────────────
def _library_search(query: str, limit: int) -> list[dict]:
"""Ready library tracks whose title matches the query.
These are merged in front of the YouTube results so Hope prefers an
already-downloaded copy over fetching a new one.
"""
q = query.strip().lower()
tokens = {t for t in re.split(r"\W+", q) if len(t) > 2}
hits: list[dict] = []
if not q:
return hits
for track in _library["tracks"].values():
if track.get("kind") != "youtube" or track.get("status") != "ready":
continue
title = (track.get("title") or "").strip().lower()
if not title:
continue
title_tokens = {t for t in re.split(r"\W+", title) if t}
if not (q in title or title in q or (tokens and tokens <= title_tokens)):
continue
hits.append({
"video_id": track["id"],
"track_id": track["id"],
"title": track.get("title") or track["id"],
"channel": track.get("channel") or "",
"duration": track.get("duration"),
"view_count": None,
"in_library": True,
"library_status": "ready",
})
return hits[:limit]
@app.post("/media/api/search")
async def search(request: Request) -> dict:
body = await request.json()
query = str(body.get("query", "")).strip()
if not query:
raise HTTPException(400, "query is required")
try:
limit = max(1, min(int(body.get("limit", 6)), 15))
except (TypeError, ValueError):
raise HTTPException(400, "limit must be an integer")
local = _library_search(query, limit)
remote = _run_search(query, limit)
seen = {r["video_id"] for r in local}
merged = local + [r for r in remote if r["video_id"] not in seen]
return {"query": query, "results": merged[:limit]}
def _resolve_play_target(body: dict) -> dict:
"""Find or create the track a play request refers to.
Accepts video_id (YouTube), track_id (existing library entry) or a raw
url+name (live stream). Returns the library track dict.
"""
video_id = str(body.get("video_id") or "").strip()
track_id = str(body.get("track_id") or "").strip()
url = str(body.get("url") or "").strip()
if video_id:
existing = _get_track(video_id)
if existing:
return existing
track = {
"id": video_id,
"kind": "youtube",
"video_id": video_id,
"title": str(body.get("title") or video_id),
"channel": str(body.get("channel") or ""),
"duration": body.get("duration"),
"status": "downloading",
"error": None,
"added_at": time.time(),
}
_upsert_track(track)
_start_download(track, video_id)
return track
if track_id:
track = _get_track(track_id)
if not track:
raise HTTPException(404, "unknown track")
return track
if url:
if not re.match(r"^https?://", url):
raise HTTPException(400, "url must be http(s)")
track_id = "s" + uuid.uuid4().hex[:10]
track = {
"id": track_id,
"kind": "stream",
"video_id": None,
"title": str(body.get("name") or url),
"channel": str(body.get("channel") or ""),
"duration": None,
"status": "ready",
"error": None,
"url": url,
"added_at": time.time(),
}
_upsert_track(track)
return track
raise HTTPException(400, "one of video_id, track_id or url is required")
@app.post("/media/api/play")
async def play(request: Request) -> dict:
body = await request.json()
room = str(body.get("room") or "default")
track = _resolve_play_target(body)
with _lock:
st = _room_state(room)
st["track_id"] = track["id"]
# start_paused carries a track into a new scope without starting it —
# the UI uses this when a conversation begins (talking pauses music).
st["desired"] = "paused" if body.get("start_paused") else "playing"
st["choices"] = [] # a pick resolves any open suggestion list
st["seek_hint"] = None
snapshot = _room_snapshot(room)
return {"ok": True, **snapshot}
@app.post("/media/api/control")
async def control(request: Request) -> dict:
body = await request.json()
room = str(body.get("room") or "default")
action = str(body.get("action") or "")
with _lock:
st = _room_state(room)
if action == "pause":
st["desired"] = "paused"
elif action == "resume":
if not st["track_id"]:
raise HTTPException(409, "nothing loaded")
st["desired"] = "playing"
elif action == "stop":
st["desired"] = "stopped"
elif action == "seek":
try:
position = max(0.0, float(body.get("position", 0)))
except (TypeError, ValueError):
raise HTTPException(400, "position must be a number")
st["seek_hint"] = {"position": position, "ts": time.time()}
elif action == "skip":
current = st["track_id"]
candidates = sorted(
(
t for t in _library["tracks"].values()
if t.get("status") == "ready" and t["id"] != current
),
key=lambda t: t.get("added_at", 0),
reverse=True,
)
if candidates:
st["track_id"] = candidates[0]["id"]
st["desired"] = "playing"
st["seek_hint"] = None
else:
st["desired"] = "stopped"
else:
raise HTTPException(400, f"unknown action: {action}")
snapshot = _room_snapshot(room)
return {"ok": True, **snapshot}
@app.post("/media/api/choices")
async def choices(request: Request) -> dict:
"""Publish Hope's song suggestions so the UI can render clickable picks."""
body = await request.json()
room = str(body.get("room") or "default")
raw = body.get("choices") or []
cleaned = []
for c in raw[:5]:
if not isinstance(c, dict) or not c.get("video_id"):
continue
cleaned.append({
"video_id": str(c["video_id"]),
"title": str(c.get("title") or c["video_id"]),
"channel": str(c.get("channel") or ""),
"duration": c.get("duration"),
})
with _lock:
_room_state(room)["choices"] = cleaned
return {"ok": True, "choices": cleaned}
@app.get("/media/api/state")
def state(room: str = "default") -> dict:
with _lock:
return _room_snapshot(room)
@app.post("/media/api/report")
async def report(request: Request) -> dict:
"""The browser reports real playback state (source of truth for the player)."""
body = await request.json()
room = str(body.get("room") or "default")
try:
position = float(body.get("position") or 0.0)
except (TypeError, ValueError):
position = 0.0
with _lock:
st = _room_state(room)
st["reported"] = {
"track_id": body.get("track_id"),
"state": str(body.get("state") or "stopped"),
"position": position,
"duration": body.get("duration"),
"ts": time.time(),
}
return {"ok": True}
@app.post("/media/api/streams")
async def add_stream(request: Request) -> dict:
body = await request.json()
track = _resolve_play_target({"url": body.get("url"), "name": body.get("name")})
return {"ok": True, "track": _track_dict(track)}
# ── API: stream proxy (external http(s) audio over same-origin HTTPS) ───────
_HOP_HEADERS = ("content-type", "content-range", "accept-ranges", "content-length")
@app.get("/media/api/stream/{track_id}")
async def stream_proxy(track_id: str, request: Request):
with _lock:
track = _get_track(track_id)
job = next(
(j for j in _jobs.values()
if j["track_id"] == track_id and j["stage"] == "downloading"),
None,
)
if not track:
raise HTTPException(404, "unknown track")
if track["kind"] == "youtube":
# Progressive download: an active download is streamed in real time
# (chunked, no Content-Length) so playback starts while the file
# grows; a finished file is served directly with native Range support.
path = _youtube_media_file(track_id)
if path is None and job is not None:
path = await _wait_for_media_file(track_id)
if path is None:
raise HTTPException(
404, f"audio file not available (track is {track.get('status')})"
)
if job is not None:
return StreamingResponse(
_growing_file(track_id, job),
media_type=_media_type_for(path.name),
headers={"Cache-Control": "no-store"},
)
return FileResponse(path, media_type=_media_type_for(path.name))
range_header = request.headers.get("range")
headers = {"Range": range_header} if range_header else {}
client = httpx.AsyncClient(timeout=httpx.Timeout(30, read=600), follow_redirects=True)
try:
upstream = await client.send(client.build_request("GET", track["url"], headers=headers), stream=True)
except httpx.HTTPError as exc:
await client.aclose()
raise HTTPException(502, f"upstream stream failed: {exc}")
if upstream.status_code >= 400:
await upstream.aclose()
await client.aclose()
raise HTTPException(502, f"upstream returned {upstream.status_code}")
out_headers = {
name: value
for name, value in upstream.headers.items()
if name.lower() in _HOP_HEADERS
}
media_type = upstream.headers.get("content-type", "audio/mpeg")
async def relay():
try:
async for chunk in upstream.aiter_bytes(64 * 1024):
yield chunk
finally:
await upstream.aclose()
await client.aclose()
return StreamingResponse(
relay(),
status_code=upstream.status_code,
media_type=media_type,
headers=out_headers,
)
@app.exception_handler(HTTPException)
async def http_error(_request: Request, exc: HTTPException):
return JSONResponse({"error": exc.detail}, status_code=exc.status_code)
if __name__ == "__main__":
uvicorn.run(app, host=MEDIA_HOST, port=MEDIA_PORT, log_level="info")
+1 -300
View File
@@ -371,34 +371,6 @@ button {
transition: opacity 90ms linear, width 90ms linear, height 90ms linear;
}
/* Album art fills the square while a track is loaded outside a conversation. */
.led-art {
position: absolute;
inset: 12px;
width: calc(100% - 24px);
height: calc(100% - 24px);
object-fit: cover;
border-radius: 18px;
z-index: 1;
opacity: 0;
transform: scale(0.96);
transition: opacity 240ms ease, transform 240ms ease;
pointer-events: none;
}
.led-wall.show-art .led-art {
opacity: 1;
transform: scale(1);
}
.led-wall.show-art .led-dot {
opacity: 0 !important;
transition: none;
}
.led-wall.show-art {
filter: none;
}
/* Palette shifts by state — a cool blue while it listens, warm toward the
center as the agent speaks, amber while thinking, dim when idle. */
@@ -1201,276 +1173,6 @@ body[data-mode="text"] .mic-meter {
.task-result { margin-top: 0.5rem; color: #bcebd8; background: rgba(118, 217, 178, 0.1); }
.task-error { margin-top: 0.5rem; color: #ffc2ba; background: rgba(255, 128, 111, 0.1); }
/* ── Media player ───────────────────────────────────────────────────────── */
/* Reuses .tasks-panel positioning (fixed right, slides in via .open); the
content scrolls under a sticky header. */
.media-panel {
overflow-y: auto;
padding-bottom: 1.2rem;
}
.media-panel .tasks-header {
position: sticky;
top: 0;
z-index: 1;
background: #0d1929;
}
.media-now {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 1rem 1.1rem 0;
padding: 0.75rem;
border-radius: 12px;
background: var(--surface-raised);
}
.media-art,
.media-art-placeholder {
width: 56px;
height: 56px;
flex: 0 0 56px;
border-radius: 8px;
object-fit: cover;
background: rgba(23, 41, 65, 0.7);
}
.media-art-placeholder {
display: flex;
align-items: center;
justify-content: center;
color: var(--ink-muted);
}
.media-now-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.media-now-info strong {
color: var(--ink);
font-size: 0.88rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-now-info span {
color: var(--ink-muted);
font-size: 0.72rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-progress {
position: relative;
margin-top: 0.35rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Track bar behind the fill (fill width is driven inline by progress). */
.media-progress::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 90px;
height: 4px;
border-radius: 2px;
background: rgba(139, 167, 255, 0.18);
}
.media-progress-fill {
height: 4px;
flex: 0 0 auto;
width: 0;
border-radius: 2px;
background: var(--iris);
transition: width 400ms ease;
}
.media-progress-label {
color: var(--ink-muted);
font-size: 0.66rem;
white-space: nowrap;
}
.media-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 0.9rem;
margin: 0.9rem 1.1rem 0;
}
.media-controls .btn-icon {
color: var(--ink-soft);
}
.media-play {
width: 52px;
height: 52px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
color: #0b1523 !important;
background: var(--iris) !important;
border: none;
}
.media-play .play-icon,
.media-play .pause-icon {
display: none;
}
.media-play:not(.playing) .play-icon {
display: inline-block;
}
.media-play.playing .pause-icon {
display: inline-block;
}
.media-play:hover:not(:disabled) {
filter: brightness(1.1);
}
.media-seek {
display: flex;
align-items: center;
gap: 0.55rem;
margin: 0.75rem 1.1rem 0;
}
.media-time {
color: var(--ink-muted);
font-size: 0.68rem;
font-variant-numeric: tabular-nums;
min-width: 2.6rem;
text-align: center;
}
.media-seek input[type="range"],
.media-volume input[type="range"] {
flex: 1;
accent-color: var(--iris);
cursor: pointer;
}
.media-volume {
display: flex;
align-items: center;
gap: 0.55rem;
margin: 0.55rem 1.1rem 0;
color: var(--ink-muted);
}
.media-search {
display: flex;
gap: 0.45rem;
margin: 0 1.1rem;
}
.media-search input {
flex: 1;
min-width: 0;
padding: 0.5rem 0.7rem;
border: 1px solid var(--line-strong);
border-radius: 9px;
color: var(--ink);
background: rgba(5, 11, 20, 0.45);
font-size: 0.8rem;
}
.media-search input::placeholder {
color: var(--ink-muted);
}
.media-stream-form input:first-child {
flex: 0 0 38%;
}
.media-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin: 0.65rem 1.1rem 1rem;
max-height: 240px;
overflow-y: auto;
}
.media-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.55rem 0.65rem;
border-radius: 9px;
background: var(--surface-raised);
}
.media-row-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.media-row-info strong {
color: var(--ink);
font-size: 0.78rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-row-info span {
color: var(--ink-muted);
font-size: 0.68rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-row-play {
flex: 0 0 auto;
padding: 0.3rem 0.7rem;
font-size: 0.7rem;
}
.media-row-delete {
flex: 0 0 auto;
color: var(--ink-muted);
}
.media-row-delete:hover {
color: #ffc2ba;
}
.media-empty {
padding: 0.8rem;
border-radius: 9px;
color: var(--ink-muted);
background: var(--surface-raised);
font-size: 0.74rem;
text-align: center;
}
.media-choice {
border: 1px solid rgba(139, 167, 255, 0.35);
background: rgba(60, 95, 201, 0.12);
}
/* ── Responsive ─────────────────────────────────────────────────────────── */
@media (max-height: 760px) and (min-width: 881px) {
.workspace {
@@ -1614,8 +1316,7 @@ body[data-mode="text"] .mic-meter {
padding: 1.25rem 1rem;
}
.tasks-panel,
.media-panel {
.tasks-panel {
width: 100vw;
}
+2 -2
View File
@@ -97,8 +97,8 @@ class Handler(BaseHTTPRequestHandler):
resp = json.dumps(
{
"token": token,
# The room name scopes media-server player state; the UI sends
# it with every /media/api/ call.
# Returned for diagnostics and reconnects. Music deliberately
# uses its own stable scope in the separate Hope Music service.
"room": room_name,
"user": {
"id": user_id,