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>
97 lines
3.3 KiB
TypeScript
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);
|
|
}
|