Files
stoat-mbot/src/sources/local.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

97 lines
3.3 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { readdir, stat } from "node:fs/promises";
import path from "node:path";
import { config } from "../config.js";
import { logger } from "../logger.js";
import { UserFacingError, type Requester, type Track } from "../types.js";
const log = logger.child({ mod: "local" });
const AUDIO_EXTENSIONS = new Set([".mp3", ".flac", ".ogg", ".opus", ".m4a", ".aac", ".wav", ".wma", ".webm"]);
const CACHE_TTL_MS = 60_000;
let cache: { files: string[]; at: number } | null = null;
function libraryRoot(): string {
if (!config.LOCAL_MEDIA_DIR) throw new UserFacingError("Локальная медиатека не настроена (LOCAL_MEDIA_DIR)");
return path.resolve(config.LOCAL_MEDIA_DIR);
}
async function walk(dir: string, out: string[], depth = 0): Promise<void> {
if (depth > 6) return;
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch (err) {
log.warn({ err, dir }, "cannot read media directory");
return;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(full, out, depth + 1);
} else if (entry.isFile() && AUDIO_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
out.push(full);
}
}
}
export async function listFiles(force = false): Promise<string[]> {
const root = libraryRoot();
if (!force && cache && Date.now() - cache.at < CACHE_TTL_MS) return cache.files;
const files: string[] = [];
await walk(root, files);
files.sort((a, b) => a.localeCompare(b));
cache = { files, at: Date.now() };
return files;
}
export function isEnabled(): boolean {
return Boolean(config.LOCAL_MEDIA_DIR);
}
/** Guards against path traversal — only files inside the library may be played. */
export async function assertInsideLibrary(filePath: string): Promise<string> {
const root = libraryRoot();
const resolved = path.resolve(filePath);
const relative = path.relative(root, resolved);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new UserFacingError("Файл вне медиатеки");
}
const info = await stat(resolved).catch(() => null);
if (!info?.isFile()) throw new UserFacingError("Файл не найден");
return resolved;
}
function toTrack(filePath: string, requestedBy: Requester): Track {
const root = libraryRoot();
const relative = path.relative(root, filePath);
const parsed = path.parse(relative);
const parentDir = path.basename(parsed.dir);
return {
id: randomUUID(),
title: parsed.name,
author: parentDir || null,
// Filled in from ffmpeg's codecData once playback starts.
duration: 0,
isLive: false,
url: filePath,
thumbnail: null,
source: "local",
requestedBy,
};
}
export async function search(query: string, limit: number, requestedBy: Requester): Promise<Track[]> {
const files = await listFiles();
const needle = query.trim().toLowerCase();
const matches = needle
? files.filter((file) => path.basename(file).toLowerCase().includes(needle))
: files;
return matches.slice(0, limit).map((file) => toTrack(file, requestedBy));
}
export async function resolvePath(filePath: string, requestedBy: Requester): Promise<Track> {
const resolved = await assertInsideLibrary(filePath);
return toTrack(resolved, requestedBy);
}