Sporalize-api / src /analysis.ts
Shoraky's picture
Stream session video ranges directly from storage
a66365f
Raw History Blame Contribute Delete
15.8 kB
import { createHash, timingSafeEqual } from "node:crypto";
import express from "express";
import type { SupabaseClient } from "@supabase/supabase-js";
import { z } from "zod";
import { env } from "./config.js";
import { activeSubscriptionFor } from "./subscriptions.js";
export function stableId(value: string) {
const hex = createHash("sha256").update(value).digest("hex");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
}
const beginSchema = z.object({ clientId: z.uuid(), playerId: z.uuid(), name: z.string().trim().max(150) });
const reportSchema = z.object({
index: z.number().int().nonnegative(), action: z.string().min(1).max(50),
start_frame: z.number().int().nonnegative().optional(), end_frame: z.number().int().nonnegative().optional(),
start_time_seconds: z.number().finite().nonnegative().optional(),
end_time_seconds: z.number().finite().positive().optional(),
error: z.string().max(2000).optional(),
});
export const completionSchema = z.discriminatedUnion("status", [
z.object({ status: z.literal("completed"), path: z.string(), actions: z.array(reportSchema).max(5000) }),
z.object({ status: z.enum(["failed", "cancelled"]) }),
]);
export function actionRows(sessionId: string, reports: z.infer<typeof reportSchema>[]) {
if (new Set(reports.map((report) => report.index)).size !== reports.length) throw new Error("Duplicate action index");
return reports.filter((report) => report.error === undefined).map((report) => {
const { start_frame: startFrame, end_frame: endFrame,
start_time_seconds: startTime, end_time_seconds: endTime } = report;
if (startFrame === undefined || endFrame === undefined || endFrame < startFrame) throw new Error("Invalid action interval");
if ((startTime === undefined) !== (endTime === undefined) ||
(startTime !== undefined && endTime !== undefined && endTime <= startTime)) throw new Error("Invalid action playback times");
return { action_id: stableId(`${sessionId}:${report.index}`), type: report.action,
start_frame: startFrame, end_frame: endFrame,
start_time_seconds: startTime ?? null, end_time_seconds: endTime ?? null };
});
}
export function analysisRouter(db: SupabaseClient, secret: string, onCompleted?: (sessionId: string, userId: string) => Promise<void>) {
const router = express.Router();
router.use((req, res, next) => {
const supplied = Buffer.from(req.header("x-sporalize-proxy-secret") ?? "");
const expected = Buffer.from(secret);
if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) return res.sendStatus(403);
const user = z.uuid().safeParse(req.header("x-sporalize-user-id"));
if (!user.success) return res.sendStatus(403);
res.locals.userId = user.data;
res.locals.isAdmin = req.header("x-sporalize-admin") === "true";
next();
});
router.use(express.json({ limit: "2mb" }));
router.post("/", async (req, res, next) => {
try {
const input = beginSchema.parse(req.body);
const userId = res.locals.userId as string;
const isAdmin = res.locals.isAdmin === true;
const subscription = isAdmin ? null : await activeSubscriptionFor(userId, { refresh: true });
if (!isAdmin && !subscription) {
return res.status(402).json({ error: "An active subscription is required to start a new analysis.", code: "subscription_required" });
}
if (!isAdmin) {
const { data: usage, error: usageError } = await db.from("users_subscriptions")
.select("num_sessions").eq("user_id", userId).maybeSingle();
if (usageError) return res.status(503).json({ error: "Unable to check your session quota." });
const usedSessions = Number(usage?.num_sessions ?? 0);
const sessionLimit = subscription?.session_limit == null ? null : Number(subscription.session_limit);
if (sessionLimit !== null && usedSessions >= sessionLimit) {
return res.status(403).json({
error: "You have used all sessions included in this plan period. Renew or upgrade your plan to analyze another session.",
code: "quota_exceeded", resource: "sessions", used: usedSessions, limit: sessionLimit,
});
}
}
let playerQuery = db.from("players").select("player_id,user_id").eq("player_id", input.playerId);
if (!isAdmin) playerQuery = playerQuery.eq("user_id", userId);
const { data: player, error: playerError } = await playerQuery.maybeSingle();
if (playerError) return res.status(503).json({ error: "Unable to verify the selected player." });
if (!player) return res.status(404).json({ error: isAdmin
? "The selected player was not found. Refresh the player list and try again."
: "Choose a player from your own roster before starting analysis." });
if (isAdmin) {
const { error } = await db.rpc("admin_create_analysis_session", {
p_session_id: input.clientId,
p_user_id: player.user_id,
p_player_id: input.playerId,
p_name: input.name || `Session ${input.clientId.slice(0, 8)}`,
p_path: `sessions/${input.clientId}`,
p_by_admin: player.user_id !== userId,
});
if (error) return res.status(503).json({ error: "Unable to prepare analysis session." });
return res.json({ session_id: input.clientId, player_id: input.playerId, user_id: player.user_id, by_admin: player.user_id !== userId, actor_is_admin: true });
}
const { data, error } = await db.rpc("begin_analysis", {
p_session_id: input.clientId, p_user_id: userId,
p_player_id: input.playerId,
p_reference: null,
p_name: input.name || `Session ${input.clientId.slice(0, 8)}`,
});
if (error) {
console.error("Unable to prepare analysis session", { code: error.code, message: error.message });
if (error.code === "P0001" && error.message.includes("subscription_quota_exceeded")) {
return res.status(403).json({ error: "You have used all sessions included in this plan period. Renew or upgrade your plan to analyze another session.", code: "quota_exceeded", resource: "sessions" });
}
if (error.code === "P0001" && error.message.includes("subscription_required")) {
return res.status(402).json({ error: "An active subscription is required to start an analysis.", code: "subscription_required" });
}
return res.status(error.code === "P0001" ? 409 : 503).json({ error: "Unable to prepare analysis session" });
}
return res.json({ ...data, user_id: userId, by_admin: false, actor_is_admin: false });
} catch (error) { next(error); }
});
router.get("/:sessionId", async (req, res, next) => {
try {
const sessionId = z.uuid().parse(req.params.sessionId);
let query = db.from("sessions").select("session_id,player_id,user_id,path,status,name,created_at,by_admin")
.eq("session_id", sessionId);
if (res.locals.isAdmin !== true) query = query.eq("user_id", res.locals.userId);
const { data, error } = await query.maybeSingle();
if (error) return res.status(503).json({ error: "Unable to load session" });
if (!data) return res.sendStatus(404);
if (req.query.playbackTimes === "false") return res.json(data);
const { data: actionTimes, error: timesError } = await db.from("actions")
.select("action_id,start_time_seconds,end_time_seconds").eq("session_id", sessionId);
if (timesError) {
console.error("Unable to load action playback times", { sessionId, code: timesError.code, message: timesError.message });
}
return res.json({ ...data, action_times: actionTimes ?? [] });
} catch (error) { next(error); }
});
router.delete("/:sessionId/actions/:actionId", async (req, res, next) => {
try {
const sessionId = z.uuid().parse(req.params.sessionId);
const actionId = z.uuid().parse(req.params.actionId);
const userId = res.locals.userId as string;
let sessionQuery = db.from("sessions").select("session_id,status").eq("session_id", sessionId);
if (res.locals.isAdmin !== true) sessionQuery = sessionQuery.eq("user_id", userId);
const { data: session, error: sessionError } = await sessionQuery.maybeSingle();
if (sessionError) return res.status(503).json({ error: "Unable to verify session ownership." });
if (!session) return res.status(404).json({ error: "Session not found." });
if (["pending", "uploading", "queued", "running"].includes(session.status)) return res.status(409).json({ error: "Actions cannot be deleted while analysis is running." });
const { data: action, error } = await db.from("actions").delete()
.eq("action_id", actionId).eq("session_id", sessionId).select("action_id").maybeSingle();
if (error) return res.status(503).json({ error: "Unable to delete action." });
if (!action) return res.status(404).json({ error: "Action not found." });
return res.sendStatus(204);
} catch (error) { next(error); }
});
router.patch("/:sessionId/actions/:actionId", async (req, res, next) => {
try {
if (res.locals.isAdmin !== true) return res.sendStatus(403);
const sessionId = z.uuid().parse(req.params.sessionId);
const actionId = z.uuid().parse(req.params.actionId);
const input = z.object({
startFrame: z.number().int().nonnegative(),
endFrame: z.number().int().nonnegative(),
type: z.string().trim().min(1).max(50),
startTimeSeconds: z.number().finite().nonnegative(),
endTimeSeconds: z.number().finite().positive(),
}).strict().parse(req.body);
if (input.endFrame < input.startFrame) return res.status(400).json({ error: "Action end frame must be after its start frame." });
if (input.endTimeSeconds <= input.startTimeSeconds) return res.status(400).json({ error: "Action playback interval is invalid." });
const { data: session } = await db.from("sessions").select("status").eq("session_id", sessionId).maybeSingle();
if (!session || ["pending", "uploading", "queued", "running"].includes(session.status)) return res.status(409).json({ error: "Actions cannot be edited while analysis is running." });
const { data, error } = await db.from("actions").update({
start_frame: input.startFrame, end_frame: input.endFrame, type: input.type,
start_time_seconds: input.startTimeSeconds, end_time_seconds: input.endTimeSeconds,
}).eq("action_id", actionId).eq("session_id", sessionId).select("action_id").maybeSingle();
if (error && !["42703", "PGRST204"].includes(error.code)) return res.status(503).json({ error: "Unable to update action." });
let updated = data;
if (error) {
const fallback = await db.from("actions").update({
start_frame: input.startFrame, end_frame: input.endFrame, type: input.type,
}).eq("action_id", actionId).eq("session_id", sessionId).select("action_id").maybeSingle();
if (fallback.error) return res.status(503).json({ error: "Unable to update action." });
updated = fallback.data;
}
if (!updated) return res.sendStatus(404);
return res.json({ ok: true });
} catch (error) { next(error); }
});
router.delete("/:sessionId", async (req, res, next) => {
try {
const sessionId = z.uuid().parse(req.params.sessionId);
const userId = res.locals.userId as string;
let sessionQuery = db.from("sessions").select("session_id").eq("session_id", sessionId);
if (res.locals.isAdmin !== true) sessionQuery = sessionQuery.eq("user_id", userId);
const { data: session, error: sessionError } = await sessionQuery.maybeSingle();
if (sessionError) return res.status(503).json({ error: "Unable to verify session ownership." });
if (!session) return res.status(404).json({ error: "Session not found." });
// The schema keeps actions when a session row is removed, so explicitly
// delete them first. Their annotations and feedback cascade from actions.
const { error: actionsError } = await db.from("actions").delete().eq("session_id", sessionId);
if (actionsError) return res.status(503).json({ error: "Unable to remove session actions." });
let deleteQuery = db.from("sessions").delete().eq("session_id", sessionId);
if (res.locals.isAdmin !== true) deleteQuery = deleteQuery.eq("user_id", userId);
const { data: deleted, error } = await deleteQuery.select("session_id").maybeSingle();
if (error) return res.status(503).json({ error: "Unable to delete session." });
if (!deleted) return res.status(404).json({ error: "Session not found." });
return res.sendStatus(204);
} catch (error) { next(error); }
});
router.put("/:sessionId", async (req, res, next) => {
try {
const sessionId = z.uuid().parse(req.params.sessionId);
const input = completionSchema.parse(req.body);
if (input.status === "completed" && input.path !== `sessions/${sessionId}`) return res.sendStatus(400);
let actions;
try { actions = input.status === "completed" ? actionRows(sessionId, input.actions) : []; }
catch { return res.status(400).json({ error: "Invalid action details" }); }
if (res.locals.isAdmin === true) {
const { data: session, error: sessionError } = await db.from("sessions").update({
status: input.status,
...(input.status === "completed" ? { path: input.path } : {}),
}).eq("session_id", sessionId).select("session_id,user_id").maybeSingle();
if (sessionError) return res.status(503).json({ error: "Unable to save analysis session." });
if (!session) return res.sendStatus(404);
if (input.status === "completed") {
const rows = actions.map((action) => ({ ...action, session_id: sessionId }));
if (rows.length) {
const { error } = await db.from("actions").upsert(rows, { onConflict: "action_id" });
if (error && !["42703", "PGRST204"].includes(error.code)) return res.status(503).json({ error: "Unable to save analysis actions." });
if (error) {
const legacyRows = rows.map(({ start_time_seconds: _start, end_time_seconds: _end, ...row }) => row);
const fallback = await db.from("actions").upsert(legacyRows, { onConflict: "action_id" });
if (fallback.error) return res.status(503).json({ error: "Unable to save analysis actions." });
}
}
if (onCompleted && session.user_id) void onCompleted(sessionId, session.user_id).catch((error) => console.error("Automatic Agent feedback generation failed", { sessionId, error }));
}
return res.json({ ok: true });
}
const { error } = await db.rpc("finish_analysis", {
p_session_id: sessionId, p_user_id: res.locals.userId, p_status: input.status,
p_path: input.status === "completed" ? input.path : null, p_actions: actions,
});
if (error) {
console.error("Unable to save analysis results", { code: error.code, message: error.message });
return res.status(error.code === "P0001" ? 409 : 503).json({ error: "Unable to save analysis" });
}
if (input.status === "completed" && onCompleted) {
const { data: completedSession } = await db.from("sessions").select("user_id").eq("session_id", sessionId).maybeSingle();
if (completedSession?.user_id) void onCompleted(sessionId, completedSession.user_id).catch((generationError) => console.error("Automatic Agent feedback generation failed", { sessionId, generationError }));
}
return res.json({ ok: true });
} catch (error) { next(error); }
});
return router;
}