diff --git a/agent/skills/freeq-radio/SKILL.md b/agent/skills/freeq-radio/SKILL.md index 76500a4..76bec7d 100644 --- a/agent/skills/freeq-radio/SKILL.md +++ b/agent/skills/freeq-radio/SKILL.md @@ -2,10 +2,11 @@ name: freeq-radio description: >- Stream internet radio into a freeq AV (voice) call when users say play radio, - put on music, stop radio, stop media, or name a station. Watch stream.place into freeq or - publish freeq out to stream.place. Tools: play_radio, stop_radio, stop_media, radio_status, - watch_stream, publish_stream, memory_bank_add, memory_bank_list. - Needs irc-bridge control API + eve-av-bridge + ffmpeg. + put on music, stop radio, stop media, or name a station. Watch stream.place or + YouTube into freeq, or publish freeq out to stream.place. Tools: play_radio, + stop_radio, stop_media, radio_status, watch_stream, publish_stream, + memory_bank_add, memory_bank_list. + Needs irc-bridge control API + eve-av-bridge + ffmpeg (+ yt-dlp for YouTube). --- # freeq radio (AV stream) @@ -22,7 +23,7 @@ description: >- - any SomaFM channel id, or a raw http(s) stream URL - "stop radio" / "kill the music" (radio only) - "stop media" / "stop all" / "stop everything" (radio + watch + publish) -- "watch stream.place/…" / "go live on stream.place" / "publish radio to stream.place" +- "watch stream.place/…" / "watch https://youtu.be/…" / "go live on stream.place" / "publish radio to stream.place" ## Channel fast-path (no agent) @@ -41,7 +42,8 @@ These hit the irc-bridge immediately (same as `eve: watch …`): 2. **`stop_radio`** — stop radio decode/feed only (call may stay open). Prefer **`stop_media`** when they want everything off. 3. **`stop_media`** — stop **all** media: radio, stream.place watch, and stream.place publish; releases MoQ planes. 4. **`radio_status`** — live probes: control_up, av_bridge_up, radio_playing. Use when user can’t hear or before claiming anything is “missing”. -5. **`watch_stream`** — stream.place → freeq MoQ (ingress plane `:8792`). +5. **`watch_stream`** — stream.place or YouTube → freeq MoQ (ingress plane `:8792`). + - stream.place URL/handle/did, or a YouTube / youtu.be / Shorts URL. 6. **`publish_stream`** — freeq / URL → stream.place RTMP (egress / inverse plane). 7. **`memory_bank_add`** / **`memory_bank_list`** — durable song/note list on host. @@ -61,7 +63,7 @@ user → eve play_radio → irc-bridge :8791 /radio/play └─ eve-av-bridge :8790 └─ ffmpeg → PCM → MoQ SFU -watch: stream.place HLS → av-bridge :8792 → freeq MoQ +watch: stream.place WHEP / YouTube (yt-dlp) → av-bridge :8792 → freeq MoQ publish: freeq/source URL → ffmpeg RTMP → stream.place (inverse) ``` @@ -78,12 +80,14 @@ If play fails: check av-bridge health `curl :8790/health`, control `curl :8791/health`, and that `ffmpeg` is on PATH. -## stream.place (`watch` — into freeq) +## stream.place / YouTube (`watch` — into freeq) - Channel command (instant): `eve: watch https://stream.place/handle` - Also: `eve: watch handle` / `eve: watch did:plc:…` -- Tool: **`watch_stream`** for natural language ("put on iame.li stream"). -- Switches the stream.place MoQ plane (one freeq plane at a time; replaces radio). +- YouTube: `eve: watch https://youtu.be/…` / `eve: watch https://www.youtube.com/watch?v=…` +- Tool: **`watch_stream`** for natural language ("put on iame.li stream", "play this YouTube"). +- Switches the watch MoQ plane (one freeq plane at a time; replaces radio). +- YouTube needs **yt-dlp** + **ffmpeg** on the watch-plane host (`scripts/yt-watch-demux.py`). ## Stop all media diff --git a/agent/tools/watch_stream.ts b/agent/tools/watch_stream.ts index 4890f3f..e9806b0 100644 --- a/agent/tools/watch_stream.ts +++ b/agent/tools/watch_stream.ts @@ -2,22 +2,40 @@ import { defineTool } from "eve/tools"; import { z } from "zod"; /** - * Flip freeq AV to a stream.place live stream (MoQ plane via irc-bridge). + * Flip freeq AV to a stream.place live stream or a YouTube link + * (MoQ watch plane via irc-bridge). */ const CONTROL = process.env.IRC_CONTROL_URL ?? "http://127.0.0.1:8791"; +function isYoutubeUrl(raw: string): boolean { + try { + const u = new URL(raw.trim().replace(/^<|>$/g, "")); + const host = u.hostname.replace(/^www\./i, "").toLowerCase(); + return ( + host === "youtube.com" || + host === "m.youtube.com" || + host === "music.youtube.com" || + host === "youtu.be" + ); + } catch { + return false; + } +} + export default defineTool({ description: - "Watch / switch to a stream.place live stream on freeq AV. " + + "Watch / switch freeq AV to a stream.place live stream or a YouTube link. " + "Use when the user says watch, switch stream, put on stream.place, " + - "or pastes a stream.place URL/handle. Prefer this over play_radio for stream.place. " + + "play this YouTube, or pastes a stream.place / YouTube URL. " + + "Prefer this over play_radio for video. " + "After ok:true tell user to join freeq voice in that channel.", inputSchema: z.object({ streamer: z .string() .describe( - "stream.place URL (https://stream.place/handle), handle (iame.li), or did:plc:…", + "stream.place URL/handle/did:plc:… OR a YouTube URL " + + "(youtube.com/watch, youtu.be, Shorts).", ), channel: z .string() @@ -25,13 +43,18 @@ export default defineTool({ .describe("IRC channel for AV, e.g. #test. Default from bridge."), }), async execute({ streamer, channel }) { - const res = await fetch(`${CONTROL}/streamplace/play`, { + const target = String(streamer ?? "").trim(); + const youtube = isYoutubeUrl(target); + + const endpoint = youtube ? "/youtube/play" : "/streamplace/play"; + const body = youtube + ? { url: target, channel: channel || undefined } + : { streamer: target, channel: channel || undefined }; + + const res = await fetch(`${CONTROL}${endpoint}`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ - streamer, - channel: channel || undefined, - }), + body: JSON.stringify(body), signal: AbortSignal.timeout(90_000), }); const json = (await res.json().catch(() => ({}))) as { @@ -44,6 +67,7 @@ export default defineTool({ viewers?: number | null; url?: string; }; + youtube?: { url?: string; title?: string }; av?: { channel?: string; sessionId?: string }; plane?: string; }; @@ -51,19 +75,35 @@ export default defineTool({ if (!res.ok || json.ok === false) { return { ok: false, - error: json.error || `streamplace play HTTP ${res.status}`, + error: json.error || `watch play HTTP ${res.status}`, say: "Couldn't switch the stream — short apology.", }; } - const handle = json.stream?.handle ?? streamer; - const title = json.stream?.title ?? handle; const ch = json.av?.channel || channel || "#test"; + + if (youtube) { + const ytUrl = json.youtube?.url ?? target; + return { + ok: true, + kind: "youtube", + url: ytUrl, + title: json.youtube?.title ?? ytUrl, + channel: ch, + sessionId: json.av?.sessionId, + plane: json.plane, + say: `Now playing YouTube on freeq AV in ${ch} — join voice there. ${ytUrl}`, + }; + } + + const handle = json.stream?.handle ?? target; + const title = json.stream?.title ?? handle; const viewers = json.stream?.viewers != null ? `${json.stream.viewers} watching` : "live"; return { ok: true, + kind: "streamplace", handle, title, viewers: json.stream?.viewers ?? null, diff --git a/flake.nix b/flake.nix index c2cf13d..b1d97bb 100644 --- a/flake.nix +++ b/flake.nix @@ -16,14 +16,34 @@ system: let pkgs = import nixpkgs { inherit system; }; + # Runtime CLIs for radio / stream-watch / YouTube (av-bridge demux). + mediaTools = pkgs.symlinkJoin { + name = "eve-media-tools"; + paths = [ + pkgs.ffmpeg + pkgs.yt-dlp + ]; + }; in { + packages = { + media-tools = mediaTools; + default = mediaTools; + }; + + apps.media-tools = { + type = "app"; + program = "${mediaTools}/bin/ffmpeg"; + }; + devShells.default = pkgs.mkShell { packages = [ pkgs.nodejs_24 pkgs.typescript pkgs.curl pkgs.jq + pkgs.ffmpeg + pkgs.yt-dlp ]; shellHook = '' @@ -32,6 +52,7 @@ echo " npm run dev # local eve (needs model API keys in env)" echo " npm run typecheck" echo " npm run boxd:start # OpenBao key bridge + eve on :8000" + echo " media: ffmpeg=$(command -v ffmpeg) yt-dlp=$(command -v yt-dlp)" ''; }; diff --git a/irc-bridge/README.md b/irc-bridge/README.md index af28faf..ae174e7 100644 --- a/irc-bridge/README.md +++ b/irc-bridge/README.md @@ -63,7 +63,7 @@ node irc-bridge/server.mjs | Plane | Port | Role | Control | |-------|------|------|---------| | radio | `:8790` | internet radio only | `/v1/radio/*` | -| stream-watch | `:8792` | stream.place HLS → freeq | `/v1/watch/*` | +| stream-watch | `:8792` | stream.place WHEP / YouTube → freeq | `/v1/watch/*` | | stream-broadcast | `:8793` | freeq call → stream.place RTMP | `/v1/call-egress/*` | Only **one** freeq tile plays at a time: starting radio, watch, or broadcast @@ -80,6 +80,7 @@ releases the other planes (`exclusivePlane`). | `/media/stop` | | stop **all** media (radio + watch + publish + release planes) | | `/radio/now-playing` | `{ title, channel? }` | announce song (from av-bridge `RADIO_TITLE_HOOK` or tooling) | | `/streamplace/play` | `{ streamer?, channel? }` | stream.place → freeq MoQ (watch plane) | +| `/youtube/play` | `{ url, channel? }` | YouTube → freeq MoQ (same watch plane) | | `/streamplace/stop` | | stop watch plane | | `/streamplace/publish` | `{ url?, title?, mode?, channel? }` | freeq/URL → stream.place RTMP (inverse) | | `/streamplace/publish/stop` | | stop RTMP publish | @@ -94,8 +95,8 @@ just because the token rotated — that drops the freeq TCP session and thrashs AV. Prefer `POST /session/reload`; use `force: true` only when you need a deliberate re-auth while healthy. -Needs **eve-av-bridge** running with **ffmpeg** for radio / watch. Publish uses -**ffmpeg** in this process (RTMP egress). +Needs **eve-av-bridge** running with **ffmpeg** for radio / watch. YouTube watch +also needs **yt-dlp**. Publish uses **ffmpeg** in this process (RTMP egress). ### Now-playing (song changes) @@ -123,6 +124,7 @@ Two paths (deduped): | `eve: radio fluid` / `eve: play groove salad` | same (aliases / multi-word names ok) | | `eve: watch https://stream.place/` | switch stream.place rebroadcast to that streamer | | `eve: watch ` / `eve: watch did:plc:…` | same | +| `eve: watch https://youtu.be/…` / YouTube URL | play YouTube on freeq AV (yt-dlp) | | `eve: go live` | publish active freeq radio source → stream.place RTMP | | `eve: go live ` | publish that URL → stream.place | | `eve: go live av ` | re-encode source video+audio (default is audio+slate) | diff --git a/irc-bridge/server.mjs b/irc-bridge/server.mjs index 0ac47ac..2ad272c 100644 --- a/irc-bridge/server.mjs +++ b/irc-bridge/server.mjs @@ -77,7 +77,7 @@ const CONTROL_PORT = Number(process.env.IRC_CONTROL_PORT ?? 8791); /** * Three freeq MoQ planes (separate eve-av-bridge processes — do not munge): * radio :8790 internet radio only AV_PLANE_ROLE=radio - * stream-watch:8792 stream.place → freeq HLS AV_PLANE_ROLE=watch + * stream-watch:8792 stream.place → freeq (WHEP) AV_PLANE_ROLE=watch * stream-broadcast :8793 freeq call → stream.place RTMP AV_PLANE_ROLE=broadcast */ const RADIO_AV_BRIDGE_URL = ( @@ -2195,7 +2195,6 @@ async function pickTopStreamplaceStream() { const title = top?.record?.title ?? handle; const count = viewers(top); if (!did) throw new Error("top stream missing author.did"); - const hls = `${STREAMPLACE_API}/xrpc/place.stream.playback.getLivePlaylist?streamer=${encodeURIComponent(did)}`; const whep = `${STREAMPLACE_API}/xrpc/place.stream.playback.whep?streamer=${encodeURIComponent(did)}&rendition=${encodeURIComponent(process.env.STREAMPLACE_WHEP_RENDITION || "source")}`; return { did, @@ -2203,7 +2202,6 @@ async function pickTopStreamplaceStream() { title: String(title), viewers: count, url: top?.record?.url ?? `https://stream.place/${did}`, - hls, whep, ranked: streams.slice(0, 5).map((s) => ({ handle: s?.author?.handle, @@ -2215,7 +2213,48 @@ async function pickTopStreamplaceStream() { } /** - * Parse a stream.place URL, handle, or DID into a streamer id for the XRPC playlist. + * Parse a YouTube watch / Shorts / youtu.be URL into a canonical watch URL. + * @param {string} raw + * @returns {string | null} + */ +function parseYoutubeUrl(raw) { + let s = String(raw ?? "").trim(); + if (!s) return null; + s = s.replace(/^<|>$/g, "").trim(); + try { + const u = new URL(s); + const host = u.hostname.replace(/^www\./i, "").toLowerCase(); + if ( + host === "youtube.com" || + host === "m.youtube.com" || + host === "music.youtube.com" + ) { + const v = u.searchParams.get("v"); + if (v && /^[a-zA-Z0-9_-]{11}$/.test(v)) { + return `https://www.youtube.com/watch?v=${v}`; + } + const shorts = u.pathname.match(/\/shorts\/([a-zA-Z0-9_-]{11})/); + if (shorts) return `https://www.youtube.com/watch?v=${shorts[1]}`; + const embed = u.pathname.match(/\/embed\/([a-zA-Z0-9_-]{11})/); + if (embed) return `https://www.youtube.com/watch?v=${embed[1]}`; + const live = u.pathname.match(/\/live\/([a-zA-Z0-9_-]{11})/); + if (live) return `https://www.youtube.com/watch?v=${live[1]}`; + return null; + } + if (host === "youtu.be") { + const id = u.pathname.replace(/^\/+/, "").split("/")[0]; + if (/^[a-zA-Z0-9_-]{11}$/.test(id)) { + return `https://www.youtube.com/watch?v=${id}`; + } + } + } catch { + return null; + } + return null; +} + +/** + * Parse a stream.place URL, handle, or DID into a streamer id for WHEP. * @param {string} raw * @returns {string | null} */ @@ -2253,7 +2292,7 @@ function parseStreamplaceTarget(raw) { /** * Fast-path: `watch ` (and close variants) from a mention/DM — - * flip stream.place plane without an agent round-trip. + * flip stream.place or YouTube onto the watch plane without an agent round-trip. * @returns {boolean} true if handled (caller should not forward to eve agent) */ function tryHandleWatch(ircClient, replyTarget, channel, body) { @@ -2262,29 +2301,37 @@ function tryHandleWatch(ircClient, replyTarget, channel, body) { /** @type {string | null} */ let streamer = null; + /** @type {string | null} */ + let youtube = null; // `watch ` / `watch: ` / `watch please…` let m = raw.match(/^watch(?:\s+|:\s*)(\S+)/i); if (m) { - streamer = parseStreamplaceTarget(m[1]); + youtube = parseYoutubeUrl(m[1]); + if (!youtube) streamer = parseStreamplaceTarget(m[1]); } // Bare `watch` → restore last pref, else top live (playStreamplace handles empty). - if (!streamer && /^watch\s*$/i.test(raw)) { + if (!streamer && !youtube && /^watch\s*$/i.test(raw)) { streamer = loadStreamplacePref()?.streamer || undefined; // empty string signal → playStreamplace picks top live when streamer omitted if (!streamer) streamer = ""; } // `watch stream.place/x` without scheme, or pasted URL as whole body after watch word - if (streamer === null && /^watch\b/i.test(raw)) { + if (streamer === null && youtube === null && /^watch\b/i.test(raw)) { const rest = raw.replace(/^watch(?:\s+|:\s*)?/i, "").trim(); - if (rest) streamer = parseStreamplaceTarget(rest.split(/\s+/)[0]); + if (rest) { + const token = rest.split(/\s+/)[0]; + youtube = parseYoutubeUrl(token); + if (!youtube) streamer = parseStreamplaceTarget(token); + } } // Not a watch command at all - if (streamer === null && !/^watch\b/i.test(raw)) return false; - if (streamer === null) { + if (streamer === null && youtube === null && !/^watch\b/i.test(raw)) + return false; + if (streamer === null && youtube === null) { ircClient.sendPrivmsg( replyTarget, - "usage: watch ", + "usage: watch ", ); return true; } @@ -2292,6 +2339,14 @@ function tryHandleWatch(ircClient, replyTarget, channel, body) { const ch = channel || IRC_CHANNEL; void (async () => { try { + if (youtube) { + log(`watch youtube → ${youtube} on ${ch}`); + const out = await playYoutube({ channel: ch, url: youtube }); + log( + `watch youtube ok url=${String(out.youtube?.url ?? youtube).slice(0, 60)} session=${out.av?.sessionId ?? "?"}`, + ); + return; + } log(`watch command → ${streamer || "(top/pref)"} on ${ch}`); const out = await playStreamplace({ channel: ch, @@ -2318,6 +2373,80 @@ function tryHandleWatch(ircClient, replyTarget, channel, body) { return true; } +/** + * YouTube page URL → freeq AV on the stream-watch MoQ plane (:8792). + * av-bridge resolves via yt-dlp demux. + */ +async function playYoutube({ channel, url } = {}) { + const ch = channel ?? IRC_CHANNEL; + const pageUrl = parseYoutubeUrl(url) || String(url ?? "").trim(); + if (!pageUrl || !parseYoutubeUrl(pageUrl)) { + throw new Error("not a YouTube URL"); + } + + const title = `youtube: ${pageUrl}`; + log(`youtube play ${pageUrl} → ${ch} plane=${STREAM_WATCH_AV_BRIDGE_URL}`); + + stopMediaFollow("playYoutube exclusive"); + await exclusivePlane(STREAM_WATCH_AV_BRIDGE_URL); + + let av; + try { + av = await ensureAv(ch, title.slice(0, 120), STREAMPLACE_AV_BRIDGE_URL); + } catch (e) { + log( + `youtube ensureAv: ${e instanceof Error ? e.message : e}; trying play anyway`, + ); + av = { channel: ch, error: String(e) }; + } + + let res; + let json = {}; + try { + res = await fetch(`${STREAM_WATCH_AV_BRIDGE_URL}/v1/watch/play`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ url: pageUrl }), + signal: AbortSignal.timeout(90_000), + }); + } catch (e) { + const cause = e instanceof Error ? e.message : String(e); + throw new Error( + `watch plane unreachable (${STREAM_WATCH_AV_BRIDGE_URL}): ${cause}. ` + + `Is eve-av-bridge-streamplace running?`, + ); + } + json = await res.json().catch(() => ({})); + if (!res.ok || json.ok === false) { + throw new Error(json.error || `stream-watch /v1/watch/play ${res.status}`); + } + log(`youtube watch transport=yt-dlp`); + + startMediaFollow({ + channel: ch, + bridgeUrl: STREAMPLACE_AV_BRIDGE_URL, + title: title.slice(0, 120), + radioUrl: pageUrl, + }); + + try { + const notice = + `youtube → freeq AV: ${pageUrl} — open voice in ${ch.startsWith("#") ? ch : "#" + ch} (eve will join your room)`.slice( + 0, + 400, + ); + irc.sendPrivmsg(ch.startsWith("#") ? ch : `#${ch}`, notice); + } catch (e) { + log(`youtube notice: ${e instanceof Error ? e.message : e}`); + } + + return { + av, + youtube: { url: pageUrl, title: pageUrl }, + plane: STREAMPLACE_AV_BRIDGE_URL, + }; +} + /** stream.place → freeq AV on the dedicated MoQ plane (:8792 by default). */ async function playStreamplace({ channel, streamer } = {}) { const ch = channel ?? IRC_CHANNEL; @@ -2330,7 +2459,6 @@ async function playStreamplace({ channel, streamer } = {}) { title: id, viewers: null, url: `https://stream.place/${id}`, - hls: `${STREAMPLACE_API}/xrpc/place.stream.playback.getLivePlaylist?streamer=${encodeURIComponent(id)}`, whep: `${STREAMPLACE_API}/xrpc/place.stream.playback.whep?streamer=${encodeURIComponent(id)}&rendition=${encodeURIComponent(process.env.STREAMPLACE_WHEP_RENDITION || "source")}`, ranked: [], }; @@ -2372,7 +2500,6 @@ async function playStreamplace({ channel, streamer } = {}) { picked.url = hit?.record?.url ?? `https://stream.place/${picked.did || id}`; const streamerId = picked.did || id; - picked.hls = `${STREAMPLACE_API}/xrpc/place.stream.playback.getLivePlaylist?streamer=${encodeURIComponent(streamerId)}`; picked.whep = `${STREAMPLACE_API}/xrpc/place.stream.playback.whep?streamer=${encodeURIComponent(streamerId)}&rendition=${encodeURIComponent(process.env.STREAMPLACE_WHEP_RENDITION || "source")}`; } } @@ -2406,46 +2533,32 @@ async function playStreamplace({ channel, streamer } = {}) { av = { channel: ch, error: String(e) }; } - // stream-watch plane only — never radio/play. - // Prefer WHEP (low-latency WebRTC); fall back to HLS if play fails. - const preferWhep = process.env.STREAMPLACE_WATCH_TRANSPORT !== "hls"; - const playUrls = preferWhep - ? [picked.whep || picked.hls, picked.hls].filter(Boolean) - : [picked.hls, picked.whep].filter(Boolean); + // stream-watch plane: WHEP only. + if (!picked.whep) { + throw new Error("streamplace watch: missing WHEP url"); + } + const playUrl = picked.whep; let res; let json = {}; - let usedUrl = playUrls[0]; - let lastErr = null; - for (const playUrl of playUrls) { - usedUrl = playUrl; - try { - res = await fetch(`${STREAM_WATCH_AV_BRIDGE_URL}/v1/watch/play`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ url: playUrl }), - signal: AbortSignal.timeout(60_000), - }); - } catch (e) { - const cause = e instanceof Error ? e.message : String(e); - throw new Error( - `watch plane unreachable (${STREAM_WATCH_AV_BRIDGE_URL}): ${cause}. ` + - `Is eve-av-bridge-streamplace running?`, - ); - } - json = await res.json().catch(() => ({})); - if (res.ok && json.ok !== false) { - log( - `streamplace watch transport=${String(playUrl).includes("playback.whep") ? "whep" : "hls"}`, - ); - lastErr = null; - break; - } - lastErr = json.error || `stream-watch /v1/watch/play ${res.status}`; - log(`streamplace watch play failed (${lastErr}); trying next transport`); + try { + res = await fetch(`${STREAM_WATCH_AV_BRIDGE_URL}/v1/watch/play`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ url: playUrl }), + signal: AbortSignal.timeout(60_000), + }); + } catch (e) { + const cause = e instanceof Error ? e.message : String(e); + throw new Error( + `watch plane unreachable (${STREAM_WATCH_AV_BRIDGE_URL}): ${cause}. ` + + `Is eve-av-bridge-streamplace running?`, + ); } - if (lastErr) { - throw new Error(lastErr); + json = await res.json().catch(() => ({})); + if (!res.ok || json.ok === false) { + throw new Error(json.error || `stream-watch /v1/watch/play ${res.status}`); } + log(`streamplace watch transport=whep`); // Keep following freeq rooms for the whole watch lifetime (not just ~30s). // freeq clients thrash rooms; without this, eve ends alone in a zombie @@ -2454,7 +2567,7 @@ async function playStreamplace({ channel, streamer } = {}) { channel: ch, bridgeUrl: STREAMPLACE_AV_BRIDGE_URL, title: title.slice(0, 120), - radioUrl: usedUrl, + radioUrl: playUrl, }); // Optional channel notice (once per start). @@ -4385,6 +4498,24 @@ const controlServer = http.createServer(async (req, res) => { sendJson(res, 200, { ok: true, ...out }); return; } + // YouTube → same watch plane (yt-dlp demux) + if ( + req.method === "POST" && + (url.pathname === "/youtube/play" || url.pathname === "/watch/youtube") + ) { + const body = await readJson(req); + const ytUrl = body.url ?? body.youtube ?? body.link; + if (!ytUrl) { + sendJson(res, 400, { ok: false, error: "url required" }); + return; + } + const out = await playYoutube({ + channel: body.channel, + url: String(ytUrl), + }); + sendJson(res, 200, { ok: true, ...out }); + return; + } if (req.method === "POST" && url.pathname === "/streamplace/stop") { const out = await stopStreamplace(); sendJson(res, 200, out); diff --git a/scripts/export-youtube-cookies.py b/scripts/export-youtube-cookies.py new file mode 100755 index 0000000..a419fbd --- /dev/null +++ b/scripts/export-youtube-cookies.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Export decrypted Chromium cookies to Netscape format for yt-dlp. + +Linux Chromium: v10/v11 are AES-CBC (not GCM). Key from libsecret +"Chromium Safe Storage", PBKDF2-SHA1(password, saltysalt, 1, 16). +""" +from __future__ import annotations + +import hashlib +import os +import shutil +import sqlite3 +import subprocess +import tempfile +from pathlib import Path + +from Crypto.Cipher import AES +from Crypto.Util.Padding import unpad + +OUT = Path.home() / ".config/eve/youtube-cookies.txt" +COOKIES_DB = Path.home() / ".config/chromium/Default/Cookies" + + +def safe_storage_password() -> bytes: + out = subprocess.check_output( + ["secret-tool", "lookup", "application", "chromium"], + text=True, + ).strip() + if not out: + raise SystemExit("no Chromium Safe Storage secret in keyring") + return out.encode() + + +def derive_key(password: bytes) -> bytes: + return hashlib.pbkdf2_hmac("sha1", password, b"saltysalt", 1, dklen=16) + + +def decrypt_cbc(keys: list[bytes], blob: bytes, hash_prefix: bool) -> str | None: + ciphertext = blob[3:] # strip v10/v11 + iv = b" " * 16 + for key in keys: + try: + cipher = AES.new(key, AES.MODE_CBC, iv) + plain = unpad(cipher.decrypt(ciphertext), 16) + if hash_prefix: + plain = plain[32:] + return plain.decode("utf-8") + except Exception: + continue + return None + + +def main() -> None: + pw = safe_storage_password() + keys = [derive_key(pw), derive_key(b""), derive_key(b"peanuts")] + + tmp = Path(tempfile.mkdtemp()) / "Cookies" + shutil.copy2(COOKIES_DB, tmp) + con = sqlite3.connect(f"file:{tmp}?mode=ro", uri=True) + con.row_factory = sqlite3.Row + + meta_version = 0 + try: + meta_version = int( + con.execute("SELECT value FROM meta WHERE key = 'version'").fetchone()[0] + ) + except Exception: + pass + hash_prefix = meta_version >= 24 + print(f"meta_version={meta_version} hash_prefix={hash_prefix}") + + cur = con.execute( + "SELECT host_key, name, value, encrypted_value, path, expires_utc, is_secure FROM cookies" + ) + + OUT.parent.mkdir(parents=True, exist_ok=True) + ok = fail = 0 + auth = [] + with OUT.open("w") as f: + f.write("# Netscape HTTP Cookie File\n# decrypted from chromium (AES-CBC)\n\n") + for r in cur: + host = r["host_key"] + name = r["name"] + val = r["value"] or "" + enc = r["encrypted_value"] + if not val and enc: + blob = bytes(enc) + if blob.startswith((b"v10", b"v11")): + val = decrypt_cbc(keys, blob, hash_prefix) or "" + else: + try: + val = blob.decode("utf-8") + except Exception: + val = "" + if not val: + fail += 1 + continue + exp = int(r["expires_utc"] or 0) + exp = exp // 1_000_000 - 11644473600 if exp > 0 else 0 + secure = "TRUE" if r["is_secure"] else "FALSE" + sub = "TRUE" if host.startswith(".") else "FALSE" + path = r["path"] or "/" + val = val.replace("\n", "").replace("\r", "") + f.write(f"{host}\t{sub}\t{path}\t{secure}\t{exp}\t{name}\t{val}\n") + ok += 1 + if "youtube" in host and name in { + "SID", + "SAPISID", + "__Secure-1PSID", + "LOGIN_INFO", + "APISID", + "SSID", + "__Secure-3PSID", + }: + auth.append((host, name, len(val))) + + os.chmod(OUT, 0o600) + print(f"wrote {OUT} ok={ok} fail={fail}") + print("youtube auth:", auth) + + +if __name__ == "__main__": + main() diff --git a/scripts/prep.sh b/scripts/prep.sh index bfe6155..6bebd4c 100755 --- a/scripts/prep.sh +++ b/scripts/prep.sh @@ -114,14 +114,34 @@ set_default AV_BRIDGE_BIND "127.0.0.1:8790" set_default RADIO_TITLE_HOOK "http://127.0.0.1:8791/radio/now-playing" set_default MEMORY_BANK_PATH "${HOME}/memory-bank.txt" -# PATH for services (annas-mcp, ffmpeg, etc.) -env_set PATH "${HOME}/.local/bin:/usr/local/bin:/usr/bin:/bin" +# PATH for services (annas-mcp, nix profile ffmpeg/yt-dlp, etc.) +env_set PATH "${HOME}/.nix-profile/bin:${HOME}/.local/bin:/usr/local/bin:/usr/bin:/bin" env_set HOME "${HOME}" env_set EVE_ROOT "${ROOT}" echo "[prep] model: ${OPENCODE_MODEL}" echo "[prep] irc: ${IRC_NICK}@${IRC_HOST} → ${IRC_CHANNEL} via bridge → ${EVE_URL}" +# --- media tools (ffmpeg + yt-dlp from flake) -------------------------------- +if command -v nix >/dev/null 2>&1; then + if ! command -v ffmpeg >/dev/null 2>&1 || ! command -v yt-dlp >/dev/null 2>&1; then + echo "[prep] installing flake media-tools (ffmpeg + yt-dlp) into nix profile ..." + nix --extra-experimental-features 'nix-command flakes' profile install \ + "${ROOT}#media-tools" \ + || echo "[prep] warning: nix profile install #media-tools failed" + fi +fi +if command -v ffmpeg >/dev/null 2>&1; then + echo "[prep] ffmpeg: $(command -v ffmpeg)" +else + echo "[prep] ffmpeg missing — radio/watch need nix profile install .#media-tools" +fi +if command -v yt-dlp >/dev/null 2>&1; then + echo "[prep] yt-dlp: $(command -v yt-dlp)" +else + echo "[prep] yt-dlp missing — YouTube watch needs nix profile install .#media-tools" +fi + # --- freeq / rook session --------------------------------------------------- if command -v npx >/dev/null 2>&1; then echo "[prep] rook login ..." diff --git a/scripts/run-av-bridge-service.sh b/scripts/run-av-bridge-service.sh index 794cb0c..f305d9d 100755 --- a/scripts/run-av-bridge-service.sh +++ b/scripts/run-av-bridge-service.sh @@ -3,7 +3,7 @@ # Prefer a prebuilt binary (boxd static deploy); fall back to freeq cargo/nix. set -euo pipefail -export PATH="${HOME}/.local/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}" +export PATH="${HOME}/.nix-profile/bin:${HOME}/.local/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" BIND="${AV_BRIDGE_BIND:-127.0.0.1:8790}" diff --git a/scripts/run-av-bridge-streamplace.sh b/scripts/run-av-bridge-streamplace.sh index 8e26249..fa92427 100755 --- a/scripts/run-av-bridge-streamplace.sh +++ b/scripts/run-av-bridge-streamplace.sh @@ -1,15 +1,27 @@ #!/usr/bin/env bash -# stream-watch MoQ plane: stream.place HLS → freeq (not radio). +# stream-watch MoQ plane: stream.place WHEP / YouTube → freeq (not radio). set -euo pipefail -export PATH="${HOME}/.local/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}" +export PATH="${HOME}/.nix-profile/bin:${HOME}/.local/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" export AV_BRIDGE_BIND="${STREAM_WATCH_AV_BRIDGE_BIND:-${STREAMPLACE_AV_BRIDGE_BIND:-127.0.0.1:8792}}" export AV_PLANE_ROLE=watch -# P360 freeq tile at continuous 20 fps — matches demux drop-frame in watch.rs. -# 360p @ ~1.2Mbps stays workable on 2-core boxd without starving Opus. +# P360 freeq tile at 15 fps / 400kbps — smooth on 2-core boxd. +# (360p@20 / 1.2Mbps + KF every 3 frames was choppy.) export AV_VIDEO_PRESET="${AV_VIDEO_PRESET:-360p}" -export AV_VIDEO_FPS="${AV_VIDEO_FPS:-20}" -export AV_VIDEO_BITRATE="${AV_VIDEO_BITRATE:-1200000}" +export AV_VIDEO_FPS="${AV_VIDEO_FPS:-15}" +export AV_VIDEO_BITRATE="${AV_VIDEO_BITRATE:-400000}" +export AV_VIDEO_KEYFRAME_INTERVAL="${AV_VIDEO_KEYFRAME_INTERVAL:-15}" +# Demux helpers (WHEP WebRTC + YouTube yt-dlp). +export WHEP_DEMUX_PATH="${WHEP_DEMUX_PATH:-$ROOT/scripts/whep-watch-demux.py}" +export YT_DEMUX_PATH="${YT_DEMUX_PATH:-$ROOT/scripts/yt-watch-demux.py}" +# yt-dlp YouTube needs a JS runtime; node is on the boxd image. +export YT_JS_RUNTIME="${YT_JS_RUNTIME:-node}" +# Optional Netscape cookies file for bot-gated datacenter IPs: +# export YT_COOKIES=$HOME/.config/eve/youtube-cookies.txt +export YT_COOKIES="${YT_COOKIES:-$HOME/.config/eve/youtube-cookies.txt}" +if [ ! -f "${YT_COOKIES}" ]; then + unset YT_COOKIES +fi # Never announce ICY titles from the watch plane. export RADIO_TITLE_HOOK= echo "[av-bridge-stream-watch] bind=$AV_BRIDGE_BIND role=$AV_PLANE_ROLE preset=$AV_VIDEO_PRESET fps=$AV_VIDEO_FPS br=$AV_VIDEO_BITRATE" diff --git a/scripts/whep-watch-demux.py b/scripts/whep-watch-demux.py index e58a4dc..ed72d67 100755 --- a/scripts/whep-watch-demux.py +++ b/scripts/whep-watch-demux.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +#!/home/boxd/.venvs/whep/bin/python """Streamplace WHEP → eve watch raw pipes (RGBA tile + f32le mono PCM). Drop-to-live + frame-aligned fifo: @@ -24,6 +24,8 @@ import aiohttp import av import numpy as np from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription +from aiortc.jitterbuffer import JitterBuffer +from aiortc.rtcrtpreceiver import RTCRtpReceiver from av.audio.resampler import AudioResampler LOG = logging.getLogger("whep-demux") @@ -33,6 +35,26 @@ WATCH_H = 360 SPEAK_RATE = 48_000 FRAME_BYTES = WATCH_W * WATCH_H * 4 +# aiortc defaults video jitter buffer to 128 RTP packets. A 1080p IDR is often +# 150–250+ FU-A packets; overflow yields garbage AUs (no start codes) and +# silent video forever — stream.place also discards RTCP NACK/PLI. +_VIDEO_JITTER_CAP = int(os.environ.get("WHEP_VIDEO_JITTER_PACKETS", "1024")) +_orig_receiver_init = RTCRtpReceiver.__init__ + + +def _receiver_init_large_jitter(self, kind: str, transport) -> None: # type: ignore[no-untyped-def] + _orig_receiver_init(self, kind, transport) + if kind == "video": + cap = 1 + while cap < max(128, _VIDEO_JITTER_CAP): + cap <<= 1 + self._RTCRtpReceiver__jitter_buffer = JitterBuffer( + capacity=cap, is_video=True + ) + + +RTCRtpReceiver.__init__ = _receiver_init_large_jitter # type: ignore[method-assign] + def letterbox_rgba(frame: av.VideoFrame, tw: int, th: int) -> bytes: src = frame.reformat(format="rgba") @@ -239,6 +261,34 @@ async def audio_consumer(track, stop: asyncio.Event) -> None: pass +async def request_video_pli(pc: RTCPeerConnection) -> int: + """Best-effort RTCP PLI (aiortc private API). + + Note: stream.place's WHEP path currently *discards* inbound RTCP, so PLI + does not force a keyframe. Video unlocks when a full IDR FU-A chain arrives + intact (~1s segment boundaries). Keep this for peers that do honor PLI. + """ + sent = 0 + for transceiver in pc.getTransceivers(): + receiver = transceiver.receiver + track = getattr(receiver, "track", None) + if track is None or getattr(track, "kind", None) != "video": + continue + active = getattr(receiver, "_RTCRtpReceiver__active_ssrc", None) + if not isinstance(active, dict) or not active: + continue + send_pli = getattr(receiver, "_send_rtcp_pli", None) + if send_pli is None: + continue + for ssrc in list(active.keys()): + try: + await send_pli(int(ssrc)) + sent += 1 + except Exception as e: + LOG.warning("PLI ssrc=%s failed: %s", ssrc, e) + return sent + + async def run(whep_url: str, video_fifo: str) -> int: stop = asyncio.Event() loop = asyncio.get_running_loop() @@ -255,6 +305,9 @@ async def run(whep_url: str, video_fifo: str) -> int: tasks: list[asyncio.Task] = [] latest: dict = {} session_url: Optional[str] = None + # stream.place ignores PLI; need an intact IDR over UDP. Restarting too + # fast under load drops the next IDR FU-A chain and loops forever. + first_frame_deadline = float(os.environ.get("WHEP_FIRST_FRAME_SECS", "45")) def _log_task(t: asyncio.Task) -> None: try: @@ -294,6 +347,7 @@ async def run(whep_url: str, video_fifo: str) -> int: assert pc.localDescription is not None LOG.info("POST WHEP (%s bytes sdp)", len(pc.localDescription.sdp)) timeout = aiohttp.ClientTimeout(total=30) + exit_code = 0 async with aiohttp.ClientSession(timeout=timeout) as session: async with session.post( whep_url, @@ -309,8 +363,29 @@ async def run(whep_url: str, video_fifo: str) -> int: await pc.setRemoteDescription(RTCSessionDescription(sdp=body, type="answer")) LOG.info("WHEP answer ok session=%s", session_url) + t0 = asyncio.get_running_loop().time() + last_pli = 0.0 + last_wait_log = 0.0 while not stop.is_set(): await asyncio.sleep(0.25) + now = asyncio.get_running_loop().time() + if latest.get("frame") is None and (now - last_pli) >= 2.0: + n = await request_video_pli(pc) + if n and (now - last_wait_log) >= 5.0: + LOG.info( + "waiting for intact IDR (PLI sent=%s, %.0fs elapsed)", + n, + now - t0, + ) + last_wait_log = now + last_pli = now + if latest.get("frame") is None and (now - t0) >= first_frame_deadline: + LOG.error( + "no decoded video frame within %.0fs — exiting to resync", + first_frame_deadline, + ) + exit_code = 3 + break if tasks and not any(not t.done() for t in tasks): LOG.info("all media tasks done") break @@ -331,7 +406,7 @@ async def run(whep_url: str, video_fifo: str) -> int: LOG.info("WHEP DELETE %s → %s", loc, dresp.status) except Exception as e: LOG.warning("WHEP DELETE failed: %s", e) - return 0 + return exit_code def main() -> int: diff --git a/scripts/yt-watch-demux.py b/scripts/yt-watch-demux.py new file mode 100755 index 0000000..ec5ca14 --- /dev/null +++ b/scripts/yt-watch-demux.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""YouTube (yt-dlp) → eve watch raw pipes (RGBA tile + f32le mono PCM). + +Same contract as whep-watch-demux.py: + --url --video-fifo + video → RGBA 640x360 frames on the fifo + audio → mono f32le @ 48 kHz on stdout + +Split A/V ffmpeg processes (same pattern as the old HLS watch path) so the +RGBA fifo open cannot deadlock against stdout setup. + +Requires: yt-dlp + ffmpeg on PATH. +""" + +from __future__ import annotations + +import argparse +import logging +import os +import shutil +import signal +import subprocess +import sys +import threading + +LOG = logging.getLogger("yt-demux") + +WATCH_W = 640 +WATCH_H = 360 +SPEAK_RATE = 48_000 + +# Prefer ≤720p progressive or DASH A/V; fall back to any best. +YT_FORMAT = os.environ.get( + "YT_WATCH_FORMAT", + "bv*[height<=720]+ba/b[height<=720]/b", +) + + +def which(name: str) -> str: + p = shutil.which(name) + if not p: + raise SystemExit(f"{name} not found on PATH") + return p + + +def resolve_urls(yt_dlp: str, page_url: str) -> tuple[list[str], str]: + """Return (media_urls, title). media_urls is 1 (muxed) or 2 (v+a) from -g.""" + title = page_url + # yt-dlp YouTube extractor requires a JS runtime (node/deno/bun). + js_runtime = os.environ.get("YT_JS_RUNTIME", "node") + extra: list[str] = [] + if js_runtime and js_runtime.lower() not in ("0", "none", "off"): + extra.extend(["--js-runtimes", js_runtime]) + # EJS challenge solver (required on recent YouTube / datacenter IPs). + remote_components = os.environ.get("YT_REMOTE_COMPONENTS", "ejs:github") + if remote_components and remote_components.lower() not in ("0", "none", "off"): + extra.extend(["--remote-components", remote_components]) + # Datacenter IPs often need cookies (export from a logged-in browser). + cookies = os.environ.get("YT_COOKIES") or os.environ.get("YTDLP_COOKIES") + if cookies and os.path.isfile(cookies): + extra.extend(["--cookies", cookies]) + extractor_args = os.environ.get( + "YT_EXTRACTOR_ARGS", + "youtube:player_client=tv,android,web", + ) + if extractor_args: + extra.extend(["--extractor-args", extractor_args]) + + try: + t = subprocess.run( + [yt_dlp, "--no-playlist", *extra, "--print", "%(title)s", page_url], + check=False, + capture_output=True, + text=True, + timeout=60, + ) + if t.returncode == 0 and t.stdout.strip(): + title = t.stdout.strip().splitlines()[0].strip() + except Exception as e: + LOG.warning("title probe failed: %s", e) + + g = subprocess.run( + [yt_dlp, "--no-playlist", *extra, "-f", YT_FORMAT, "-g", page_url], + check=False, + capture_output=True, + text=True, + timeout=120, + ) + if g.returncode != 0: + err = (g.stderr or g.stdout or "").strip()[:500] + raise SystemExit(f"yt-dlp -g failed: {err or g.returncode}") + urls = [ln.strip() for ln in g.stdout.splitlines() if ln.strip()] + if not urls: + raise SystemExit("yt-dlp -g returned no URLs") + LOG.info("resolved %d media url(s) title=%r", len(urls), title[:80]) + return urls, title + + +def audio_cmd(ffmpeg: str, media_urls: list[str]) -> list[str]: + cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-re"] + if len(media_urls) >= 2: + # DASH: second URL is audio for bv*+ba. + cmd.extend(["-i", media_urls[1]]) + amap = "0:a:0" + else: + cmd.extend(["-i", media_urls[0]]) + amap = "0:a:0" + cmd.extend( + [ + "-map", + amap, + "-vn", + "-ac", + "1", + "-ar", + str(SPEAK_RATE), + "-f", + "f32le", + "pipe:1", + ] + ) + return cmd + + +def video_cmd(ffmpeg: str, media_urls: list[str], video_fifo: str) -> list[str]: + vf = ( + f"scale={WATCH_W}:{WATCH_H}:force_original_aspect_ratio=decrease:" + f"flags=fast_bilinear," + f"pad={WATCH_W}:{WATCH_H}:(ow-iw)/2:(oh-ih)/2:black,format=rgba" + ) + cmd = [ + ffmpeg, + "-hide_banner", + "-loglevel", + "error", + "-nostdin", + "-y", + # Pace VOD to realtime so drop-to-latest tile shows motion, not EOF. + "-re", + "-i", + media_urls[0], + ] + cmd.extend( + [ + "-map", + "0:v:0", + "-an", + "-vf", + vf, + "-pix_fmt", + "rgba", + "-fps_mode", + "passthrough", + "-f", + "rawvideo", + video_fifo, + ] + ) + return cmd + + +def _drain_err(proc: subprocess.Popen, label: str) -> None: + assert proc.stderr is not None + for raw in iter(proc.stderr.readline, b""): + line = raw.decode("utf-8", "replace").rstrip() + if line: + LOG.info("%s: %s", label, line) + + +def run(page_url: str, video_fifo: str) -> int: + yt_dlp = which("yt-dlp") + ffmpeg = which("ffmpeg") + media_urls, title = resolve_urls(yt_dlp, page_url) + LOG.info("play %r → fifo=%s", title[:100], video_fifo) + + if not os.path.exists(video_fifo): + os.mkfifo(video_fifo, 0o600) + + a_cmd = audio_cmd(ffmpeg, media_urls) + v_cmd = video_cmd(ffmpeg, media_urls, video_fifo) + LOG.info("audio ffmpeg … → stdout; video ffmpeg … → fifo") + + # Stay in the demux process group that eve-av-bridge created (setpgid) so + # WatchHandle::stop SIGKILL's yt-dlp + both ffmpeg children together. + audio = subprocess.Popen( + a_cmd, + stdin=subprocess.DEVNULL, + stdout=sys.stdout.buffer, + stderr=subprocess.PIPE, + ) + video = subprocess.Popen( + v_cmd, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + + threading.Thread( + target=_drain_err, args=(audio, "ffmpeg-a"), name="yt-a-err", daemon=True + ).start() + threading.Thread( + target=_drain_err, args=(video, "ffmpeg-v"), name="yt-v-err", daemon=True + ).start() + + pgid = os.getpgrp() + + def _on_signal(signum: int, _frame) -> None: + LOG.info("signal %s — stopping ffmpeg (pgid=%s)", signum, pgid) + try: + os.killpg(pgid, signal.SIGKILL) + except ProcessLookupError: + pass + + signal.signal(signal.SIGTERM, _on_signal) + signal.signal(signal.SIGINT, _on_signal) + + # Prefer audio lifetime as the session clock (matches WHEP stdout contract). + a_rc = audio.wait() + try: + if video.poll() is None: + video.kill() + video.wait(timeout=2) + except Exception: + try: + video.kill() + except Exception: + pass + LOG.info("done audio_rc=%s video_rc=%s", a_rc, video.returncode) + return 0 if a_rc == 0 else (a_rc if isinstance(a_rc, int) and a_rc > 0 else 1) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--url", required=True, help="YouTube watch / youtu.be / Shorts URL") + ap.add_argument("--video-fifo", required=True) + ap.add_argument("-v", "--verbose", action="store_true") + args = ap.parse_args() + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + stream=sys.stderr, + ) + try: + return run(args.url, args.video_fifo) + except KeyboardInterrupt: + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/systemd/README.md b/systemd/README.md index 3bc0ac0..a92b3cd 100644 --- a/systemd/README.md +++ b/systemd/README.md @@ -134,8 +134,9 @@ curl -sS -X POST http://127.0.0.1:8791/streamplace/play -H 'content-type: applic curl -sS -X POST http://127.0.0.1:8791/streamplace/stop ``` -Picks `place.stream.live.getLiveUsers` sorted by `viewerCount`, plays HLS via -`place.stream.playback.getLivePlaylist?streamer=` through ffmpeg → MoQ. +Picks `place.stream.live.getLiveUsers` sorted by `viewerCount`, plays via +`place.stream.playback.whep?streamer=` (WHEP → demux → MoQ), or a +YouTube URL (`yt-watch-demux.py` via yt-dlp + ffmpeg). ### stream.place publish plane (inverse / call rebroadcast) diff --git a/systemd/user/eve-av-bridge-broadcast.service b/systemd/user/eve-av-bridge-broadcast.service index 9341f8a..77c2775 100644 --- a/systemd/user/eve-av-bridge-broadcast.service +++ b/systemd/user/eve-av-bridge-broadcast.service @@ -8,7 +8,7 @@ PartOf=eve.target Type=simple WorkingDirectory=@ROOT@ Environment=HOME=%h -Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin +Environment=PATH=%h/.nix-profile/bin:%h/.local/bin:/usr/local/bin:/usr/bin:/bin Environment=STREAM_BROADCAST_AV_BRIDGE_BIND=127.0.0.1:8793 Environment=AV_BRIDGE_BIND=127.0.0.1:8793 Environment=AV_PLANE_ROLE=broadcast diff --git a/systemd/user/eve-av-bridge-streamplace.service b/systemd/user/eve-av-bridge-streamplace.service index 587e2e9..3fc8de0 100644 --- a/systemd/user/eve-av-bridge-streamplace.service +++ b/systemd/user/eve-av-bridge-streamplace.service @@ -1,5 +1,5 @@ [Unit] -Description=eve AV bridge (stream-watch: stream.place → freeq MoQ) +Description=eve AV bridge (stream-watch: stream.place / YouTube → freeq MoQ) Documentation=file://@ROOT@/systemd/README.md After=network-online.target eve-av-bridge.service PartOf=eve.target @@ -8,16 +8,19 @@ PartOf=eve.target Type=simple WorkingDirectory=@ROOT@ Environment=HOME=%h -Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin +Environment=PATH=%h/.nix-profile/bin:%h/.local/bin:/usr/local/bin:/usr/bin:/bin Environment=STREAMPLACE_AV_BRIDGE_BIND=127.0.0.1:8792 Environment=AV_BRIDGE_BIND=127.0.0.1:8792 Environment=AV_PLANE_ROLE=watch -# P360 freeq tile at continuous 20 fps (matches watch.rs demux drop-frame). +# P360 @ 15fps / 400kbps — smooth on 2-core boxd (avoid 1.2Mbps + KF/3 thrash). Environment=AV_VIDEO_PRESET=360p -Environment=AV_VIDEO_FPS=20 -Environment=AV_VIDEO_BITRATE=1200000 +Environment=AV_VIDEO_FPS=15 +Environment=AV_VIDEO_BITRATE=400000 +Environment=AV_VIDEO_KEYFRAME_INTERVAL=15 Environment=RADIO_TITLE_HOOK= Environment=AV_INCLUDE_VIDEO=1 +Environment=YT_JS_RUNTIME=node +Environment=YT_COOKIES=%h/.config/eve/youtube-cookies.txt EnvironmentFile=-%h/.config/eve/runtime.env EnvironmentFile=-%h/.config/eve/config.env ExecStart=@ROOT@/scripts/run-av-bridge-streamplace.sh diff --git a/systemd/user/eve-av-bridge.service b/systemd/user/eve-av-bridge.service index 7a568f6..5af134f 100644 --- a/systemd/user/eve-av-bridge.service +++ b/systemd/user/eve-av-bridge.service @@ -9,7 +9,7 @@ PartOf=eve.target Type=simple WorkingDirectory=@ROOT@ Environment=HOME=%h -Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin +Environment=PATH=%h/.nix-profile/bin:%h/.local/bin:/usr/local/bin:/usr/bin:/bin EnvironmentFile=-%h/.config/eve/runtime.env Environment=AV_PLANE_ROLE=radio EnvironmentFile=-%h/.config/eve/config.env diff --git a/systemd/user/eve-irc-bridge.service b/systemd/user/eve-irc-bridge.service index a9ee9ec..30a878e 100644 --- a/systemd/user/eve-irc-bridge.service +++ b/systemd/user/eve-irc-bridge.service @@ -10,7 +10,7 @@ PartOf=eve.target Type=simple WorkingDirectory=@ROOT@ Environment=HOME=%h -Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin +Environment=PATH=%h/.nix-profile/bin:%h/.local/bin:/usr/local/bin:/usr/bin:/bin EnvironmentFile=-%h/.config/eve/runtime.env EnvironmentFile=-%h/.config/eve/config.env ExecStart=@NODE@ @ROOT@/irc-bridge/server.mjs diff --git a/systemd/user/eve-prep.service b/systemd/user/eve-prep.service index 12046d6..0f94c94 100644 --- a/systemd/user/eve-prep.service +++ b/systemd/user/eve-prep.service @@ -9,7 +9,7 @@ Type=oneshot RemainAfterExit=yes WorkingDirectory=@ROOT@ Environment=HOME=%h -Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin +Environment=PATH=%h/.nix-profile/bin:%h/.local/bin:/usr/local/bin:/usr/bin:/bin # Operator secrets for OpenBao (OPENBAO_ADDR, OPENBAO_TOKEN) — mode 0600. EnvironmentFile=-%h/.config/eve/openbao.env # Optional non-secret overrides (IRC_*, OPENCODE_MODEL, …).