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:
@@ -0,0 +1,20 @@
|
|||||||
|
CREATE TABLE "voice_recordings" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"username" text NOT NULL,
|
||||||
|
"avatar_url" text,
|
||||||
|
"guild_id" text,
|
||||||
|
"channel_id" text,
|
||||||
|
"channel_name" text,
|
||||||
|
"filename" text NOT NULL,
|
||||||
|
"size_bytes" integer NOT NULL,
|
||||||
|
"download_url" text,
|
||||||
|
"upload_status" text DEFAULT 'pending' NOT NULL,
|
||||||
|
"upload_error" text,
|
||||||
|
"created_at" bigint NOT NULL,
|
||||||
|
"uploaded_at" bigint
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_voice_recordings_user_id" ON "voice_recordings" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_voice_recordings_channel_id" ON "voice_recordings" USING btree ("channel_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_voice_recordings_created_at" ON "voice_recordings" USING btree ("created_at");
|
||||||
@@ -0,0 +1,943 @@
|
|||||||
|
{
|
||||||
|
"id": "a4dd8687-4a2c-4520-9cc5-03aa1774e383",
|
||||||
|
"prevId": "79348c3a-b314-4bfd-88f3-7ddfbea4427e",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.ai_analysis_runs": {
|
||||||
|
"name": "ai_analysis_runs",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"conversation_key": {
|
||||||
|
"name": "conversation_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"target_message_ids": {
|
||||||
|
"name": "target_message_ids",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"name": "model",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"request_tokens_estimate": {
|
||||||
|
"name": "request_tokens_estimate",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"response_raw": {
|
||||||
|
"name": "response_raw",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'pending'"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"name": "error",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"completed_at": {
|
||||||
|
"name": "completed_at",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"idx_ai_analysis_runs_conversation_key": {
|
||||||
|
"name": "idx_ai_analysis_runs_conversation_key",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "conversation_key",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"idx_ai_analysis_runs_status": {
|
||||||
|
"name": "idx_ai_analysis_runs_status",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "status",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"idx_ai_analysis_runs_created_at": {
|
||||||
|
"name": "idx_ai_analysis_runs_created_at",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "created_at",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.attachments": {
|
||||||
|
"name": "attachments",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"message_id": {
|
||||||
|
"name": "message_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"guild_id": {
|
||||||
|
"name": "guild_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"channel_id": {
|
||||||
|
"name": "channel_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"thread_id": {
|
||||||
|
"name": "thread_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"filename": {
|
||||||
|
"name": "filename",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"name": "size",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"discord_url": {
|
||||||
|
"name": "discord_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"uploaded_url": {
|
||||||
|
"name": "uploaded_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"upload_status": {
|
||||||
|
"name": "upload_status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'pending'"
|
||||||
|
},
|
||||||
|
"upload_error": {
|
||||||
|
"name": "upload_error",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"uploaded_at": {
|
||||||
|
"name": "uploaded_at",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"idx_attachments_channel": {
|
||||||
|
"name": "idx_attachments_channel",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "channel_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"idx_attachments_message": {
|
||||||
|
"name": "idx_attachments_message",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "message_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"idx_attachments_status": {
|
||||||
|
"name": "idx_attachments_status",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "upload_status",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"idx_attachments_channel_created": {
|
||||||
|
"name": "idx_attachments_channel_created",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "channel_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "created_at",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"idx_attachments_thread_created": {
|
||||||
|
"name": "idx_attachments_thread_created",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "thread_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "created_at",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"fk_attachments_message_id": {
|
||||||
|
"name": "fk_attachments_message_id",
|
||||||
|
"tableFrom": "attachments",
|
||||||
|
"tableTo": "messages",
|
||||||
|
"columnsFrom": [
|
||||||
|
"message_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.messages": {
|
||||||
|
"name": "messages",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"guild_id": {
|
||||||
|
"name": "guild_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"channel_id": {
|
||||||
|
"name": "channel_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"thread_id": {
|
||||||
|
"name": "thread_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"avatar_url": {
|
||||||
|
"name": "avatar_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"name": "content",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"edited_content": {
|
||||||
|
"name": "edited_content",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"edited_at": {
|
||||||
|
"name": "edited_at",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"deleted_at": {
|
||||||
|
"name": "deleted_at",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'text'"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"name": "metadata",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"ai_status": {
|
||||||
|
"name": "ai_status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'pending'"
|
||||||
|
},
|
||||||
|
"ai_moderation_flags": {
|
||||||
|
"name": "ai_moderation_flags",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"ai_moderation_score": {
|
||||||
|
"name": "ai_moderation_score",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"ai_moderation_raw": {
|
||||||
|
"name": "ai_moderation_raw",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"ai_analysis": {
|
||||||
|
"name": "ai_analysis",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"ai_analyzed_at": {
|
||||||
|
"name": "ai_analyzed_at",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"ai_error": {
|
||||||
|
"name": "ai_error",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"idx_messages_channel": {
|
||||||
|
"name": "idx_messages_channel",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "channel_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"idx_messages_user": {
|
||||||
|
"name": "idx_messages_user",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"idx_messages_created": {
|
||||||
|
"name": "idx_messages_created",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "created_at",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"idx_messages_thread": {
|
||||||
|
"name": "idx_messages_thread",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "thread_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"idx_messages_channel_created": {
|
||||||
|
"name": "idx_messages_channel_created",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "channel_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "created_at",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"idx_messages_thread_created": {
|
||||||
|
"name": "idx_messages_thread_created",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "thread_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "created_at",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"idx_messages_ai_status_created": {
|
||||||
|
"name": "idx_messages_ai_status_created",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "ai_status",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "created_at",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"idx_messages_guild_ai_status_created": {
|
||||||
|
"name": "idx_messages_guild_ai_status_created",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "guild_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "ai_status",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "created_at",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.muxer_jobs": {
|
||||||
|
"name": "muxer_jobs",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"name": "data",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'pending'"
|
||||||
|
},
|
||||||
|
"attempts": {
|
||||||
|
"name": "attempts",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"maxAttempts": {
|
||||||
|
"name": "maxAttempts",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 3
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"name": "createdAt",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"name": "updatedAt",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"name": "error",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"idx_muxer_jobs_status": {
|
||||||
|
"name": "idx_muxer_jobs_status",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "status",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"idx_muxer_jobs_createdAt": {
|
||||||
|
"name": "idx_muxer_jobs_createdAt",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "createdAt",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.ui_state": {
|
||||||
|
"name": "ui_state",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"key": {
|
||||||
|
"name": "key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.voice_recordings": {
|
||||||
|
"name": "voice_recordings",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"avatar_url": {
|
||||||
|
"name": "avatar_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"guild_id": {
|
||||||
|
"name": "guild_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"channel_id": {
|
||||||
|
"name": "channel_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"channel_name": {
|
||||||
|
"name": "channel_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"filename": {
|
||||||
|
"name": "filename",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"size_bytes": {
|
||||||
|
"name": "size_bytes",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"download_url": {
|
||||||
|
"name": "download_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"upload_status": {
|
||||||
|
"name": "upload_status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'pending'"
|
||||||
|
},
|
||||||
|
"upload_error": {
|
||||||
|
"name": "upload_error",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"uploaded_at": {
|
||||||
|
"name": "uploaded_at",
|
||||||
|
"type": "bigint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"idx_voice_recordings_user_id": {
|
||||||
|
"name": "idx_voice_recordings_user_id",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"idx_voice_recordings_channel_id": {
|
||||||
|
"name": "idx_voice_recordings_channel_id",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "channel_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"idx_voice_recordings_created_at": {
|
||||||
|
"name": "idx_voice_recordings_created_at",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "created_at",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,13 @@
|
|||||||
"when": 1778764447718,
|
"when": 1778764447718,
|
||||||
"tag": "0001_curious_zodiak",
|
"tag": "0001_curious_zodiak",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1779109619461,
|
||||||
|
"tag": "0002_dark_omega_flight",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+14
-2
@@ -5,6 +5,7 @@ import { MessagesPanel } from "./components/messages/MessagesPanel";
|
|||||||
import { ReviewPanel } from "./components/review/ReviewPanel";
|
import { ReviewPanel } from "./components/review/ReviewPanel";
|
||||||
import { Tabs, TabsContent } from "./components/ui/tabs";
|
import { Tabs, TabsContent } from "./components/ui/tabs";
|
||||||
import { VoicePanel } from "./components/voice/VoicePanel";
|
import { VoicePanel } from "./components/voice/VoicePanel";
|
||||||
|
import { RecordingsPanel } from "./components/recordings/RecordingsPanel";
|
||||||
import { AuthOverlay } from "./components/layout/AuthOverlay";
|
import { AuthOverlay } from "./components/layout/AuthOverlay";
|
||||||
import { useDashboardSocket } from "./hooks/useDashboardSocket";
|
import { useDashboardSocket } from "./hooks/useDashboardSocket";
|
||||||
import { mergeMessages, useMessages } from "./hooks/useMessages";
|
import { mergeMessages, useMessages } from "./hooks/useMessages";
|
||||||
@@ -75,6 +76,10 @@ export default function App() {
|
|||||||
onMessageAnalyzed: (message) => messages.setMessages((prev) => mergeMessages(prev, [message])),
|
onMessageAnalyzed: (message) => messages.setMessages((prev) => mergeMessages(prev, [message])),
|
||||||
onAttachmentUploaded: () => messages.fetchMessages(selectedTextChannel).catch(() => undefined),
|
onAttachmentUploaded: () => messages.fetchMessages(selectedTextChannel).catch(() => undefined),
|
||||||
onMediaState: media.setMediaState,
|
onMediaState: media.setMediaState,
|
||||||
|
onVoiceRecordingUploaded: (recording) => {
|
||||||
|
const event = new CustomEvent("voice_recording_uploaded", { detail: recording });
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
},
|
||||||
onPcm: handleIncomingPcm,
|
onPcm: handleIncomingPcm,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -178,7 +183,7 @@ export default function App() {
|
|||||||
await patchUIState({ isListening: true });
|
await patchUIState({ isListening: true });
|
||||||
}, [isListening, patchUIState]);
|
}, [isListening, patchUIState]);
|
||||||
|
|
||||||
const tabs = useMemo(() => ["voice", "media", "messages", "review"] as DashboardTab[], []);
|
const tabs = useMemo(() => ["voice", "media", "messages", "recordings", "review"] as DashboardTab[], []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout
|
<DashboardLayout
|
||||||
@@ -189,7 +194,7 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
<Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}>
|
<Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}>
|
||||||
<div className="mb-4 grid grid-cols-4 gap-2 rounded-2xl bg-muted p-1">
|
<div className="mb-4 grid grid-cols-5 gap-2 rounded-2xl bg-muted p-1">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button key={tab} className={`rounded-xl px-2 py-2 text-xs font-medium ${activeTab === tab ? "bg-background text-foreground" : "text-muted-foreground"}`} onClick={() => patchUIState({ activeTab: tab })}>
|
<button key={tab} className={`rounded-xl px-2 py-2 text-xs font-medium ${activeTab === tab ? "bg-background text-foreground" : "text-muted-foreground"}`} onClick={() => patchUIState({ activeTab: tab })}>
|
||||||
{tab}
|
{tab}
|
||||||
@@ -250,6 +255,13 @@ export default function App() {
|
|||||||
onReanalyze={messages.reanalyze}
|
onReanalyze={messages.reanalyze}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="recordings">
|
||||||
|
{!isAuthenticated ? (
|
||||||
|
<AuthOverlay onAuthenticated={() => setIsAuthenticated(true)} />
|
||||||
|
) : (
|
||||||
|
<RecordingsPanel />
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
<TabsContent value="review">
|
<TabsContent value="review">
|
||||||
<ReviewPanel messages={messages.messages} onReanalyze={messages.reanalyze} />
|
<ReviewPanel messages={messages.messages} onReanalyze={messages.reanalyze} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Bot, MessageSquare, Music2, ShieldAlert, Volume2 } from "lucide-react";
|
import { Bot, MessageSquare, Music2, ShieldAlert, Volume2, Mic } from "lucide-react";
|
||||||
import type { DashboardTab } from "../../types/ui";
|
import type { DashboardTab } from "../../types/ui";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
@@ -7,6 +7,7 @@ const navItems: Array<{ id: DashboardTab; label: string; icon: typeof Volume2 }>
|
|||||||
{ id: "voice", label: "Voice", icon: Volume2 },
|
{ id: "voice", label: "Voice", icon: Volume2 },
|
||||||
{ id: "media", label: "Media", icon: Music2 },
|
{ id: "media", label: "Media", icon: Music2 },
|
||||||
{ id: "messages", label: "Messages", icon: MessageSquare },
|
{ id: "messages", label: "Messages", icon: MessageSquare },
|
||||||
|
{ id: "recordings", label: "Recordings", icon: Mic },
|
||||||
{ id: "review", label: "Review", icon: ShieldAlert },
|
{ id: "review", label: "Review", icon: ShieldAlert },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface DashboardSocketHandlers {
|
|||||||
onMessageAnalyzed?: (message: MessageRecord) => void;
|
onMessageAnalyzed?: (message: MessageRecord) => void;
|
||||||
onAttachmentUploaded?: () => void;
|
onAttachmentUploaded?: () => void;
|
||||||
onMediaState?: (state: MediaState) => void;
|
onMediaState?: (state: MediaState) => void;
|
||||||
|
onVoiceRecordingUploaded?: (recording: any) => void;
|
||||||
onPcm?: (data: ArrayBuffer) => void;
|
onPcm?: (data: ArrayBuffer) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +76,9 @@ export function useDashboardSocket(handlers: DashboardSocketHandlers) {
|
|||||||
case "media_state":
|
case "media_state":
|
||||||
handlersRef.current.onMediaState?.(message.state);
|
handlersRef.current.onMediaState?.(message.state);
|
||||||
break;
|
break;
|
||||||
|
case "voice_recording_uploaded":
|
||||||
|
handlersRef.current.onVoiceRecordingUploaded?.(message.data);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore malformed socket messages
|
// ignore malformed socket messages
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type DashboardTab = "voice" | "media" | "messages" | "review";
|
export type DashboardTab = "voice" | "media" | "messages" | "review" | "recordings";
|
||||||
|
|
||||||
export interface UIState {
|
export interface UIState {
|
||||||
selectedGuild?: string;
|
selectedGuild?: string;
|
||||||
|
|||||||
@@ -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
|
// 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
|
// Runtime table selection based on config
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
@@ -400,6 +472,11 @@ export const aiAnalysisRunsTable =
|
|||||||
? pgAIAnalysisRunsTable
|
? pgAIAnalysisRunsTable
|
||||||
: sqliteAIAnalysisRunsTable;
|
: sqliteAIAnalysisRunsTable;
|
||||||
|
|
||||||
|
export const voiceRecordingsTable =
|
||||||
|
config.DATABASE_TYPE === "postgres"
|
||||||
|
? pgVoiceRecordingsTable
|
||||||
|
: sqliteVoiceRecordingsTable;
|
||||||
|
|
||||||
// Export table types for use in queries
|
// Export table types for use in queries
|
||||||
export type MuxerJob = typeof muxerJobsTable.$inferSelect;
|
export type MuxerJob = typeof muxerJobsTable.$inferSelect;
|
||||||
export type MuxerJobInsert = typeof muxerJobsTable.$inferInsert;
|
export type MuxerJobInsert = typeof muxerJobsTable.$inferInsert;
|
||||||
@@ -415,3 +492,6 @@ export type UIStateInsert = typeof uiStateTable.$inferInsert;
|
|||||||
|
|
||||||
export type AIAnalysisRun = typeof aiAnalysisRunsTable.$inferSelect;
|
export type AIAnalysisRun = typeof aiAnalysisRunsTable.$inferSelect;
|
||||||
export type AIAnalysisRunInsert = typeof aiAnalysisRunsTable.$inferInsert;
|
export type AIAnalysisRunInsert = typeof aiAnalysisRunsTable.$inferInsert;
|
||||||
|
|
||||||
|
export type VoiceRecording = typeof voiceRecordingsTable.$inferSelect;
|
||||||
|
export type VoiceRecordingInsert = typeof voiceRecordingsTable.$inferInsert;
|
||||||
|
|||||||
@@ -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,5 +1,5 @@
|
|||||||
import { createChildLogger } from "../logger";
|
|
||||||
import { AppError } from "../errors";
|
import { AppError } from "../errors";
|
||||||
|
import { createChildLogger } from "../logger";
|
||||||
import { discordPlayer } from "../player";
|
import { discordPlayer } from "../player";
|
||||||
import { MediaQueue } from "./mediaQueue";
|
import { MediaQueue } from "./mediaQueue";
|
||||||
import { resolveMediaSource } from "./mediaResolver";
|
import { resolveMediaSource } from "./mediaResolver";
|
||||||
|
|||||||
@@ -128,7 +128,8 @@ export type ModerationWsEvent =
|
|||||||
| { type: "message_analyzed"; data: MessageRecord }
|
| { type: "message_analyzed"; data: MessageRecord }
|
||||||
| { type: "attachment_created"; data: AttachmentRecord }
|
| { type: "attachment_created"; data: AttachmentRecord }
|
||||||
| { type: "analysis_queue_status"; data: AnalysisQueueStatus }
|
| { 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 {
|
export interface AnalysisQueueStatus {
|
||||||
queuedConversations: number;
|
queuedConversations: number;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
finalizeRecordingSession,
|
finalizeRecordingSession,
|
||||||
type RecordingSession,
|
type RecordingSession,
|
||||||
} from "./recorder/sessionRecording";
|
} from "./recorder/sessionRecording";
|
||||||
|
import { uploadRecordingSegment } from "./recorder/uploader";
|
||||||
import { retryWithBackoff } from "./retry";
|
import { retryWithBackoff } from "./retry";
|
||||||
import type { PcmBroadcaster } from "./types";
|
import type { PcmBroadcaster } from "./types";
|
||||||
|
|
||||||
@@ -211,6 +212,24 @@ export async function startRecording(
|
|||||||
"Metadata saved",
|
"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) => {
|
currentSegment.out.on("error", (err) => {
|
||||||
|
|||||||
@@ -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,7 +1,6 @@
|
|||||||
import type { Router } from "express";
|
import type { Router } from "express";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { AppError } from "../errors";
|
import { AppError } from "../errors";
|
||||||
import type { MessageRecord } from "../moderation/types";
|
|
||||||
import {
|
import {
|
||||||
getAnalysisQueueStatus,
|
getAnalysisQueueStatus,
|
||||||
queueMessageAnalysis,
|
queueMessageAnalysis,
|
||||||
@@ -11,6 +10,7 @@ import {
|
|||||||
searchMessages,
|
searchMessages,
|
||||||
updateMessageAIAnalysis,
|
updateMessageAIAnalysis,
|
||||||
} from "../moderation/messageStore";
|
} from "../moderation/messageStore";
|
||||||
|
import type { MessageRecord } from "../moderation/types";
|
||||||
|
|
||||||
export function createAnalysisRoutes(): Router {
|
export function createAnalysisRoutes(): Router {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ export interface SharedUIState {
|
|||||||
selectedVoiceChannel: string;
|
selectedVoiceChannel: string;
|
||||||
selectedTextGuild: string;
|
selectedTextGuild: string;
|
||||||
selectedTextChannel: string;
|
selectedTextChannel: string;
|
||||||
activeTab: "voice" | "messages" | "media" | "review";
|
activeTab: "voice" | "messages" | "media" | "review" | "recordings";
|
||||||
isListening: boolean;
|
isListening: boolean;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-5
@@ -28,6 +28,7 @@ import { createMessageRoutes } from "./routes/messageRoutes";
|
|||||||
import { createSyncRoutes } from "./routes/syncRoutes";
|
import { createSyncRoutes } from "./routes/syncRoutes";
|
||||||
import { createUIStateRoutes } from "./routes/uiStateRoutes";
|
import { createUIStateRoutes } from "./routes/uiStateRoutes";
|
||||||
import { createVoiceRoutes } from "./routes/voiceRoutes";
|
import { createVoiceRoutes } from "./routes/voiceRoutes";
|
||||||
|
import { createRecordingsRoutes } from "./routes/recordingsRoutes";
|
||||||
import { Streamer } from "./streaming";
|
import { Streamer } from "./streaming";
|
||||||
import type { VoiceController } from "./voiceController";
|
import type { VoiceController } from "./voiceController";
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@ interface SharedUIState {
|
|||||||
selectedVoiceChannel: string;
|
selectedVoiceChannel: string;
|
||||||
selectedTextGuild: string;
|
selectedTextGuild: string;
|
||||||
selectedTextChannel: string;
|
selectedTextChannel: string;
|
||||||
activeTab: "voice" | "messages" | "media" | "review";
|
activeTab: "voice" | "messages" | "media" | "review" | "recordings";
|
||||||
isListening: boolean;
|
isListening: boolean;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
}
|
}
|
||||||
@@ -94,11 +95,11 @@ export function normalizeSharedUIState(
|
|||||||
selectedVoiceChannel: value.selectedVoiceChannel ?? "",
|
selectedVoiceChannel: value.selectedVoiceChannel ?? "",
|
||||||
selectedTextGuild: value.selectedTextGuild ?? guild,
|
selectedTextGuild: value.selectedTextGuild ?? guild,
|
||||||
selectedTextChannel: value.selectedTextChannel ?? "",
|
selectedTextChannel: value.selectedTextChannel ?? "",
|
||||||
activeTab: (["voice", "messages", "media", "review"].includes(
|
activeTab: (["voice", "messages", "media", "review", "recordings"].includes(
|
||||||
value.activeTab ?? "",
|
value.activeTab ?? "",
|
||||||
)
|
)
|
||||||
? value.activeTab
|
? value.activeTab
|
||||||
: "voice") as "voice" | "messages" | "media" | "review",
|
: "voice") as "voice" | "messages" | "media" | "review" | "recordings",
|
||||||
isListening: value.isListening ?? false,
|
isListening: value.isListening ?? false,
|
||||||
isStreaming: value.isStreaming ?? false,
|
isStreaming: value.isStreaming ?? false,
|
||||||
};
|
};
|
||||||
@@ -143,13 +144,16 @@ function patchSharedUIState(patch: SharedUIStatePatch) {
|
|||||||
sharedUIState.selectedTextChannel = patch.selectedTextChannel;
|
sharedUIState.selectedTextChannel = patch.selectedTextChannel;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
["voice", "messages", "media", "review"].includes(patch.activeTab ?? "")
|
["voice", "messages", "media", "review", "recordings"].includes(
|
||||||
|
patch.activeTab ?? "",
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
sharedUIState.activeTab = patch.activeTab as
|
sharedUIState.activeTab = patch.activeTab as
|
||||||
| "voice"
|
| "voice"
|
||||||
| "messages"
|
| "messages"
|
||||||
| "media"
|
| "media"
|
||||||
| "review";
|
| "review"
|
||||||
|
| "recordings";
|
||||||
}
|
}
|
||||||
if (typeof patch.isListening === "boolean") {
|
if (typeof patch.isListening === "boolean") {
|
||||||
sharedUIState.isListening = patch.isListening;
|
sharedUIState.isListening = patch.isListening;
|
||||||
@@ -325,6 +329,7 @@ export async function startWebserver(
|
|||||||
app.use("/api", createMessageRoutes());
|
app.use("/api", createMessageRoutes());
|
||||||
app.use("/api", createAnalysisRoutes());
|
app.use("/api", createAnalysisRoutes());
|
||||||
app.use("/api", createSyncRoutes(_client));
|
app.use("/api", createSyncRoutes(_client));
|
||||||
|
app.use("/api", createRecordingsRoutes());
|
||||||
app.use(
|
app.use(
|
||||||
"/api",
|
"/api",
|
||||||
createMediaRoutes(mediaController, {
|
createMediaRoutes(mediaController, {
|
||||||
|
|||||||
Reference in New Issue
Block a user