feat: add voice recordings feature with database integration

- Implemented PostgreSQL and SQLite schemas for voice recordings.
- Created repository functions for inserting, updating, and listing voice recordings.
- Developed uploader logic to handle file uploads and database updates.
- Added routes for accessing voice recordings via API.
- Enhanced UI state to include recordings tab.
- Introduced moderation WebSocket event for uploaded recordings.
This commit is contained in:
MythEclipse
2026-05-18 20:15:38 +07:00
parent 9d5559a45b
commit 3b9e87ee6c
17 changed files with 1404 additions and 13 deletions
+80
View File
@@ -201,6 +201,41 @@ export const pgAIAnalysisRunsTable = pgTable(
}),
);
/**
* Voice Recordings Table (PostgreSQL)
* Stores voice recording segment metadata and upload status
*/
export const pgVoiceRecordingsTable = pgTable(
"voice_recordings",
{
id: pgText("id").primaryKey(),
user_id: pgText("user_id").notNull(),
username: pgText("username").notNull(),
avatar_url: pgText("avatar_url"),
guild_id: pgText("guild_id"),
channel_id: pgText("channel_id"),
channel_name: pgText("channel_name"),
filename: pgText("filename").notNull(),
size_bytes: pgInteger("size_bytes").notNull(),
download_url: pgText("download_url"),
upload_status: pgText("upload_status", {
enum: ["pending", "uploaded", "failed"],
})
.notNull()
.default("pending"),
upload_error: pgText("upload_error"),
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
uploaded_at: pgBigint("uploaded_at", { mode: "number" }),
},
(table) => ({
userIdIdx: pgIndex("idx_voice_recordings_user_id").on(table.user_id),
channelIdIdx: pgIndex("idx_voice_recordings_channel_id").on(
table.channel_id,
),
createdIdx: pgIndex("idx_voice_recordings_created_at").on(table.created_at),
}),
);
// SQLite Schema
// =============
@@ -378,6 +413,43 @@ export const sqliteAIAnalysisRunsTable = sqliteTable(
}),
);
/**
* Voice Recordings Table (SQLite)
* Stores voice recording segment metadata and upload status
*/
export const sqliteVoiceRecordingsTable = sqliteTable(
"voice_recordings",
{
id: sqliteText("id").primaryKey(),
user_id: sqliteText("user_id").notNull(),
username: sqliteText("username").notNull(),
avatar_url: sqliteText("avatar_url"),
guild_id: sqliteText("guild_id"),
channel_id: sqliteText("channel_id"),
channel_name: sqliteText("channel_name"),
filename: sqliteText("filename").notNull(),
size_bytes: sqliteInteger("size_bytes").notNull(),
download_url: sqliteText("download_url"),
upload_status: sqliteText("upload_status", {
enum: ["pending", "uploaded", "failed"],
})
.notNull()
.default("pending"),
upload_error: sqliteText("upload_error"),
created_at: sqliteInteger("created_at").notNull(),
uploaded_at: sqliteInteger("uploaded_at"),
},
(table) => ({
userIdIdx: sqliteIndex("idx_voice_recordings_user_id").on(table.user_id),
channelIdIdx: sqliteIndex("idx_voice_recordings_channel_id").on(
table.channel_id,
),
createdIdx: sqliteIndex("idx_voice_recordings_created_at").on(
table.created_at,
),
}),
);
// Runtime table selection based on config
// ========================================
@@ -400,6 +472,11 @@ export const aiAnalysisRunsTable =
? pgAIAnalysisRunsTable
: sqliteAIAnalysisRunsTable;
export const voiceRecordingsTable =
config.DATABASE_TYPE === "postgres"
? pgVoiceRecordingsTable
: sqliteVoiceRecordingsTable;
// Export table types for use in queries
export type MuxerJob = typeof muxerJobsTable.$inferSelect;
export type MuxerJobInsert = typeof muxerJobsTable.$inferInsert;
@@ -415,3 +492,6 @@ export type UIStateInsert = typeof uiStateTable.$inferInsert;
export type AIAnalysisRun = typeof aiAnalysisRunsTable.$inferSelect;
export type AIAnalysisRunInsert = typeof aiAnalysisRunsTable.$inferInsert;
export type VoiceRecording = typeof voiceRecordingsTable.$inferSelect;
export type VoiceRecordingInsert = typeof voiceRecordingsTable.$inferInsert;
+115
View File
@@ -0,0 +1,115 @@
import { desc, eq } from "drizzle-orm";
import { createChildLogger } from "../logger";
import { getDatabase } from "./drizzle";
import {
type VoiceRecording,
type VoiceRecordingInsert,
voiceRecordingsTable,
} from "./schema";
const logger = createChildLogger("voice-recording-repo");
interface QueryBuilder<T = unknown> extends PromiseLike<T> {
from(...args: unknown[]): QueryBuilder<T>;
where(...args: unknown[]): QueryBuilder<T>;
orderBy(...args: unknown[]): QueryBuilder<T>;
limit(...args: unknown[]): QueryBuilder<T>;
offset(...args: unknown[]): QueryBuilder<T>;
values(...args: unknown[]): QueryBuilder<T>;
onConflictDoNothing(...args: unknown[]): QueryBuilder<T>;
returning(...args: unknown[]): QueryBuilder<T>;
set(...args: unknown[]): QueryBuilder<T>;
}
interface RecordingDatabase {
select<T = unknown[]>(...args: unknown[]): QueryBuilder<T>;
insert<T = unknown>(...args: unknown[]): QueryBuilder<T>;
update(...args: unknown[]): QueryBuilder<unknown>;
}
function db(): RecordingDatabase {
return getDatabase() as unknown as RecordingDatabase;
}
export async function insertVoiceRecording(
recording: VoiceRecordingInsert,
): Promise<void> {
try {
await db()
.insert(voiceRecordingsTable)
.values(recording)
.onConflictDoNothing();
} catch (error) {
logger.error(
{
id: recording.id,
error: error instanceof Error ? error.message : String(error),
},
"Failed to insert voice recording",
);
throw error;
}
}
export async function updateVoiceRecordingAsUploaded(
id: string,
downloadUrl: string,
uploadedAt: number,
): Promise<void> {
try {
await db()
.update(voiceRecordingsTable)
.set({
download_url: downloadUrl,
upload_status: "uploaded",
uploaded_at: uploadedAt,
})
.where(eq(voiceRecordingsTable.id, id));
} catch (error) {
logger.error(
{ id, error: error instanceof Error ? error.message : String(error) },
"Failed to update voice recording status to uploaded",
);
throw error;
}
}
export async function updateVoiceRecordingAsFailed(
id: string,
error: string,
): Promise<void> {
try {
await db()
.update(voiceRecordingsTable)
.set({
upload_status: "failed",
upload_error: error,
})
.where(eq(voiceRecordingsTable.id, id));
} catch (error) {
logger.error(
{ id, error: error instanceof Error ? error.message : String(error) },
"Failed to update voice recording status to failed",
);
throw error;
}
}
export async function listVoiceRecordings(
limit = 100,
): Promise<VoiceRecording[]> {
try {
const rows = await db()
.select()
.from(voiceRecordingsTable)
.orderBy(desc(voiceRecordingsTable.created_at))
.limit(limit);
return rows as VoiceRecording[];
} catch (error) {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Failed to list voice recordings",
);
throw error;
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { createChildLogger } from "../logger";
import { AppError } from "../errors";
import { createChildLogger } from "../logger";
import { discordPlayer } from "../player";
import { MediaQueue } from "./mediaQueue";
import { resolveMediaSource } from "./mediaResolver";
+2 -1
View File
@@ -128,7 +128,8 @@ export type ModerationWsEvent =
| { type: "message_analyzed"; data: MessageRecord }
| { type: "attachment_created"; data: AttachmentRecord }
| { type: "analysis_queue_status"; data: AnalysisQueueStatus }
| { type: "media_state"; state: MediaState };
| { type: "media_state"; state: MediaState }
| { type: "voice_recording_uploaded"; data: any };
export interface AnalysisQueueStatus {
queuedConversations: number;
+19
View File
@@ -25,6 +25,7 @@ import {
finalizeRecordingSession,
type RecordingSession,
} from "./recorder/sessionRecording";
import { uploadRecordingSegment } from "./recorder/uploader";
import { retryWithBackoff } from "./retry";
import type { PcmBroadcaster } from "./types";
@@ -211,6 +212,24 @@ export async function startRecording(
"Metadata saved",
);
}
// Trigger async voice segment upload
const segmentId = `${userId}-${currentSegment.startTime}`;
uploadRecordingSegment({
id: segmentId,
oggPath: currentSegment.filename,
userId: userMetadata.userId,
username: userMetadata.username,
avatarUrl: userMetadata.avatarUrl,
guildId: channel.guild.id,
channelId: channel.id,
channelName: channel.name,
}).catch((err) => {
logger.error(
{ segmentId, error: err.message },
"Upload segment trigger failed",
);
});
});
currentSegment.out.on("error", (err) => {
+145
View File
@@ -0,0 +1,145 @@
import fs from "node:fs";
import path from "node:path";
import {
insertVoiceRecording,
updateVoiceRecordingAsFailed,
updateVoiceRecordingAsUploaded,
} from "../database/voiceRecordingRepo";
import { createChildLogger } from "../logger";
import { retryWithBackoff } from "../retry";
const logger = createChildLogger("recording-uploader");
export interface UploadResponse {
download_url: string;
public_id: string;
file_name: string;
size_bytes: number;
}
/**
* Uploads a recorded segment OGG file to external server and registers in database
*/
export async function uploadRecordingSegment(input: {
id: string;
oggPath: string;
userId: string;
username: string;
avatarUrl: string | null;
guildId: string | null;
channelId: string | null;
channelName: string | null;
}): Promise<void> {
const {
id,
oggPath,
userId,
username,
avatarUrl,
guildId,
channelId,
channelName,
} = input;
const fileName = path.basename(oggPath);
try {
// 1. Get file size and insert initial pending state to DB
const stats = fs.statSync(oggPath);
await insertVoiceRecording({
id,
user_id: userId,
username,
avatar_url: avatarUrl,
guild_id: guildId,
channel_id: channelId,
channel_name: channelName,
filename: fileName,
size_bytes: stats.size,
upload_status: "pending",
created_at: Date.now(),
});
// 2. Perform async upload with retry logic
const downloadUrl = await retryWithBackoff(
async () => {
const fileBuffer = fs.readFileSync(oggPath);
const fileBlob = new Blob([fileBuffer], { type: "audio/ogg" });
const formData = new FormData();
formData.append("file", fileBlob, fileName);
formData.append("fileName", fileName);
const res = await fetch("https://upload.asepharyana.tech/api/upload", {
method: "POST",
headers: {
accept: "application/json",
},
body: formData,
});
if (!res.ok) {
throw new Error(`Upload failed: Status ${res.status}`);
}
const data = (await res.json()) as UploadResponse;
if (!data.download_url) {
throw new Error("Missing download_url in response");
}
return data.download_url;
},
{
retries: 3,
minTimeout: 1000,
maxTimeout: 5000,
logger,
},
);
// 3. Update DB to uploaded state
await updateVoiceRecordingAsUploaded(id, downloadUrl, Date.now());
logger.info({ id, downloadUrl }, "Recording segment uploaded successfully");
// 4. Broadcast via WebSocket if broadcaster exists globally
const broadcaster = (globalThis as any).moderationBroadcaster;
if (broadcaster) {
const payload = JSON.stringify({
type: "voice_recording_uploaded",
data: {
id,
user_id: userId,
username,
avatar_url: avatarUrl,
guild_id: guildId,
channel_id: channelId,
channel_name: channelName,
filename: fileName,
size_bytes: stats.size,
download_url: downloadUrl,
upload_status: "uploaded",
created_at: Date.now(),
uploaded_at: Date.now(),
},
timestamp: Date.now(),
});
broadcaster.getClients().forEach((client: any) => {
if (client.readyState === 1) {
try {
client.send(payload);
} catch (err) {
logger.warn(
{ err },
"Failed to send recording upload event to client",
);
}
}
});
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
logger.error({ id, error: errorMsg }, "Failed to upload voice recording");
await updateVoiceRecordingAsFailed(id, errorMsg).catch((err) => {
logger.error({ id, err }, "Failed to write failure state to DB");
});
}
}
+1 -1
View File
@@ -1,7 +1,6 @@
import type { Router } from "express";
import express from "express";
import { AppError } from "../errors";
import type { MessageRecord } from "../moderation/types";
import {
getAnalysisQueueStatus,
queueMessageAnalysis,
@@ -11,6 +10,7 @@ import {
searchMessages,
updateMessageAIAnalysis,
} from "../moderation/messageStore";
import type { MessageRecord } from "../moderation/types";
export function createAnalysisRoutes(): Router {
const router = express.Router();
+39
View File
@@ -0,0 +1,39 @@
import {
Router,
type Request,
type Response,
type NextFunction,
} from "express";
import { listVoiceRecordings } from "../database/voiceRecordingRepo";
import { AppError } from "../errors";
import { createChildLogger } from "../logger";
const logger = createChildLogger("recordings-routes");
export function createRecordingsRoutes(): Router {
const router = Router();
router.get(
"/recordings",
async (_req: Request, res: Response, next: NextFunction) => {
try {
const recordings = await listVoiceRecordings(100);
res.json(recordings);
} catch (error) {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Failed to list recordings",
);
next(
new AppError(
"DATABASE_ERROR",
"Failed to retrieve voice recordings",
500,
),
);
}
},
);
return router;
}
+1 -1
View File
@@ -9,7 +9,7 @@ export interface SharedUIState {
selectedVoiceChannel: string;
selectedTextGuild: string;
selectedTextChannel: string;
activeTab: "voice" | "messages" | "media" | "review";
activeTab: "voice" | "messages" | "media" | "review" | "recordings";
isListening: boolean;
isStreaming: boolean;
}
+10 -5
View File
@@ -28,6 +28,7 @@ import { createMessageRoutes } from "./routes/messageRoutes";
import { createSyncRoutes } from "./routes/syncRoutes";
import { createUIStateRoutes } from "./routes/uiStateRoutes";
import { createVoiceRoutes } from "./routes/voiceRoutes";
import { createRecordingsRoutes } from "./routes/recordingsRoutes";
import { Streamer } from "./streaming";
import type { VoiceController } from "./voiceController";
@@ -56,7 +57,7 @@ interface SharedUIState {
selectedVoiceChannel: string;
selectedTextGuild: string;
selectedTextChannel: string;
activeTab: "voice" | "messages" | "media" | "review";
activeTab: "voice" | "messages" | "media" | "review" | "recordings";
isListening: boolean;
isStreaming: boolean;
}
@@ -94,11 +95,11 @@ export function normalizeSharedUIState(
selectedVoiceChannel: value.selectedVoiceChannel ?? "",
selectedTextGuild: value.selectedTextGuild ?? guild,
selectedTextChannel: value.selectedTextChannel ?? "",
activeTab: (["voice", "messages", "media", "review"].includes(
activeTab: (["voice", "messages", "media", "review", "recordings"].includes(
value.activeTab ?? "",
)
? value.activeTab
: "voice") as "voice" | "messages" | "media" | "review",
: "voice") as "voice" | "messages" | "media" | "review" | "recordings",
isListening: value.isListening ?? false,
isStreaming: value.isStreaming ?? false,
};
@@ -143,13 +144,16 @@ function patchSharedUIState(patch: SharedUIStatePatch) {
sharedUIState.selectedTextChannel = patch.selectedTextChannel;
}
if (
["voice", "messages", "media", "review"].includes(patch.activeTab ?? "")
["voice", "messages", "media", "review", "recordings"].includes(
patch.activeTab ?? "",
)
) {
sharedUIState.activeTab = patch.activeTab as
| "voice"
| "messages"
| "media"
| "review";
| "review"
| "recordings";
}
if (typeof patch.isListening === "boolean") {
sharedUIState.isListening = patch.isListening;
@@ -325,6 +329,7 @@ export async function startWebserver(
app.use("/api", createMessageRoutes());
app.use("/api", createAnalysisRoutes());
app.use("/api", createSyncRoutes(_client));
app.use("/api", createRecordingsRoutes());
app.use(
"/api",
createMediaRoutes(mediaController, {