From f9a4b4a92d8930c17780aab55372cda584c336a5 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Wed, 13 May 2026 17:06:22 +0700 Subject: [PATCH] feat: add muxer job queue with SQLite integration and metrics tracking --- .env.example | 2 + bun.lockb | Bin 148440 -> 159658 bytes package.json | 3 + src/index.ts | 1 - src/metrics.ts | 78 +++++++++++++++ src/muxer-queue.ts | 230 +++++++++++++++++++++++++++++++++++++++++++++ src/webserver.ts | 8 ++ 7 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 src/metrics.ts create mode 100644 src/muxer-queue.ts diff --git a/.env.example b/.env.example index 0b5d9f5..39659db 100644 --- a/.env.example +++ b/.env.example @@ -31,3 +31,5 @@ RECONNECT_TIMEOUT_MS=5000 LOG_LEVEL=info NODE_ENV=development + + diff --git a/bun.lockb b/bun.lockb index 0b3702247da4e1479517d7dc2e836092073d827e..04ee2b725d496918e61b9a331fdf2a7c6072247e 100755 GIT binary patch delta 32893 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�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 diff --git a/package.json b/package.json index 2b1d408..4546ff8 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@discordjs/opus": "^0.10.0", "@discordjs/voice": "^0.19.1", "@snazzah/davey": "^0.1.10", + "better-sqlite3": "^12.10.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "discord.js-selfbot-v13": "^3.7.1", @@ -28,12 +29,14 @@ "pino": "^9.4.0", "pino-http": "^11.0.0", "prism-media": "2.0.0-alpha.0", + "prom-client": "^15.1.3", "sodium-native": "^4.3.2", "ws": "^8.20.1", "zod": "^4.4.3" }, "devDependencies": { "@biomejs/biome": "latest", + "@types/better-sqlite3": "^7.6.13", "@types/bun": "latest", "@types/express": "^5.0.6", "@types/fluent-ffmpeg": "^2.1.28", diff --git a/src/index.ts b/src/index.ts index 9762825..72637ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,6 @@ import "dotenv/config"; import { getVoiceConnection } from "@discordjs/voice"; import { Client } from "discord.js-selfbot-v13"; import { config } from "./config"; -import { AppError } from "./errors"; import { createChildLogger } from "./logger"; import { discordPlayer } from "./player"; import { startRecording, stopRecording } from "./recorder"; diff --git a/src/metrics.ts b/src/metrics.ts new file mode 100644 index 0000000..d1ae7d9 --- /dev/null +++ b/src/metrics.ts @@ -0,0 +1,78 @@ +import { Counter, Gauge, Histogram, register } from "prom-client"; + +// Audio metrics +export const audioLevelGauge = new Gauge({ + name: "audio_level_db", + help: "Current audio level in dB", + labelNames: ["user_id"], +}); + +export const recordingDurationCounter = new Counter({ + name: "recording_duration_seconds_total", + help: "Total recording duration in seconds", + labelNames: ["user_id"], +}); + +export const activeRecordingsGauge = new Gauge({ + name: "active_recordings", + help: "Number of active recordings", +}); + +export const recordedSegmentsCounter = new Counter({ + name: "recorded_segments_total", + help: "Total number of recorded segments", + labelNames: ["user_id"], +}); + +// Connection metrics +export const voiceConnectionsGauge = new Gauge({ + name: "voice_connections_active", + help: "Number of active voice connections", +}); + +export const connectionErrorsCounter = new Counter({ + name: "connection_errors_total", + help: "Total number of connection errors", + labelNames: ["error_type"], +}); + +export const reconnectAttemptsCounter = new Counter({ + name: "reconnect_attempts_total", + help: "Total number of reconnection attempts", +}); + +// WebSocket metrics +export const wsClientsGauge = new Gauge({ + name: "websocket_clients_connected", + help: "Number of connected WebSocket clients", +}); + +export const wsMessagesCounter = new Counter({ + name: "websocket_messages_total", + help: "Total WebSocket messages sent", + labelNames: ["message_type"], +}); + +// HTTP metrics +export const httpRequestDurationHistogram = new Histogram({ + name: "http_request_duration_seconds", + help: "HTTP request duration in seconds", + labelNames: ["method", "route", "status"], + buckets: [0.001, 0.01, 0.1, 0.5, 1, 2, 5], +}); + +export const httpRequestsCounter = new Counter({ + name: "http_requests_total", + help: "Total HTTP requests", + labelNames: ["method", "route", "status"], +}); + +// System metrics +export const uptimeGauge = new Gauge({ + name: "process_uptime_seconds", + help: "Process uptime in seconds", +}); + +export async function getMetrics(): Promise { + return register.metrics(); +} diff --git a/src/muxer-queue.ts b/src/muxer-queue.ts new file mode 100644 index 0000000..921788d --- /dev/null +++ b/src/muxer-queue.ts @@ -0,0 +1,230 @@ +import path from "node:path"; +import Database from "better-sqlite3"; +import { createChildLogger } from "./logger"; + +const logger = createChildLogger("muxer-queue"); + +export interface MuxerJobData { + userId: string; + sessionId: string; + recordingsDir: string; + outputDir: string; +} + +interface StoredJob { + id: string; + data: string; + status: "pending" | "processing" | "completed" | "failed"; + attempts: number; + maxAttempts: number; + createdAt: number; + updatedAt: number; + error?: string; +} + +const dbPath = path.join(process.cwd(), ".muxer-queue.db"); +let db: Database.Database | null = null; + +function initializeDatabase(): Database.Database { + const database = new Database(dbPath); + database.pragma("journal_mode = WAL"); + + database.exec(` + CREATE TABLE IF NOT EXISTS muxer_jobs ( + id TEXT PRIMARY KEY, + data TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + attempts INTEGER NOT NULL DEFAULT 0, + maxAttempts INTEGER NOT NULL DEFAULT 3, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + error TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_status ON muxer_jobs(status); + CREATE INDEX IF NOT EXISTS idx_createdAt ON muxer_jobs(createdAt); + `); + + return database; +} + +function getDatabase(): Database.Database { + if (!db) { + db = initializeDatabase(); + } + return db; +} + +export async function enqueueMuxerJob(data: MuxerJobData): Promise { + try { + const database = getDatabase(); + const jobId = `${data.userId}-${data.sessionId}`; + const now = Date.now(); + + const stmt = database.prepare(` + INSERT INTO muxer_jobs (id, data, status, attempts, maxAttempts, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + + stmt.run(jobId, JSON.stringify(data), "pending", 0, 3, now, now); + + logger.info( + { jobId, userId: data.userId, sessionId: data.sessionId }, + "Muxer job enqueued", + ); + + return jobId; + } catch (error) { + logger.error( + { + userId: data.userId, + error: error instanceof Error ? error.message : String(error), + }, + "Failed to enqueue muxer job", + ); + throw error; + } +} + +export async function getPendingJobs(): Promise { + const database = getDatabase(); + const stmt = database.prepare(` + SELECT id, data, status, attempts, maxAttempts, createdAt, updatedAt, error + FROM muxer_jobs + WHERE status = 'pending' + ORDER BY createdAt ASC + LIMIT 10 + `); + + const rows = stmt.all() as Array<{ + id: string; + data: string; + status: string; + attempts: number; + maxAttempts: number; + createdAt: number; + updatedAt: number; + error?: string; + }>; + + return rows.map((row) => ({ + ...row, + status: row.status as "pending" | "processing" | "completed" | "failed", + })); +} + +export async function updateJobStatus( + jobId: string, + status: "processing" | "completed" | "failed", + error?: string, +): Promise { + const database = getDatabase(); + const now = Date.now(); + + if (status === "failed") { + const stmt = database.prepare(` + UPDATE muxer_jobs + SET status = ?, attempts = attempts + 1, updatedAt = ?, error = ? + WHERE id = ? + `); + stmt.run(status, now, error || null, jobId); + } else { + const stmt = database.prepare(` + UPDATE muxer_jobs + SET status = ?, updatedAt = ? + WHERE id = ? + `); + stmt.run(status, now, jobId); + } + + logger.info({ jobId, status, error }, "Job status updated"); +} + +export async function retryFailedJob(jobId: string): Promise { + const database = getDatabase(); + + const job = database + .prepare("SELECT * FROM muxer_jobs WHERE id = ?") + .get(jobId) as StoredJob | undefined; + + if (!job) { + logger.warn({ jobId }, "Job not found"); + return false; + } + + if (job.attempts >= job.maxAttempts) { + logger.warn( + { jobId, attempts: job.attempts, maxAttempts: job.maxAttempts }, + "Max retry attempts reached", + ); + return false; + } + + const stmt = database.prepare(` + UPDATE muxer_jobs + SET status = 'pending', updatedAt = ? + WHERE id = ? + `); + + stmt.run(Date.now(), jobId); + logger.info({ jobId, attempt: job.attempts + 1 }, "Job retried"); + + return true; +} + +export async function cleanupCompletedJobs( + olderThanMs: number = 24 * 60 * 60 * 1000, +): Promise { + const database = getDatabase(); + const cutoffTime = Date.now() - olderThanMs; + + const stmt = database.prepare(` + DELETE FROM muxer_jobs + WHERE status = 'completed' AND updatedAt < ? + `); + + const result = stmt.run(cutoffTime); + logger.info({ deletedCount: result.changes }, "Cleaned up completed jobs"); + + return result.changes; +} + +export async function getJobStats(): Promise<{ + pending: number; + processing: number; + completed: number; + failed: number; +}> { + const database = getDatabase(); + + const stats = database + .prepare(` + SELECT + SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending, + SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END) as processing, + SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed, + SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed + FROM muxer_jobs + `) + .get() as { + pending: number | null; + processing: number | null; + completed: number | null; + failed: number | null; + }; + + return { + pending: stats.pending || 0, + processing: stats.processing || 0, + completed: stats.completed || 0, + failed: stats.failed || 0, + }; +} + +export async function closeQueue(): Promise { + if (db) { + db.close(); + db = null; + logger.info("Muxer queue closed"); + } +} diff --git a/src/webserver.ts b/src/webserver.ts index d2dc86f..4100633 100644 --- a/src/webserver.ts +++ b/src/webserver.ts @@ -6,6 +6,7 @@ import pinoHttp from "pino-http"; import prism from "prism-media"; import { WebSocketServer } from "ws"; import { createChildLogger, logger } from "./logger"; +import { getMetrics, uptimeGauge } from "./metrics"; import { discordPlayer } from "./player"; const wsLogger = createChildLogger("webserver"); @@ -68,6 +69,13 @@ export function startWebserver(port: number = 3000) { }); }); + // Metrics endpoint + app.get("/metrics", async (_req, res) => { + res.set("Content-Type", "text/plain"); + uptimeGauge.set(process.uptime()); + res.send(await getMetrics()); + }); + // Inbound: Discord PCM → tagged chunks → browser (global as any).broadcastPcmToWeb = (chunk: Buffer, userId: string) => { let hash = 0;