Files
stoat-mbot/src/sources/direct.ts
T
Leonid PershinandClaude Opus 5 a9b7ccdd16 Add music bot for self-hosted Stoat with web control panel
Plays audio into Stoat voice channels over LiveKit and exposes the same
player through both chat commands and a browser panel, so the two never
drift apart: everything routes through a single MusicManager.

- core: per-server GuildPlayer (queue, loop, shuffle, seek, volume,
  idle auto-leave) driving revoice.js/@livekit/rtc-node and ffmpeg
- sources: yt-dlp for YouTube/SoundCloud, direct media URLs and internet
  radio, optional local library with path-traversal guards
- bot: 18 chat commands with aliases, plus !panel one-time login links
- api: Fastify REST + WebSocket, sessions authenticated against the
  instance's own /auth/session/login (TOTP supported), permissions
  re-checked against Stoat membership and roles on every request
- web: React panel with search, queue editing, seek and volume
- deploy: Dockerfile, compose.override.yml and Caddyfile snippets for
  dropping the service into an existing /opt/stoat stack

Verified with npm run typecheck, both builds, and scripts/smoke-api.mjs
(9 API checks). Voice playback itself needs a live instance to test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:12:43 +03:00

76 lines
2.1 KiB
TypeScript

import { randomUUID } from "node:crypto";
import path from "node:path";
import { logger } from "../logger.js";
import type { Requester, Track } from "../types.js";
const log = logger.child({ mod: "direct" });
const AUDIO_CONTENT_TYPES = [
"audio/",
"application/ogg",
"application/x-mpegurl",
"application/vnd.apple.mpegurl",
"video/mp4",
"video/webm",
];
export interface ProbeResult {
isMedia: boolean;
isLive: boolean;
title: string | null;
}
/** Cheap HEAD probe used to tell "direct media URL" apart from "web page". */
export async function probe(url: string): Promise<ProbeResult> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8000);
try {
const res = await fetch(url, {
method: "HEAD",
redirect: "follow",
signal: controller.signal,
headers: { "user-agent": "stoat-mbot/0.1", icy: "1" },
});
const contentType = (res.headers.get("content-type") ?? "").toLowerCase();
const isMedia = AUDIO_CONTENT_TYPES.some((type) => contentType.startsWith(type));
// Shoutcast/Icecast expose the station name and never a content length.
const icyName = res.headers.get("icy-name");
const isLive = isMedia && !res.headers.get("content-length");
return { isMedia: isMedia || Boolean(icyName), isLive: isLive || Boolean(icyName), title: icyName };
} catch (err) {
log.debug({ err, url }, "probe failed");
return { isMedia: false, isLive: false, title: null };
} finally {
clearTimeout(timer);
}
}
export function toTrack(url: string, requestedBy: Requester, probed: ProbeResult): Track {
let title = probed.title;
if (!title) {
try {
const name = path.basename(new URL(url).pathname);
title = decodeURIComponent(name) || new URL(url).hostname;
} catch {
title = url;
}
}
let host: string | null = null;
try {
host = new URL(url).hostname;
} catch {
host = null;
}
return {
id: randomUUID(),
title,
author: host,
duration: 0,
isLive: probed.isLive,
url,
thumbnail: null,
source: "direct",
requestedBy,
};
}