feat(recordings): add phase6 recording finalization pipeline and simulator support
This commit is contained in:
185
Backend/routes/recordings.ts
Normal file
185
Backend/routes/recordings.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { db } from '../db/client';
|
||||
import { recordings, streamSessions } from '../db/schema';
|
||||
import { requireDeviceAuth } from '../middleware/device-auth';
|
||||
import { minioBucket, minioClient, minioPresignedExpirySeconds } from '../utils/minio';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const listSchema = z.object({
|
||||
status: z.string().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(25),
|
||||
});
|
||||
|
||||
const finalizeSchema = z.object({
|
||||
objectKey: z.string().trim().min(1),
|
||||
bucket: z.string().trim().min(1).default(minioBucket),
|
||||
durationSeconds: z.coerce.number().int().nonnegative().optional(),
|
||||
sizeBytes: z.coerce.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
const recordingParamSchema = z.object({
|
||||
recordingId: z.string().uuid(),
|
||||
});
|
||||
|
||||
router.get('/me/list', requireDeviceAuth, async (req, res) => {
|
||||
const parsed = listSchema.safeParse(req.query);
|
||||
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ message: 'Invalid query params', errors: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceAuth = req.deviceAuth;
|
||||
|
||||
if (!deviceAuth) {
|
||||
res.status(401).json({ message: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await db.query.recordings.findMany({
|
||||
where: eq(recordings.ownerUserId, deviceAuth.userId),
|
||||
orderBy: [desc(recordings.createdAt)],
|
||||
limit: parsed.data.limit,
|
||||
});
|
||||
|
||||
const filtered = parsed.data.status ? result.filter((recording) => recording.status === parsed.data.status) : result;
|
||||
|
||||
res.json({ count: filtered.length, recordings: filtered });
|
||||
});
|
||||
|
||||
router.post('/:recordingId/finalize', requireDeviceAuth, async (req, res) => {
|
||||
const parsedParams = recordingParamSchema.safeParse(req.params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
res.status(400).json({ message: 'Invalid recordingId', errors: parsedParams.error.flatten() });
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = finalizeSchema.safeParse(req.body ?? {});
|
||||
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ message: 'Invalid request body', errors: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceAuth = req.deviceAuth;
|
||||
|
||||
if (!deviceAuth) {
|
||||
res.status(401).json({ message: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const recording = await db.query.recordings.findFirst({
|
||||
where: and(eq(recordings.id, parsedParams.data.recordingId), eq(recordings.ownerUserId, deviceAuth.userId)),
|
||||
});
|
||||
|
||||
if (!recording) {
|
||||
res.status(404).json({ message: 'Recording not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (recording.cameraDeviceId !== deviceAuth.deviceId) {
|
||||
res.status(403).json({ message: 'Only camera device can finalize this recording' });
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const [updated] = await db
|
||||
.update(recordings)
|
||||
.set({
|
||||
objectKey: parsed.data.objectKey,
|
||||
bucket: parsed.data.bucket,
|
||||
durationSeconds: parsed.data.durationSeconds,
|
||||
sizeBytes: parsed.data.sizeBytes,
|
||||
status: 'ready',
|
||||
availableAt: now,
|
||||
updatedAt: now,
|
||||
error: null,
|
||||
})
|
||||
.where(eq(recordings.id, recording.id))
|
||||
.returning();
|
||||
|
||||
res.json({ message: 'Recording finalized', recording: updated });
|
||||
});
|
||||
|
||||
router.get('/:recordingId/download-url', requireDeviceAuth, async (req, res) => {
|
||||
const parsedParams = recordingParamSchema.safeParse(req.params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
res.status(400).json({ message: 'Invalid recordingId', errors: parsedParams.error.flatten() });
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceAuth = req.deviceAuth;
|
||||
|
||||
if (!deviceAuth) {
|
||||
res.status(401).json({ message: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const recording = await db.query.recordings.findFirst({
|
||||
where: and(eq(recordings.id, parsedParams.data.recordingId), eq(recordings.ownerUserId, deviceAuth.userId)),
|
||||
});
|
||||
|
||||
if (!recording) {
|
||||
res.status(404).json({ message: 'Recording not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const canAccess = recording.requesterDeviceId === deviceAuth.deviceId || recording.cameraDeviceId === deviceAuth.deviceId;
|
||||
|
||||
if (!canAccess) {
|
||||
res.status(403).json({ message: 'Device cannot access this recording' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (recording.status !== 'ready' || !recording.objectKey || !recording.bucket) {
|
||||
res.status(409).json({ message: 'Recording is not available yet' });
|
||||
return;
|
||||
}
|
||||
|
||||
const downloadUrl = await minioClient.presignedGetObject(
|
||||
recording.bucket,
|
||||
recording.objectKey,
|
||||
minioPresignedExpirySeconds,
|
||||
);
|
||||
|
||||
res.json({
|
||||
recordingId: recording.id,
|
||||
objectKey: recording.objectKey,
|
||||
bucket: recording.bucket,
|
||||
downloadUrl,
|
||||
expiresInSeconds: minioPresignedExpirySeconds,
|
||||
});
|
||||
});
|
||||
|
||||
// Internal helper used by stream lifecycle to create recording placeholder rows.
|
||||
export const createRecordingForStream = async (streamSessionId: string): Promise<void> => {
|
||||
const stream = await db.query.streamSessions.findFirst({ where: eq(streamSessions.id, streamSessionId) });
|
||||
|
||||
if (!stream) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await db.query.recordings.findFirst({ where: eq(recordings.streamSessionId, stream.id) });
|
||||
|
||||
if (existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db.insert(recordings).values({
|
||||
ownerUserId: stream.ownerUserId,
|
||||
streamSessionId: stream.id,
|
||||
cameraDeviceId: stream.cameraDeviceId,
|
||||
requesterDeviceId: stream.requesterDeviceId,
|
||||
status: 'awaiting_upload',
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
};
|
||||
|
||||
export default router;
|
||||
@@ -9,6 +9,7 @@ import { deviceCommands, deviceLinks, devices, streamSessions } from '../db/sche
|
||||
import { mediaProvider } from '../media/service';
|
||||
import { requireDeviceAuth } from '../middleware/device-auth';
|
||||
import { dispatchCommandById, sendRealtimeToDevice } from '../realtime/gateway';
|
||||
import { createRecordingForStream } from './recordings';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -402,6 +403,8 @@ router.post('/:streamSessionId/end', requireDeviceAuth, async (req, res) => {
|
||||
.where(eq(streamSessions.id, session.id))
|
||||
.returning();
|
||||
|
||||
await createRecordingForStream(session.id);
|
||||
|
||||
sendRealtimeToDevice(session.requesterDeviceId, 'stream:ended', {
|
||||
streamSessionId: session.id,
|
||||
status: parsed.data.reason,
|
||||
|
||||
Reference in New Issue
Block a user