diff --git a/Backend/db/schema.ts b/Backend/db/schema.ts index 0b878d7..f8c6698 100644 --- a/Backend/db/schema.ts +++ b/Backend/db/schema.ts @@ -77,6 +77,24 @@ export const streamSessions = pgTable('stream_sessions', { updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), }); +export const recordings = pgTable('recordings', { + id: uuid('id').defaultRandom().primaryKey(), + ownerUserId: uuid('owner_user_id').notNull().references(() => users.id), + streamSessionId: uuid('stream_session_id').notNull().references(() => streamSessions.id), + cameraDeviceId: uuid('camera_device_id').notNull().references(() => devices.id), + requesterDeviceId: uuid('requester_device_id').notNull().references(() => devices.id), + eventId: uuid('event_id').references(() => events.id), + objectKey: varchar('object_key', { length: 1024 }), + bucket: varchar('bucket', { length: 255 }), + durationSeconds: integer('duration_seconds'), + sizeBytes: integer('size_bytes'), + status: varchar('status', { length: 32 }).default('awaiting_upload').notNull(), + availableAt: timestamp('available_at', { withTimezone: true }), + error: text('error'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); + export const events = pgTable('events', { id: uuid('id').defaultRandom().primaryKey(), userId: uuid('user_id').notNull().references(() => users.id), @@ -159,6 +177,7 @@ export const schema = { deviceLinks, deviceCommands, streamSessions, + recordings, events, videos, notifications, diff --git a/Backend/drizzle/0009_recordings_pipeline.sql b/Backend/drizzle/0009_recordings_pipeline.sql new file mode 100644 index 0000000..598eacb --- /dev/null +++ b/Backend/drizzle/0009_recordings_pipeline.sql @@ -0,0 +1,23 @@ +CREATE TABLE "recordings" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "owner_user_id" uuid NOT NULL, + "stream_session_id" uuid NOT NULL, + "camera_device_id" uuid NOT NULL, + "requester_device_id" uuid NOT NULL, + "event_id" uuid, + "object_key" varchar(1024), + "bucket" varchar(255), + "duration_seconds" integer, + "size_bytes" integer, + "status" varchar(32) DEFAULT 'awaiting_upload' NOT NULL, + "available_at" timestamp with time zone, + "error" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "recordings" ADD CONSTRAINT "recordings_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "recordings" ADD CONSTRAINT "recordings_stream_session_id_stream_sessions_id_fk" FOREIGN KEY ("stream_session_id") REFERENCES "public"."stream_sessions"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "recordings" ADD CONSTRAINT "recordings_camera_device_id_devices_id_fk" FOREIGN KEY ("camera_device_id") REFERENCES "public"."devices"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "recordings" ADD CONSTRAINT "recordings_requester_device_id_devices_id_fk" FOREIGN KEY ("requester_device_id") REFERENCES "public"."devices"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "recordings" ADD CONSTRAINT "recordings_event_id_events_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."events"("id") ON DELETE no action ON UPDATE no action; diff --git a/Backend/drizzle/meta/_journal.json b/Backend/drizzle/meta/_journal.json index 81506ec..4bb9cd9 100644 --- a/Backend/drizzle/meta/_journal.json +++ b/Backend/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1770415956419, "tag": "0008_media_plane_columns", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1770416956419, + "tag": "0009_recordings_pipeline", + "breakpoints": true } ] } diff --git a/Backend/index.ts b/Backend/index.ts index 499c238..27075d6 100644 --- a/Backend/index.ts +++ b/Backend/index.ts @@ -12,8 +12,10 @@ import deviceLinksRoutes from './routes/device-links'; import commandsRoutes from './routes/commands'; import eventsRoutes from './routes/events'; import streamsRoutes from './routes/streams'; +import recordingsRoutes from './routes/recordings'; import { setupRealtimeGateway } from './realtime/gateway'; import { ensureMinioBucket } from './utils/minio'; +import { startRecordingsWorker } from './workers/recordings'; const app = express(); const openApiDocument = buildOpenApiDocument(); @@ -39,6 +41,7 @@ app.use('/device-links', deviceLinksRoutes); app.use('/commands', commandsRoutes); app.use('/events', eventsRoutes); app.use('/streams', streamsRoutes); +app.use('/recordings', recordingsRoutes); app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { console.error(err); @@ -57,6 +60,7 @@ const start = async () => { } setupRealtimeGateway(server); + startRecordingsWorker(); server.listen(port, () => { console.log(`Server is running on port ${port}`); diff --git a/Backend/public/mobile-sim.html b/Backend/public/mobile-sim.html index 46f93ae..b123096 100644 --- a/Backend/public/mobile-sim.html +++ b/Backend/public/mobile-sim.html @@ -159,6 +159,8 @@ + +
@@ -167,6 +169,7 @@ + @@ -185,6 +188,7 @@ socket: null, lastMotionEventId: null, lastStreamSessionId: null, + lastRecordingId: null, }; const $ = (id) => document.getElementById(id); @@ -203,7 +207,11 @@ const render = () => { $('deviceState').textContent = JSON.stringify({ device: state.device, hasToken: Boolean(state.deviceToken) }, null, 2); $('clientState').textContent = JSON.stringify({ lastStreamSessionId: state.lastStreamSessionId }, null, 2); - $('cameraState').textContent = JSON.stringify({ lastMotionEventId: state.lastMotionEventId }, null, 2); + $('cameraState').textContent = JSON.stringify( + { lastMotionEventId: state.lastMotionEventId, lastRecordingId: state.lastRecordingId }, + null, + 2, + ); }; const authFetch = async (url, options = {}) => { @@ -425,6 +433,29 @@ } }); + $('listRecordingsBtn').addEventListener('click', async () => { + try { + const payload = await deviceFetch('/recordings/me/list'); + if (payload.recordings?.length > 0) { + state.lastRecordingId = payload.recordings[0].id; + render(); + } + log('recordings', payload); + } catch (error) { + log('list recordings failed', { error: error.message }); + } + }); + + $('downloadLatestRecordingBtn').addEventListener('click', async () => { + try { + if (!state.lastRecordingId) throw new Error('No recording id available'); + const payload = await deviceFetch(`/recordings/${state.lastRecordingId}/download-url`); + log('recording download url', payload); + } catch (error) { + log('recording download failed', { error: error.message }); + } + }); + $('fetchSubscribeBtn').addEventListener('click', async () => { try { if (!state.lastStreamSessionId) throw new Error('No known stream session'); @@ -475,6 +506,34 @@ } }); + $('finalizeRecordingBtn').addEventListener('click', async () => { + try { + if (!state.lastStreamSessionId) throw new Error('No stream session yet'); + + const listPayload = await deviceFetch('/recordings/me/list'); + const target = listPayload.recordings?.find((r) => r.streamSessionId === state.lastStreamSessionId) ?? listPayload.recordings?.[0]; + + if (!target) throw new Error('No recording placeholder exists'); + + state.lastRecordingId = target.id; + render(); + + const payload = await deviceFetch(`/recordings/${target.id}/finalize`, { + method: 'POST', + body: JSON.stringify({ + objectKey: `recordings/${target.id}.mp4`, + bucket: 'videos', + durationSeconds: 12, + sizeBytes: 1024 * 1024 * 8, + }), + }); + + log('recording finalized', payload); + } catch (error) { + log('finalize recording failed', { error: error.message }); + } + }); + render();