feat(core): implement data retention, metrics, and enhanced media handling
This commit introduces several significant improvements across the backend and gateway services:
- **Data Retention**: Added an automated cleanup scheduler in `discord-gateway` to prune expired messages, attachments, and voice recordings based on configurable retention policies.
- **Observability**: Integrated `prom-client` in the `backend` service to expose Prometheus metrics via `/api/metrics` and added default Node.js runtime metrics.
- **Media Handling**: Enhanced `MediaHandler` in `discord-gateway` to support media URL resolution and improved playback status tracking.
- **API & Config**: Expanded the configuration endpoint to expose more system settings and reorganized `.env.example` for better readability.
- **Refactoring & Cleanup**:
- Removed unused `better-sqlite3` dependency.
- Refactored voice channel routing.
- Improved error handling and testing coverage with comprehensive unit tests for shared utilities and error classes.
- **Documentation**: Added `MEMORY.md` for project context.
This commit is contained in:
@@ -1,15 +1,23 @@
|
||||
import { type CommandMessage, type CommandReply } from "@bete/shared";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { StreamType } from "@discordjs/voice";
|
||||
import { resolveMediaUrl } from "../voice-recording/mediaSource.js";
|
||||
import { discordPlayer } from "../voice-recording/player.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CurrentTrack {
|
||||
title: string;
|
||||
url: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface MediaStatusPayload {
|
||||
playing: boolean;
|
||||
musicVolume: number;
|
||||
current: unknown;
|
||||
current: CurrentTrack | null;
|
||||
queue: unknown[];
|
||||
}
|
||||
|
||||
@@ -19,29 +27,84 @@ export interface MediaStatusPayload {
|
||||
|
||||
export class MediaHandler {
|
||||
private logger = createChildLogger("media-handler");
|
||||
private currentTrack: CurrentTrack | null = null;
|
||||
|
||||
getCurrentMediaStatus(): MediaStatusPayload {
|
||||
return {
|
||||
playing: discordPlayer.getStatus() === "playing",
|
||||
musicVolume: discordPlayer.getMusicVolume(),
|
||||
current: null,
|
||||
current: this.currentTrack,
|
||||
queue: [],
|
||||
};
|
||||
}
|
||||
|
||||
async handleMediaQueue(cmd: CommandMessage): Promise<CommandReply<unknown>> {
|
||||
this.logger.info(
|
||||
"media:queue received — media queueing is handled externally",
|
||||
);
|
||||
return {
|
||||
id: cmd.id,
|
||||
success: true,
|
||||
data: this.getCurrentMediaStatus(),
|
||||
};
|
||||
const url = String(cmd.payload.url ?? "").trim();
|
||||
|
||||
if (!url) {
|
||||
this.logger.warn("media:queue received without a URL");
|
||||
return {
|
||||
id: cmd.id,
|
||||
success: false,
|
||||
data: null,
|
||||
error: "url is required",
|
||||
};
|
||||
}
|
||||
|
||||
if (!discordPlayer.isConnected()) {
|
||||
this.logger.warn(
|
||||
"media:queue attempted without an active voice connection",
|
||||
);
|
||||
return {
|
||||
id: cmd.id,
|
||||
success: false,
|
||||
data: null,
|
||||
error: "Not connected to a voice channel. Connect to voice first.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.info({ url }, "Resolving media URL");
|
||||
|
||||
const resolution = await resolveMediaUrl(url);
|
||||
|
||||
this.currentTrack = {
|
||||
title: resolution.title ?? url,
|
||||
url,
|
||||
duration: resolution.duration,
|
||||
};
|
||||
|
||||
discordPlayer.playStream(resolution.stream, "music", {
|
||||
inputType: StreamType.Arbitrary,
|
||||
inlineVolume: true,
|
||||
volume: discordPlayer.getMusicVolume(),
|
||||
});
|
||||
|
||||
this.logger.info(
|
||||
{ url, title: resolution.title },
|
||||
"Media queued and playback started",
|
||||
);
|
||||
|
||||
return {
|
||||
id: cmd.id,
|
||||
success: true,
|
||||
data: this.getCurrentMediaStatus(),
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error({ error: message, url }, "Failed to queue media");
|
||||
return {
|
||||
id: cmd.id,
|
||||
success: false,
|
||||
data: null,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async handleMediaSkip(cmd: CommandMessage): Promise<CommandReply<unknown>> {
|
||||
discordPlayer.stop("music");
|
||||
this.currentTrack = null;
|
||||
return {
|
||||
id: cmd.id,
|
||||
success: true,
|
||||
@@ -51,6 +114,7 @@ export class MediaHandler {
|
||||
|
||||
async handleMediaStop(cmd: CommandMessage): Promise<CommandReply<unknown>> {
|
||||
discordPlayer.stop("music");
|
||||
this.currentTrack = null;
|
||||
return {
|
||||
id: cmd.id,
|
||||
success: true,
|
||||
|
||||
@@ -111,67 +111,6 @@ export class VoiceHandler {
|
||||
}
|
||||
}
|
||||
|
||||
async handleGuildsList(cmd: CommandMessage): Promise<CommandReply<unknown>> {
|
||||
if (!this.client) {
|
||||
return {
|
||||
id: cmd.id,
|
||||
success: false,
|
||||
data: null,
|
||||
error: "Gateway not initialized",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const guilds = this.client.guilds.cache
|
||||
.map((guild) => ({ id: guild.id, name: guild.name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return { id: cmd.id, success: true, data: guilds };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { id: cmd.id, success: false, data: null, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
async handleWatchableChannels(
|
||||
cmd: CommandMessage,
|
||||
): Promise<CommandReply<unknown>> {
|
||||
if (!this.client) {
|
||||
return {
|
||||
id: cmd.id,
|
||||
success: false,
|
||||
data: null,
|
||||
error: "Gateway not initialized",
|
||||
};
|
||||
}
|
||||
|
||||
const guildId = String(cmd.payload.guildId ?? "");
|
||||
if (!guildId) {
|
||||
return {
|
||||
id: cmd.id,
|
||||
success: false,
|
||||
data: null,
|
||||
error: "guildId is required",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const guild = await this.client.guilds.fetch(guildId);
|
||||
const channels = await guild.channels.fetch();
|
||||
const textChannels = channels
|
||||
.filter((c) => c?.type === "GUILD_TEXT")
|
||||
.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
type: c.type,
|
||||
}));
|
||||
|
||||
return { id: cmd.id, success: true, data: textChannels };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { id: cmd.id, success: false, data: null, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
async handleVoiceTransmitStart(
|
||||
cmd: CommandMessage,
|
||||
): Promise<CommandReply<unknown>> {
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { PassThrough, Readable } from "node:stream";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { StreamType } from "@discordjs/voice";
|
||||
|
||||
const logger = createChildLogger("media-source");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MediaInfo {
|
||||
title: string;
|
||||
duration: number;
|
||||
uploader?: string;
|
||||
thumbnail?: string;
|
||||
}
|
||||
|
||||
export interface MediaSourceResolution {
|
||||
stream: Readable;
|
||||
type: StreamType;
|
||||
title?: string;
|
||||
duration?: number;
|
||||
info: MediaInfo;
|
||||
}
|
||||
|
||||
export interface ResolveOptions {
|
||||
/** Timeout in milliseconds for the yt-dlp process. */
|
||||
timeout?: number;
|
||||
/**
|
||||
* yt-dlp format string override (e.g. "bestaudio[ext=m4a]").
|
||||
* Defaults to "bestaudio".
|
||||
*/
|
||||
quality?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Tracks all spawned yt-dlp child processes for shutdown cleanup. */
|
||||
const activeProcesses: Set<ChildProcess> = new Set();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseSeconds(value: string): number {
|
||||
const n = Number.parseFloat(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the first two newline-delimited lines from a Readable stdout stream.
|
||||
*
|
||||
* yt-dlp with `--print before_dl:title --print before_dl:duration` outputs:
|
||||
* line 1: video title
|
||||
* line 2: duration in seconds (float)
|
||||
* rest: raw binary audio data
|
||||
*
|
||||
* Returns the parsed header and a new Readable that contains all remaining
|
||||
* data (the audio stream).
|
||||
*/
|
||||
function readFirstTwoLines(stdout: Readable): Promise<{
|
||||
title: string;
|
||||
duration: number;
|
||||
remaining: Readable;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const passThrough = new PassThrough();
|
||||
|
||||
let buffer = Buffer.alloc(0);
|
||||
let title = "";
|
||||
let stage: "title" | "duration" | "done" = "title";
|
||||
|
||||
function cleanup() {
|
||||
stdout.removeListener("data", onData);
|
||||
stdout.removeListener("error", onError);
|
||||
stdout.removeListener("end", onEnd);
|
||||
}
|
||||
|
||||
function onData(chunk: Buffer) {
|
||||
if (stage === "done") return;
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
processBuffer();
|
||||
}
|
||||
|
||||
function processBuffer() {
|
||||
while (buffer.length > 0 && stage !== "done") {
|
||||
const nl = buffer.indexOf(0x0a); // '\n' byte
|
||||
if (nl === -1) break; // Need more data
|
||||
|
||||
const line = buffer.subarray(0, nl).toString("utf8").trim();
|
||||
buffer = buffer.subarray(nl + 1);
|
||||
|
||||
if (stage === "title") {
|
||||
title = line;
|
||||
stage = "duration";
|
||||
} else if (stage === "duration") {
|
||||
const duration = parseSeconds(line);
|
||||
stage = "done";
|
||||
cleanup();
|
||||
|
||||
// Write any buffered data that follows the second newline
|
||||
if (buffer.length > 0) {
|
||||
passThrough.write(buffer);
|
||||
}
|
||||
|
||||
// Pipe the remainder of stdout into the pass-through
|
||||
stdout.pipe(passThrough);
|
||||
|
||||
resolve({ title, duration, remaining: passThrough });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onError(err: Error) {
|
||||
if (stage !== "done") {
|
||||
cleanup();
|
||||
reject(err);
|
||||
}
|
||||
}
|
||||
|
||||
function onEnd() {
|
||||
if (stage !== "done") {
|
||||
cleanup();
|
||||
reject(
|
||||
new Error(
|
||||
`yt-dlp stdout ended before metadata could be read. ` +
|
||||
`Stage: ${stage}, partial title: "${title}"`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
stdout.on("data", onData);
|
||||
stdout.on("error", onError);
|
||||
stdout.on("end", onEnd);
|
||||
});
|
||||
}
|
||||
|
||||
function buildNotInstalledError(): Error {
|
||||
return new Error(
|
||||
"yt-dlp is not installed or not found in PATH. " +
|
||||
'Run "pnpm run install:yt-dlp" to install it.',
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve a media URL (YouTube, Spotify, etc.) to a playable audio stream.
|
||||
*
|
||||
* Spawns `yt-dlp`, extracts the title and duration from the first two stdout
|
||||
* lines, then pipes the remaining raw audio data into a Readable stream.
|
||||
*
|
||||
* The returned stream uses `StreamType.Arbitrary` — suitable for
|
||||
* `DiscordPlayer.playStream()` with `inputType: StreamType.Arbitrary`.
|
||||
*
|
||||
* @throws If yt-dlp is not installed or the process exits with a non-zero code
|
||||
* before the metadata headers have been parsed.
|
||||
*/
|
||||
export function resolveMediaUrl(
|
||||
url: string,
|
||||
options?: ResolveOptions,
|
||||
): Promise<MediaSourceResolution> {
|
||||
return new Promise<MediaSourceResolution>((resolve, reject) => {
|
||||
const format = options?.quality ?? "bestaudio";
|
||||
const args = [
|
||||
"-f",
|
||||
format,
|
||||
"--audio-format",
|
||||
"best",
|
||||
"-o",
|
||||
"-",
|
||||
"--print",
|
||||
"before_dl:title",
|
||||
"--print",
|
||||
"before_dl:duration",
|
||||
url,
|
||||
];
|
||||
|
||||
logger.info({ url }, "Spawning yt-dlp for media resolution");
|
||||
|
||||
const proc = spawn("yt-dlp", args, {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
activeProcesses.add(proc);
|
||||
|
||||
let stderrBuf = "";
|
||||
let resolved = false;
|
||||
|
||||
// -- helpers -----------------------------------------------------------
|
||||
|
||||
const failOnce = (err: Error) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
activeProcesses.delete(proc);
|
||||
reject(err);
|
||||
};
|
||||
|
||||
// -- spawn error (ENOENT etc.) ----------------------------------------
|
||||
|
||||
proc.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "ENOENT") {
|
||||
failOnce(buildNotInstalledError());
|
||||
} else {
|
||||
failOnce(new Error(`yt-dlp failed to start: ${err.message}`));
|
||||
}
|
||||
});
|
||||
|
||||
// -- stderr (capture for diagnostics) ----------------------------------
|
||||
|
||||
if (proc.stderr) {
|
||||
proc.stderr.on("data", (chunk: Buffer) => {
|
||||
stderrBuf += chunk.toString("utf8");
|
||||
});
|
||||
}
|
||||
|
||||
// -- stdout: parse header, then stream audio ---------------------------
|
||||
|
||||
readFirstTwoLines(proc.stdout)
|
||||
.then(({ title, duration, remaining }) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
activeProcesses.delete(proc);
|
||||
|
||||
const info: MediaInfo = { title, duration };
|
||||
resolve({
|
||||
stream: remaining,
|
||||
type: StreamType.Arbitrary,
|
||||
title,
|
||||
duration,
|
||||
info,
|
||||
});
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
failOnce(err);
|
||||
});
|
||||
|
||||
// -- process exit (non-zero means failure) -----------------------------
|
||||
|
||||
proc.on("close", (code, signal) => {
|
||||
activeProcesses.delete(proc);
|
||||
|
||||
if (resolved) return;
|
||||
|
||||
if (code !== null && code !== 0) {
|
||||
const detail = stderrBuf.trim() ? `: ${stderrBuf.trim()}` : "";
|
||||
failOnce(new Error(`yt-dlp exited with code ${code}${detail}`));
|
||||
} else if (signal) {
|
||||
failOnce(new Error(`yt-dlp was killed by signal ${signal}`));
|
||||
}
|
||||
});
|
||||
|
||||
// -- optional timeout --------------------------------------------------
|
||||
|
||||
if (options?.timeout && options.timeout > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
if (resolved) return;
|
||||
logger.warn({ url, timeout: options.timeout }, "yt-dlp timed out");
|
||||
proc.kill("SIGTERM");
|
||||
failOnce(new Error(`yt-dlp timed out after ${options.timeout}ms`));
|
||||
}, options.timeout);
|
||||
|
||||
proc.once("close", () => clearTimeout(timer));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract metadata (title, duration, uploader, thumbnail) from a media URL
|
||||
* without downloading the audio stream.
|
||||
*
|
||||
* Uses `yt-dlp --dump-json` and parses the JSON output.
|
||||
*
|
||||
* @throws If yt-dlp is not installed or the process exits with a non-zero
|
||||
* code.
|
||||
*/
|
||||
export async function extractMediaInfo(url: string): Promise<MediaInfo> {
|
||||
return new Promise<MediaInfo>((resolve, reject) => {
|
||||
const args = ["--dump-json", "--no-warnings", url];
|
||||
|
||||
logger.debug({ url }, "Spawning yt-dlp for metadata extraction");
|
||||
|
||||
const proc = spawn("yt-dlp", args, {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
activeProcesses.add(proc);
|
||||
|
||||
let stdoutBuf = "";
|
||||
let stderrBuf = "";
|
||||
|
||||
if (proc.stdout) {
|
||||
proc.stdout.on("data", (chunk: Buffer) => {
|
||||
stdoutBuf += chunk.toString("utf8");
|
||||
});
|
||||
}
|
||||
|
||||
if (proc.stderr) {
|
||||
proc.stderr.on("data", (chunk: Buffer) => {
|
||||
stderrBuf += chunk.toString("utf8");
|
||||
});
|
||||
}
|
||||
|
||||
proc.on("error", (err: NodeJS.ErrnoException) => {
|
||||
activeProcesses.delete(proc);
|
||||
if (err.code === "ENOENT") {
|
||||
reject(buildNotInstalledError());
|
||||
} else {
|
||||
reject(new Error(`yt-dlp failed to start: ${err.message}`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
activeProcesses.delete(proc);
|
||||
|
||||
if (code !== 0) {
|
||||
const detail = stderrBuf.trim() ? `: ${stderrBuf.trim()}` : "";
|
||||
reject(
|
||||
new Error(
|
||||
`yt-dlp metadata extraction exited with code ${code}${detail}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = JSON.parse(stdoutBuf.trim()) as Record<string, unknown>;
|
||||
resolve({
|
||||
title: String(raw.title ?? url),
|
||||
duration: typeof raw.duration === "number" ? raw.duration : 0,
|
||||
uploader: String(raw.uploader ?? raw.channel ?? "") || undefined,
|
||||
thumbnail: String(raw.thumbnail ?? "") || undefined,
|
||||
});
|
||||
} catch (parseErr) {
|
||||
reject(
|
||||
new Error(
|
||||
`Failed to parse yt-dlp JSON output: ${(parseErr as Error).message}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill all active yt-dlp child processes.
|
||||
*
|
||||
* Call during graceful shutdown to ensure no orphan processes remain.
|
||||
*/
|
||||
export function cleanup(): void {
|
||||
if (activeProcesses.size === 0) return;
|
||||
|
||||
logger.info(
|
||||
{ count: activeProcesses.size },
|
||||
"Killing active yt-dlp processes",
|
||||
);
|
||||
|
||||
for (const proc of activeProcesses) {
|
||||
try {
|
||||
proc.kill("SIGTERM");
|
||||
} catch {
|
||||
// Process may already be dead — ignore
|
||||
}
|
||||
}
|
||||
|
||||
activeProcesses.clear();
|
||||
}
|
||||
Reference in New Issue
Block a user