feat(media): add phase5 media provider abstraction and stream credentials APIs
This commit is contained in:
@@ -6,9 +6,9 @@ import { z } from 'zod';
|
||||
|
||||
import { db } from '../db/client';
|
||||
import { deviceCommands, deviceLinks, devices, streamSessions } from '../db/schema';
|
||||
import { mediaProvider } from '../media/service';
|
||||
import { requireDeviceAuth } from '../middleware/device-auth';
|
||||
import { dispatchCommandById, sendRealtimeToDevice } from '../realtime/gateway';
|
||||
import { createStreamPlaybackToken } from '../utils/stream-token';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -36,6 +36,35 @@ const listSchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(100).default(25),
|
||||
});
|
||||
|
||||
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 sessions = await db.query.streamSessions.findMany({
|
||||
where: and(
|
||||
eq(streamSessions.ownerUserId, deviceAuth.userId),
|
||||
or(eq(streamSessions.requesterDeviceId, deviceAuth.deviceId), eq(streamSessions.cameraDeviceId, deviceAuth.deviceId)),
|
||||
),
|
||||
orderBy: [desc(streamSessions.createdAt)],
|
||||
limit: parsed.data.limit,
|
||||
});
|
||||
|
||||
const filtered = parsed.data.status ? sessions.filter((session) => session.status === parsed.data.status) : sessions;
|
||||
|
||||
res.json({ count: filtered.length, streamSessions: filtered });
|
||||
});
|
||||
|
||||
router.post('/request', requireDeviceAuth, async (req, res) => {
|
||||
const parsed = requestStreamSchema.safeParse(req.body ?? {});
|
||||
|
||||
@@ -100,6 +129,7 @@ router.post('/request', requireDeviceAuth, async (req, res) => {
|
||||
status: 'requested',
|
||||
reason: parsed.data.reason,
|
||||
metadata: parsed.data.metadata ?? null,
|
||||
mediaProvider: mediaProvider.name,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
@@ -192,12 +222,22 @@ router.post('/:streamSessionId/accept', requireDeviceAuth, async (req, res) => {
|
||||
|
||||
const now = new Date();
|
||||
const streamKey = parsed.data.streamKey ?? `stream_${session.id}_${randomUUID()}`;
|
||||
const mediaSession = await mediaProvider.createSession({
|
||||
streamSessionId: session.id,
|
||||
ownerUserId: session.ownerUserId,
|
||||
cameraDeviceId: session.cameraDeviceId,
|
||||
requesterDeviceId: session.requesterDeviceId,
|
||||
});
|
||||
|
||||
const [updated] = await db
|
||||
.update(streamSessions)
|
||||
.set({
|
||||
status: 'streaming',
|
||||
streamKey,
|
||||
mediaProvider: mediaSession.provider,
|
||||
mediaSessionId: mediaSession.mediaSessionId,
|
||||
publishEndpoint: mediaSession.publishUrl,
|
||||
subscribeEndpoint: mediaSession.subscribeUrl,
|
||||
metadata: parsed.data.metadata ?? session.metadata,
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
@@ -215,11 +255,103 @@ router.post('/:streamSessionId/accept', requireDeviceAuth, async (req, res) => {
|
||||
cameraDeviceId: updated.cameraDeviceId,
|
||||
status: updated.status,
|
||||
startedAt: updated.startedAt,
|
||||
mediaProvider: updated.mediaProvider,
|
||||
mediaSessionId: updated.mediaSessionId,
|
||||
subscribeEndpoint: updated.subscribeEndpoint,
|
||||
});
|
||||
|
||||
res.json({ message: 'Stream accepted', streamSession: updated });
|
||||
});
|
||||
|
||||
router.get('/:streamSessionId/publish-credentials', requireDeviceAuth, async (req, res) => {
|
||||
const parsedParams = streamParamSchema.safeParse(req.params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
res.status(400).json({ message: 'Invalid streamSessionId', errors: parsedParams.error.flatten() });
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceAuth = req.deviceAuth;
|
||||
|
||||
if (!deviceAuth) {
|
||||
res.status(401).json({ message: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await db.query.streamSessions.findFirst({
|
||||
where: and(eq(streamSessions.id, parsedParams.data.streamSessionId), eq(streamSessions.ownerUserId, deviceAuth.userId)),
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
res.status(404).json({ message: 'Stream session not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.cameraDeviceId !== deviceAuth.deviceId) {
|
||||
res.status(403).json({ message: 'Only camera device can request publish credentials' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session.mediaSessionId || session.status !== 'streaming') {
|
||||
res.status(409).json({ message: 'Stream session is not ready for publish credentials' });
|
||||
return;
|
||||
}
|
||||
|
||||
const credentials = await mediaProvider.issuePublishCredentials({
|
||||
mediaSessionId: session.mediaSessionId,
|
||||
cameraDeviceId: session.cameraDeviceId,
|
||||
ownerUserId: session.ownerUserId,
|
||||
});
|
||||
|
||||
res.json(credentials);
|
||||
});
|
||||
|
||||
router.get('/:streamSessionId/subscribe-credentials', requireDeviceAuth, async (req, res) => {
|
||||
const parsedParams = streamParamSchema.safeParse(req.params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
res.status(400).json({ message: 'Invalid streamSessionId', errors: parsedParams.error.flatten() });
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceAuth = req.deviceAuth;
|
||||
|
||||
if (!deviceAuth) {
|
||||
res.status(401).json({ message: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await db.query.streamSessions.findFirst({
|
||||
where: and(eq(streamSessions.id, parsedParams.data.streamSessionId), eq(streamSessions.ownerUserId, deviceAuth.userId)),
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
res.status(404).json({ message: 'Stream session not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const isRequester = session.requesterDeviceId === deviceAuth.deviceId;
|
||||
const isCamera = session.cameraDeviceId === deviceAuth.deviceId;
|
||||
|
||||
if (!isRequester && !isCamera) {
|
||||
res.status(403).json({ message: 'Device cannot request subscribe credentials for this stream' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session.mediaSessionId || session.status !== 'streaming') {
|
||||
res.status(409).json({ message: 'Stream is not active yet' });
|
||||
return;
|
||||
}
|
||||
|
||||
const credentials = await mediaProvider.issueSubscribeCredentials({
|
||||
mediaSessionId: session.mediaSessionId,
|
||||
viewerDeviceId: deviceAuth.deviceId,
|
||||
ownerUserId: session.ownerUserId,
|
||||
});
|
||||
|
||||
res.json(credentials);
|
||||
});
|
||||
|
||||
router.post('/:streamSessionId/end', requireDeviceAuth, async (req, res) => {
|
||||
const parsedParams = streamParamSchema.safeParse(req.params);
|
||||
|
||||
@@ -317,53 +449,26 @@ router.get('/:streamSessionId/playback-token', requireDeviceAuth, async (req, re
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session.streamKey || session.status !== 'streaming') {
|
||||
if (!session.streamKey || !session.mediaSessionId || session.status !== 'streaming') {
|
||||
res.status(409).json({ message: 'Stream is not active yet' });
|
||||
return;
|
||||
}
|
||||
|
||||
const playbackToken = createStreamPlaybackToken({
|
||||
sessionId: session.id,
|
||||
const credentials = await mediaProvider.issueSubscribeCredentials({
|
||||
mediaSessionId: session.mediaSessionId,
|
||||
viewerDeviceId: deviceAuth.deviceId,
|
||||
userId: deviceAuth.userId,
|
||||
ownerUserId: deviceAuth.userId,
|
||||
});
|
||||
|
||||
res.json({
|
||||
streamSessionId: session.id,
|
||||
streamKey: session.streamKey,
|
||||
status: session.status,
|
||||
playbackToken,
|
||||
expiresInSeconds: 60 * 15,
|
||||
playbackToken: credentials.subscribeToken,
|
||||
subscribeUrl: credentials.subscribeUrl,
|
||||
mediaProvider: credentials.provider,
|
||||
expiresInSeconds: credentials.expiresInSeconds,
|
||||
});
|
||||
});
|
||||
|
||||
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 sessions = await db.query.streamSessions.findMany({
|
||||
where: and(
|
||||
eq(streamSessions.ownerUserId, deviceAuth.userId),
|
||||
or(eq(streamSessions.requesterDeviceId, deviceAuth.deviceId), eq(streamSessions.cameraDeviceId, deviceAuth.deviceId)),
|
||||
),
|
||||
orderBy: [desc(streamSessions.createdAt)],
|
||||
limit: parsed.data.limit,
|
||||
});
|
||||
|
||||
const filtered = parsed.data.status ? sessions.filter((session) => session.status === parsed.data.status) : sessions;
|
||||
|
||||
res.json({ count: filtered.length, streamSessions: filtered });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
Reference in New Issue
Block a user