merge: W1-db media repositories
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
This commit is contained in:
commit
c877343218
@ -7,6 +7,7 @@ export * from "./events.js";
|
|||||||
export * from "./external-conversations.js";
|
export * from "./external-conversations.js";
|
||||||
export * from "./groups.js";
|
export * from "./groups.js";
|
||||||
export * from "./main-agent.js";
|
export * from "./main-agent.js";
|
||||||
|
export * from "./media.js";
|
||||||
export * from "./memory-config.js";
|
export * from "./memory-config.js";
|
||||||
export * from "./messages.js";
|
export * from "./messages.js";
|
||||||
export * from "./messaging.js";
|
export * from "./messaging.js";
|
||||||
|
|||||||
395
packages/db/src/media-collections.ts
Normal file
395
packages/db/src/media-collections.ts
Normal file
@ -0,0 +1,395 @@
|
|||||||
|
import type { MediaCollection, MediaCollectionKind, MediaLabelSource } from "@rakazo/contracts";
|
||||||
|
import { roundCoordinate } from "@rakazo/media";
|
||||||
|
import type { MediaCollection as MediaCollectionRow, PrismaClient } from "./client.js";
|
||||||
|
import { Prisma } from "./client.js";
|
||||||
|
import type { CollectionStats, MediaDb, MediaPage, MediaPageInput } from "./media-shared.js";
|
||||||
|
import {
|
||||||
|
assertAssetsInSpace,
|
||||||
|
decodeKeysetCursor,
|
||||||
|
EMPTY_COLLECTION_STATS,
|
||||||
|
InvalidMediaInputError,
|
||||||
|
keysetAfterSql,
|
||||||
|
lockMediaKey,
|
||||||
|
mapMediaAsset,
|
||||||
|
mapMediaCollection,
|
||||||
|
pageLimit,
|
||||||
|
toPage,
|
||||||
|
} from "./media-shared.js";
|
||||||
|
import { IsolationError } from "./scope.js";
|
||||||
|
|
||||||
|
/** One session or theme cluster computed by the worker. */
|
||||||
|
export interface MediaClusterInput {
|
||||||
|
/** Stable key so a re-run updates the same collection. */
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
labelSource: Exclude<MediaLabelSource, "user">;
|
||||||
|
startAt: Date | null;
|
||||||
|
endAt: Date | null;
|
||||||
|
centroid: { lat: number; lon: number } | null;
|
||||||
|
coverAssetId: string | null;
|
||||||
|
/** In display order. */
|
||||||
|
assetIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplaceClustersResult {
|
||||||
|
created: number;
|
||||||
|
updated: number;
|
||||||
|
deleted: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectionStats(db: MediaDb, ids: string[]): Promise<Map<string, CollectionStats>> {
|
||||||
|
if (ids.length === 0) return new Map();
|
||||||
|
const rows = await db.$queryRaw<
|
||||||
|
Array<{ id: string; assetCount: number; videoCount: number; totalDurationMs: bigint }>
|
||||||
|
>(Prisma.sql`
|
||||||
|
SELECT ci."collectionId" AS id,
|
||||||
|
count(*)::int AS "assetCount",
|
||||||
|
(count(*) FILTER (WHERE a.kind = 'video'))::int AS "videoCount",
|
||||||
|
coalesce(sum(a."durationMs"), 0)::bigint AS "totalDurationMs"
|
||||||
|
FROM media_collection_items ci
|
||||||
|
JOIN media_assets a ON a.id = ci."assetId"
|
||||||
|
WHERE ci."collectionId" IN (${Prisma.join(ids)})
|
||||||
|
GROUP BY ci."collectionId"
|
||||||
|
`);
|
||||||
|
return new Map(
|
||||||
|
rows.map((row) => [
|
||||||
|
row.id,
|
||||||
|
{
|
||||||
|
assetCount: row.assetCount,
|
||||||
|
videoCount: row.videoCount,
|
||||||
|
totalDurationMs: Number(row.totalDurationMs),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectionDto(db: MediaDb, row: MediaCollectionRow): Promise<MediaCollection> {
|
||||||
|
const stats = await collectionStats(db, [row.id]);
|
||||||
|
return mapMediaCollection(row, stats.get(row.id) ?? EMPTY_COLLECTION_STATS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assets ordered by capture time, undated last, with the span they cover. */
|
||||||
|
async function orderedAssets(db: MediaDb, spaceId: string, assetIds: string[]) {
|
||||||
|
const assets = await db.mediaAsset.findMany({
|
||||||
|
where: { spaceId, id: { in: [...new Set(assetIds)] } },
|
||||||
|
orderBy: [{ capturedAt: { sort: "asc", nulls: "last" } }, { id: "asc" }],
|
||||||
|
select: { id: true, capturedAt: true },
|
||||||
|
});
|
||||||
|
const dated = assets.flatMap((asset) => (asset.capturedAt ? [asset.capturedAt] : []));
|
||||||
|
return {
|
||||||
|
ids: assets.map((asset) => asset.id),
|
||||||
|
startAt: dated[0] ?? null,
|
||||||
|
endAt: dated.at(-1) ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeItems(db: MediaDb, collectionId: string, assetIds: string[]): Promise<void> {
|
||||||
|
await db.mediaCollectionItem.deleteMany({ where: { collectionId } });
|
||||||
|
if (assetIds.length === 0) return;
|
||||||
|
await db.mediaCollectionItem.createMany({
|
||||||
|
data: [...new Set(assetIds)].map((assetId, position) => ({ collectionId, assetId, position })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sessions, themes and manual collections. */
|
||||||
|
export function createMediaCollectionRepos(prisma: PrismaClient) {
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Collections
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function requireCollection(db: MediaDb, spaceId: string, collectionId: string) {
|
||||||
|
const row = await db.mediaCollection.findFirst({ where: { id: collectionId, spaceId } });
|
||||||
|
if (!row) throw new IsolationError();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a full clustering run for one kind. Collections are matched by cluster key;
|
||||||
|
* a title the user set survives, stale rule/model clusters are removed, and manual
|
||||||
|
* collections are never touched.
|
||||||
|
*/
|
||||||
|
async function replaceClusters(
|
||||||
|
spaceId: string,
|
||||||
|
kind: Exclude<MediaCollectionKind, "manual">,
|
||||||
|
clusters: MediaClusterInput[],
|
||||||
|
): Promise<ReplaceClustersResult> {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
for (const cluster of clusters) {
|
||||||
|
if (keys.has(cluster.key)) throw new InvalidMediaInputError("Duplicate cluster key");
|
||||||
|
keys.add(cluster.key);
|
||||||
|
if (cluster.coverAssetId && !cluster.assetIds.includes(cluster.coverAssetId)) {
|
||||||
|
throw new InvalidMediaInputError("Cover asset is not in its cluster");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
await lockMediaKey(tx, `clusters:${spaceId}:${kind}`);
|
||||||
|
await assertAssetsInSpace(
|
||||||
|
tx,
|
||||||
|
spaceId,
|
||||||
|
clusters.flatMap((cluster) => cluster.assetIds),
|
||||||
|
);
|
||||||
|
const existing = await tx.mediaCollection.findMany({
|
||||||
|
where: { spaceId, kind, clusterKey: { not: null } },
|
||||||
|
});
|
||||||
|
const byKey = new Map(existing.map((row) => [row.clusterKey as string, row]));
|
||||||
|
const result: ReplaceClustersResult = { created: 0, updated: 0, deleted: 0 };
|
||||||
|
for (const cluster of clusters) {
|
||||||
|
const fields = {
|
||||||
|
startAt: cluster.startAt,
|
||||||
|
endAt: cluster.endAt,
|
||||||
|
centroidLat: cluster.centroid ? roundCoordinate(cluster.centroid.lat) : null,
|
||||||
|
centroidLon: cluster.centroid ? roundCoordinate(cluster.centroid.lon) : null,
|
||||||
|
coverAssetId: cluster.coverAssetId,
|
||||||
|
};
|
||||||
|
const current = byKey.get(cluster.key);
|
||||||
|
let collectionId: string;
|
||||||
|
if (current) {
|
||||||
|
const keepTitle = current.labelSource === "user";
|
||||||
|
await tx.mediaCollection.update({
|
||||||
|
where: { id: current.id },
|
||||||
|
data: {
|
||||||
|
...fields,
|
||||||
|
...(keepTitle ? {} : { title: cluster.title, labelSource: cluster.labelSource }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
collectionId = current.id;
|
||||||
|
result.updated += 1;
|
||||||
|
} else {
|
||||||
|
const created = await tx.mediaCollection.create({
|
||||||
|
data: {
|
||||||
|
...fields,
|
||||||
|
spaceId,
|
||||||
|
kind,
|
||||||
|
clusterKey: cluster.key,
|
||||||
|
title: cluster.title,
|
||||||
|
labelSource: cluster.labelSource,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
collectionId = created.id;
|
||||||
|
result.created += 1;
|
||||||
|
}
|
||||||
|
await writeItems(tx, collectionId, cluster.assetIds);
|
||||||
|
}
|
||||||
|
const stale = existing.filter(
|
||||||
|
(row) => !keys.has(row.clusterKey as string) && row.labelSource !== "user",
|
||||||
|
);
|
||||||
|
if (stale.length > 0) {
|
||||||
|
const deleted = await tx.mediaCollection.deleteMany({
|
||||||
|
where: { spaceId, id: { in: stale.map((row) => row.id) } },
|
||||||
|
});
|
||||||
|
result.deleted = deleted.count;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
{ timeout: 120_000 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listCollections(
|
||||||
|
spaceId: string,
|
||||||
|
input: MediaPageInput & { kind?: MediaCollectionKind },
|
||||||
|
): Promise<MediaPage<MediaCollection>> {
|
||||||
|
const limit = pageLimit(input.limit);
|
||||||
|
const cursor = input.cursor ? decodeKeysetCursor(input.cursor) : null;
|
||||||
|
const keys = await prisma.$queryRaw<Array<{ id: string }>>(Prisma.sql`
|
||||||
|
SELECT c.id FROM media_collections c
|
||||||
|
WHERE c."spaceId" = ${spaceId}
|
||||||
|
${input.kind ? Prisma.sql`AND c.kind = ${input.kind}` : Prisma.empty}
|
||||||
|
AND ${keysetAfterSql(cursor, Prisma.sql`c."startAt"`, Prisma.sql`c.id`)}
|
||||||
|
ORDER BY c."startAt" DESC NULLS LAST, c.id DESC
|
||||||
|
LIMIT ${limit + 1}
|
||||||
|
`);
|
||||||
|
const ids = keys.map((key) => key.id);
|
||||||
|
const [rows, stats] = await Promise.all([
|
||||||
|
prisma.mediaCollection.findMany({ where: { spaceId, id: { in: ids } } }),
|
||||||
|
collectionStats(prisma, ids),
|
||||||
|
]);
|
||||||
|
const byId = new Map(rows.map((row) => [row.id, row]));
|
||||||
|
const ordered = ids.flatMap((id) => {
|
||||||
|
const row = byId.get(id);
|
||||||
|
return row ? [row] : [];
|
||||||
|
});
|
||||||
|
return toPage(
|
||||||
|
ordered,
|
||||||
|
limit,
|
||||||
|
(row) => ({ at: row.startAt, id: row.id }),
|
||||||
|
(row) => mapMediaCollection(row, stats.get(row.id) ?? EMPTY_COLLECTION_STATS),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCollection(spaceId: string, collectionId: string) {
|
||||||
|
const row = await requireCollection(prisma, spaceId, collectionId);
|
||||||
|
const items = await prisma.mediaCollectionItem.findMany({
|
||||||
|
where: { collectionId, asset: { spaceId } },
|
||||||
|
orderBy: [{ position: "asc" }, { assetId: "asc" }],
|
||||||
|
include: { asset: true },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
collection: await collectionDto(prisma, row),
|
||||||
|
assets: items.map((item) => mapMediaAsset(item.asset)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asset ids of a collection in display order. */
|
||||||
|
async function collectionAssetIds(spaceId: string, collectionIds: string[]): Promise<string[]> {
|
||||||
|
const ids = [...new Set(collectionIds)];
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
const owned = await prisma.mediaCollection.count({ where: { spaceId, id: { in: ids } } });
|
||||||
|
if (owned !== ids.length) throw new IsolationError();
|
||||||
|
const items = await prisma.mediaCollectionItem.findMany({
|
||||||
|
where: { collectionId: { in: ids } },
|
||||||
|
orderBy: [{ position: "asc" }, { assetId: "asc" }],
|
||||||
|
select: { assetId: true, collectionId: true },
|
||||||
|
});
|
||||||
|
const order = new Map(ids.map((id, index) => [id, index]));
|
||||||
|
items.sort((a, b) => (order.get(a.collectionId) ?? 0) - (order.get(b.collectionId) ?? 0));
|
||||||
|
return [...new Set(items.map((item) => item.assetId))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The user's title wins over every later clustering label. */
|
||||||
|
async function renameCollection(
|
||||||
|
spaceId: string,
|
||||||
|
collectionId: string,
|
||||||
|
title: string,
|
||||||
|
): Promise<MediaCollection> {
|
||||||
|
const result = await prisma.mediaCollection.updateMany({
|
||||||
|
where: { id: collectionId, spaceId },
|
||||||
|
data: { title, labelSource: "user" },
|
||||||
|
});
|
||||||
|
if (result.count !== 1) throw new IsolationError();
|
||||||
|
return collectionDto(prisma, await requireCollection(prisma, spaceId, collectionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge into the first collection; the others are deleted. */
|
||||||
|
async function mergeCollections(
|
||||||
|
spaceId: string,
|
||||||
|
collectionIds: string[],
|
||||||
|
): Promise<MediaCollection> {
|
||||||
|
const ids = [...new Set(collectionIds)];
|
||||||
|
if (ids.length < 2) throw new InvalidMediaInputError("Merge needs two collections");
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const rows = await tx.mediaCollection.findMany({ where: { spaceId, id: { in: ids } } });
|
||||||
|
if (rows.length !== ids.length) throw new IsolationError();
|
||||||
|
const target = rows.find((row) => row.id === ids[0]) as MediaCollectionRow;
|
||||||
|
const items = await tx.mediaCollectionItem.findMany({
|
||||||
|
where: { collectionId: { in: ids } },
|
||||||
|
select: { assetId: true },
|
||||||
|
});
|
||||||
|
const assets = await orderedAssets(
|
||||||
|
tx,
|
||||||
|
spaceId,
|
||||||
|
items.map((item) => item.assetId),
|
||||||
|
);
|
||||||
|
await writeItems(tx, target.id, assets.ids);
|
||||||
|
await tx.mediaCollection.deleteMany({
|
||||||
|
where: { spaceId, id: { in: ids.filter((id) => id !== target.id) } },
|
||||||
|
});
|
||||||
|
const updated = await tx.mediaCollection.update({
|
||||||
|
where: { id: target.id },
|
||||||
|
data: {
|
||||||
|
startAt: assets.startAt,
|
||||||
|
endAt: assets.endAt,
|
||||||
|
labelSource: "user",
|
||||||
|
coverAssetId: target.coverAssetId ?? assets.ids[0] ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return collectionDto(tx, updated);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split before `atAssetId`: it and every later asset move to a new collection. */
|
||||||
|
async function splitCollection(
|
||||||
|
spaceId: string,
|
||||||
|
collectionId: string,
|
||||||
|
atAssetId: string,
|
||||||
|
): Promise<MediaCollection[]> {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const row = await requireCollection(tx, spaceId, collectionId);
|
||||||
|
const items = await tx.mediaCollectionItem.findMany({
|
||||||
|
where: { collectionId },
|
||||||
|
orderBy: [{ position: "asc" }, { assetId: "asc" }],
|
||||||
|
select: { assetId: true },
|
||||||
|
});
|
||||||
|
const index = items.findIndex((item) => item.assetId === atAssetId);
|
||||||
|
if (index <= 0) throw new InvalidMediaInputError("Split asset must be inside the collection");
|
||||||
|
const first = await orderedAssets(
|
||||||
|
tx,
|
||||||
|
spaceId,
|
||||||
|
items.slice(0, index).map((item) => item.assetId),
|
||||||
|
);
|
||||||
|
const second = await orderedAssets(
|
||||||
|
tx,
|
||||||
|
spaceId,
|
||||||
|
items.slice(index).map((item) => item.assetId),
|
||||||
|
);
|
||||||
|
await writeItems(tx, collectionId, first.ids);
|
||||||
|
const kept = await tx.mediaCollection.update({
|
||||||
|
where: { id: collectionId },
|
||||||
|
data: {
|
||||||
|
startAt: first.startAt,
|
||||||
|
endAt: first.endAt,
|
||||||
|
labelSource: "user",
|
||||||
|
coverAssetId:
|
||||||
|
row.coverAssetId && first.ids.includes(row.coverAssetId)
|
||||||
|
? row.coverAssetId
|
||||||
|
: (first.ids[0] ?? null),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const created = await tx.mediaCollection.create({
|
||||||
|
data: {
|
||||||
|
spaceId,
|
||||||
|
kind: row.kind,
|
||||||
|
title: `${row.title} (2)`,
|
||||||
|
labelSource: "user",
|
||||||
|
startAt: second.startAt,
|
||||||
|
endAt: second.endAt,
|
||||||
|
centroidLat: row.centroidLat,
|
||||||
|
centroidLon: row.centroidLon,
|
||||||
|
coverAssetId:
|
||||||
|
row.coverAssetId && second.ids.includes(row.coverAssetId)
|
||||||
|
? row.coverAssetId
|
||||||
|
: (second.ids[0] ?? null),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await writeItems(tx, created.id, second.ids);
|
||||||
|
return [await collectionDto(tx, kept), await collectionDto(tx, created)];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createManualCollection(
|
||||||
|
spaceId: string,
|
||||||
|
title: string,
|
||||||
|
assetIds: string[],
|
||||||
|
): Promise<MediaCollection> {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
await assertAssetsInSpace(tx, spaceId, assetIds);
|
||||||
|
const assets = await orderedAssets(tx, spaceId, assetIds);
|
||||||
|
const created = await tx.mediaCollection.create({
|
||||||
|
data: {
|
||||||
|
spaceId,
|
||||||
|
kind: "manual",
|
||||||
|
title,
|
||||||
|
labelSource: "user",
|
||||||
|
startAt: assets.startAt,
|
||||||
|
endAt: assets.endAt,
|
||||||
|
coverAssetId: assets.ids[0] ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await writeItems(tx, created.id, assets.ids);
|
||||||
|
return collectionDto(tx, created);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
replaceClusters,
|
||||||
|
listCollections,
|
||||||
|
getCollection,
|
||||||
|
collectionAssetIds,
|
||||||
|
renameCollection,
|
||||||
|
mergeCollections,
|
||||||
|
splitCollection,
|
||||||
|
createManualCollection,
|
||||||
|
};
|
||||||
|
}
|
||||||
265
packages/db/src/media-fixtures.ts
Normal file
265
packages/db/src/media-fixtures.ts
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { MediaSourceItem } from "@rakazo/adapter-kit";
|
||||||
|
import type { BrandKit, TeaserWeights } from "@rakazo/contracts";
|
||||||
|
import { TEASER_FEATURES } from "@rakazo/contracts";
|
||||||
|
import { bootstrapUserSpace } from "./bootstrap-user.js";
|
||||||
|
import type { PrismaClient } from "./client.js";
|
||||||
|
import { createDb } from "./client.js";
|
||||||
|
import type { MediaRepos } from "./media.js";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Synthetic fixtures for the Media Studio Postgres tests. Nothing here describes a
|
||||||
|
* real person, place, or file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const signupEnv = { signupsEnabled: "true", signupAllowlist: "" };
|
||||||
|
|
||||||
|
export interface MediaTestDb {
|
||||||
|
prisma: PrismaClient;
|
||||||
|
newUser(): Promise<{ userId: string; spaceId: string }>;
|
||||||
|
cleanup(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openMediaTestDb(databaseUrl: string): MediaTestDb {
|
||||||
|
const db = createDb(databaseUrl, { poolMax: 8 });
|
||||||
|
const userIds: string[] = [];
|
||||||
|
return {
|
||||||
|
prisma: db.prisma,
|
||||||
|
async newUser() {
|
||||||
|
// bootstrapUserSpace derives the organization slug from the first 12 characters.
|
||||||
|
const id = `md${randomUUID().replaceAll("-", "")}`;
|
||||||
|
userIds.push(id);
|
||||||
|
await db.prisma.user.create({ data: { id, name: "Test", email: `${id}@rakazo.test` } });
|
||||||
|
const { spaceId } = await bootstrapUserSpace(db.prisma, { id }, signupEnv, {
|
||||||
|
claimDeploymentOwner: false,
|
||||||
|
});
|
||||||
|
return { userId: id, spaceId };
|
||||||
|
},
|
||||||
|
async cleanup() {
|
||||||
|
const memberships = await db.prisma.member.findMany({
|
||||||
|
where: { userId: { in: userIds } },
|
||||||
|
select: { organizationId: true },
|
||||||
|
});
|
||||||
|
await db.prisma.organization.deleteMany({
|
||||||
|
where: { id: { in: memberships.map((member) => member.organizationId) } },
|
||||||
|
});
|
||||||
|
await db.prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
||||||
|
await db.prisma.$disconnect();
|
||||||
|
await db.pool.end();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BASE_TIME = Date.UTC(2026, 0, 1, 8, 0, 0);
|
||||||
|
|
||||||
|
export function sourceItem(
|
||||||
|
index: number,
|
||||||
|
overrides: Partial<MediaSourceItem> = {},
|
||||||
|
): MediaSourceItem {
|
||||||
|
return {
|
||||||
|
externalId: `clip-${String(index).padStart(5, "0")}.mp4`,
|
||||||
|
kind: index % 3 === 0 ? "photo" : "video",
|
||||||
|
capturedAt: new Date(BASE_TIME + index * 60_000),
|
||||||
|
durationMs: index % 3 === 0 ? null : 10_000,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
fps: 30,
|
||||||
|
gpsLat: null,
|
||||||
|
gpsLon: null,
|
||||||
|
deviceModel: "Test Camera",
|
||||||
|
albumNames: [],
|
||||||
|
isFavorite: false,
|
||||||
|
fileName: `clip-${index}.mp4`,
|
||||||
|
sizeBytes: 1000 + index,
|
||||||
|
revision: "r1",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function testEdl(assetId: string, profileId = "example-short-vertical") {
|
||||||
|
return {
|
||||||
|
edlVersion: 1,
|
||||||
|
output: {
|
||||||
|
profileId,
|
||||||
|
width: 1080,
|
||||||
|
height: 1920,
|
||||||
|
fps: 30,
|
||||||
|
durationTargetMs: 5000,
|
||||||
|
durationHardMinMs: 1000,
|
||||||
|
durationHardMaxMs: 60_000,
|
||||||
|
},
|
||||||
|
tracks: {
|
||||||
|
video: [{ id: "c1", assetId, inMs: 0, outMs: 5000, atMs: 0, reframe: "auto" }],
|
||||||
|
},
|
||||||
|
metadata: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const testBrandKit: BrandKit = {
|
||||||
|
name: "Test Kit",
|
||||||
|
colors: {
|
||||||
|
primary: "#101010",
|
||||||
|
secondary: "#202020",
|
||||||
|
accent: "#303030",
|
||||||
|
text: "#f0f0f0",
|
||||||
|
background: "#000000",
|
||||||
|
},
|
||||||
|
fonts: [],
|
||||||
|
logoArtifactIds: [],
|
||||||
|
captionPresets: [],
|
||||||
|
musicBeds: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const testTeaserWeights = Object.fromEntries(
|
||||||
|
TEASER_FEATURES.map((feature) => [feature, 1]),
|
||||||
|
) as TeaserWeights;
|
||||||
|
|
||||||
|
export function vectorBytes(values: number[]): Uint8Array {
|
||||||
|
return new Uint8Array(new Float32Array(values).buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One of every media record in a space, linked the way the product links them. */
|
||||||
|
export async function seedMediaSpace(media: MediaRepos, spaceId: string, userId: string) {
|
||||||
|
const source = await media.createSource(spaceId, {
|
||||||
|
kind: "folder",
|
||||||
|
label: "Footage",
|
||||||
|
ownerUserId: userId,
|
||||||
|
config: { path: "/media/footage" },
|
||||||
|
});
|
||||||
|
const synced = await media.upsertSourceItems(spaceId, source.id, [
|
||||||
|
sourceItem(1, { albumNames: ["Holiday"] }),
|
||||||
|
sourceItem(2),
|
||||||
|
]);
|
||||||
|
const [assetId, secondAssetId] = synced.changedAssetIds as [string, string];
|
||||||
|
const analysis = await media.upsertAnalysis(spaceId, assetId, {
|
||||||
|
kind: "embeddings",
|
||||||
|
modelId: "test-embedder",
|
||||||
|
modelVersion: "1",
|
||||||
|
vectors: vectorBytes([1, 0, 0]),
|
||||||
|
});
|
||||||
|
await media.replaceSegments(spaceId, assetId, 1, [
|
||||||
|
{ kind: "shot", startMs: 0, endMs: 4000, labels: ["beach"], transcriptExcerpt: "hello" },
|
||||||
|
]);
|
||||||
|
const segments = await media.listSegments(spaceId, [assetId]);
|
||||||
|
const tracks = await media.replaceFaceTracks(spaceId, assetId, "faces-1", [
|
||||||
|
{
|
||||||
|
startMs: 0,
|
||||||
|
endMs: 3000,
|
||||||
|
confidenceMin: 0.9,
|
||||||
|
keyframesPath: `faces/${assetId}/t1.json`,
|
||||||
|
embedding: vectorBytes([0.1, 0.2, 0.3]),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const [person] = await media.createPersonSuggestions(spaceId, [
|
||||||
|
{ centroid: vectorBytes([0.1, 0.2, 0.3]), trackIds: tracks.map((track) => track.id) },
|
||||||
|
]);
|
||||||
|
const mark = await media.createMark(spaceId, {
|
||||||
|
kind: "tattoo",
|
||||||
|
label: "arm",
|
||||||
|
personId: person?.id ?? null,
|
||||||
|
assetId,
|
||||||
|
prompt: { tMs: 1000, point: { x: 0.5, y: 0.5 } },
|
||||||
|
treatment: "blur",
|
||||||
|
});
|
||||||
|
await media.replaceClusters(spaceId, "session", [
|
||||||
|
{
|
||||||
|
key: "s1",
|
||||||
|
title: "Morning",
|
||||||
|
labelSource: "rule",
|
||||||
|
startAt: new Date(BASE_TIME),
|
||||||
|
endAt: new Date(BASE_TIME + 120_000),
|
||||||
|
centroid: null,
|
||||||
|
coverAssetId: assetId,
|
||||||
|
assetIds: [assetId, secondAssetId],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const collection = (await media.listCollections(spaceId, {})).items[0];
|
||||||
|
const brandKit = await media.upsertBrandKit(spaceId, { kit: testBrandKit });
|
||||||
|
const project = await media.createProject(spaceId, userId, {
|
||||||
|
title: "Teaser",
|
||||||
|
goal: "teaser",
|
||||||
|
profileIds: ["example-short-vertical"],
|
||||||
|
collectionIds: collection ? [collection.id] : [],
|
||||||
|
brandKitId: brandKit.id,
|
||||||
|
});
|
||||||
|
const plan = await media.createPlanVersion(spaceId, {
|
||||||
|
projectId: project.id,
|
||||||
|
profileId: "example-short-vertical",
|
||||||
|
edl: testEdl(assetId),
|
||||||
|
createdBy: "agent",
|
||||||
|
rationale: null,
|
||||||
|
});
|
||||||
|
const render = await media.createRender(spaceId, {
|
||||||
|
planId: plan.id,
|
||||||
|
quality: "draft",
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
await media.claimRender(spaceId, render.id);
|
||||||
|
await media.updateRender(spaceId, render.id, { status: "done", progress: 1 });
|
||||||
|
const queuedRender = await media.createRender(spaceId, {
|
||||||
|
planId: plan.id,
|
||||||
|
quality: "final",
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
const publication = await media.createPublication(spaceId, userId, {
|
||||||
|
renderId: render.id,
|
||||||
|
destination: "bundle",
|
||||||
|
targetUrl: "https://example.com/offer",
|
||||||
|
trackingUrl: "https://example.com/offer?utm_content=a",
|
||||||
|
utm: { source: "shorts", medium: "video", campaign: "launch", content: "a" },
|
||||||
|
title: "Title",
|
||||||
|
caption: "Caption",
|
||||||
|
tags: ["test"],
|
||||||
|
thumbnailVariant: "t1",
|
||||||
|
scheduledAt: null,
|
||||||
|
});
|
||||||
|
const metric = await media.recordMetric(spaceId, {
|
||||||
|
publicationId: publication.id,
|
||||||
|
observedAt: new Date(BASE_TIME).toISOString(),
|
||||||
|
views: 100,
|
||||||
|
clicks: 10,
|
||||||
|
conversions: 1,
|
||||||
|
revenueCents: 999,
|
||||||
|
currency: "EUR",
|
||||||
|
source: "manual",
|
||||||
|
});
|
||||||
|
const mapping = await media.saveMetricMapping(spaceId, {
|
||||||
|
name: "Default",
|
||||||
|
delimiter: ",",
|
||||||
|
columns: { publication: "id", observedAt: "date", views: "views" },
|
||||||
|
publicationKey: "id",
|
||||||
|
revenueUnit: "major",
|
||||||
|
defaultCurrency: "EUR",
|
||||||
|
});
|
||||||
|
await media.putTeaserModel(spaceId, testTeaserWeights, 3);
|
||||||
|
const upload = await media.beginUpload(spaceId, {
|
||||||
|
assetId: secondAssetId,
|
||||||
|
userId,
|
||||||
|
sizeBytes: 10,
|
||||||
|
sha256: "a".repeat(64),
|
||||||
|
fileName: "clip.mp4",
|
||||||
|
chunkBytes: 4,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
sourceId: source.id,
|
||||||
|
assetId,
|
||||||
|
secondAssetId,
|
||||||
|
analysisId: analysis.id,
|
||||||
|
segmentId: segments[0]?.id as string,
|
||||||
|
trackId: tracks[0]?.id as string,
|
||||||
|
personId: person?.id as string,
|
||||||
|
markId: mark.id,
|
||||||
|
collectionId: collection?.id as string,
|
||||||
|
brandKitId: brandKit.id,
|
||||||
|
projectId: project.id,
|
||||||
|
planId: plan.id,
|
||||||
|
renderId: render.id,
|
||||||
|
queuedRenderId: queuedRender.id,
|
||||||
|
publicationId: publication.id,
|
||||||
|
metricId: metric.id,
|
||||||
|
mappingId: mapping.id as string,
|
||||||
|
uploadId: upload.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SeededMediaSpace = Awaited<ReturnType<typeof seedMediaSpace>>;
|
||||||
428
packages/db/src/media-isolation.postgres.test.ts
Normal file
428
packages/db/src/media-isolation.postgres.test.ts
Normal file
@ -0,0 +1,428 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import type { MediaRepos } from "./media.js";
|
||||||
|
import { createMediaRepos } from "./media.js";
|
||||||
|
import type { MediaTestDb, SeededMediaSpace } from "./media-fixtures.js";
|
||||||
|
import {
|
||||||
|
openMediaTestDb,
|
||||||
|
seedMediaSpace,
|
||||||
|
sourceItem,
|
||||||
|
testBrandKit,
|
||||||
|
testEdl,
|
||||||
|
vectorBytes,
|
||||||
|
} from "./media-fixtures.js";
|
||||||
|
import { IsolationError } from "./scope.js";
|
||||||
|
|
||||||
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
const describePostgres =
|
||||||
|
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
|
||||||
|
|
||||||
|
describePostgres("media repositories: space isolation (PostgreSQL)", () => {
|
||||||
|
let db: MediaTestDb;
|
||||||
|
let media: MediaRepos;
|
||||||
|
let a: SeededMediaSpace;
|
||||||
|
let owner: { userId: string; spaceId: string };
|
||||||
|
let intruder: { userId: string; spaceId: string };
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = openMediaTestDb(databaseUrl!);
|
||||||
|
media = createMediaRepos(db.prisma);
|
||||||
|
owner = await db.newUser();
|
||||||
|
intruder = await db.newUser();
|
||||||
|
a = await seedMediaSpace(media, owner.spaceId, owner.userId);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await db?.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function snapshot() {
|
||||||
|
const where = { spaceId: owner.spaceId };
|
||||||
|
const p = db.prisma;
|
||||||
|
return {
|
||||||
|
sources: await p.mediaSource.findMany({ where, orderBy: { id: "asc" } }),
|
||||||
|
assets: await p.mediaAsset.findMany({ where, orderBy: { id: "asc" } }),
|
||||||
|
segments: await p.mediaSegment.findMany({ where, orderBy: { id: "asc" } }),
|
||||||
|
analyses: await p.mediaAnalysis.findMany({ where, orderBy: { id: "asc" } }),
|
||||||
|
collections: await p.mediaCollection.findMany({ where, orderBy: { id: "asc" } }),
|
||||||
|
items: await p.mediaCollectionItem.findMany({
|
||||||
|
where: { collection: where },
|
||||||
|
orderBy: [{ collectionId: "asc" }, { assetId: "asc" }],
|
||||||
|
}),
|
||||||
|
people: await p.mediaPerson.findMany({ where, orderBy: { id: "asc" } }),
|
||||||
|
tracks: await p.mediaFaceTrack.findMany({ where, orderBy: { id: "asc" } }),
|
||||||
|
marks: await p.mediaMark.findMany({ where, orderBy: { id: "asc" } }),
|
||||||
|
kits: await p.mediaBrandKit.findMany({ where, orderBy: { id: "asc" } }),
|
||||||
|
projects: await p.mediaProject.findMany({ where, orderBy: { id: "asc" } }),
|
||||||
|
plans: await p.mediaEditPlan.findMany({ where, orderBy: { id: "asc" } }),
|
||||||
|
renders: await p.mediaRender.findMany({ where, orderBy: { id: "asc" } }),
|
||||||
|
publications: await p.mediaPublication.findMany({ where, orderBy: { id: "asc" } }),
|
||||||
|
metrics: await p.mediaMetric.findMany({ where, orderBy: { id: "asc" } }),
|
||||||
|
mappings: await p.mediaMetricMapping.findMany({ where, orderBy: { id: "asc" } }),
|
||||||
|
teaser: await p.mediaTeaserModel.findMany({ where }),
|
||||||
|
uploads: await p.mediaUpload.findMany({ where, orderBy: { id: "asc" } }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("rejects every read and write of another space's ids and leaves its rows untouched", async () => {
|
||||||
|
const before = await snapshot();
|
||||||
|
const b = intruder.spaceId;
|
||||||
|
const denied: Array<[string, () => Promise<unknown>]> = [
|
||||||
|
["getSource", () => media.getSource(b, a.sourceId)],
|
||||||
|
["updateSourceSync", () => media.updateSourceSync(b, a.sourceId, { status: "error" })],
|
||||||
|
["removeSource", () => media.removeSource(b, a.sourceId)],
|
||||||
|
["upsertSourceItems", () => media.upsertSourceItems(b, a.sourceId, [sourceItem(99)])],
|
||||||
|
["getAsset", () => media.getAsset(b, a.assetId)],
|
||||||
|
["getAssetRecord", () => media.getAssetRecord(b, a.assetId)],
|
||||||
|
[
|
||||||
|
"transitionIngestState",
|
||||||
|
() => media.transitionIngestState(b, a.assetId, { from: ["indexed"], to: "proxying" }),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"recordProxy",
|
||||||
|
() => media.recordProxy(b, a.assetId, { contentHash: "x", proxyPath: "p", thumbPath: "t" }),
|
||||||
|
],
|
||||||
|
["recordAnalysisVersion", () => media.recordAnalysisVersion(b, a.assetId, 9, 1)],
|
||||||
|
["requestUpload", () => media.requestUpload(b, [a.assetId])],
|
||||||
|
[
|
||||||
|
"upsertAnalysis",
|
||||||
|
() =>
|
||||||
|
media.upsertAnalysis(b, a.assetId, {
|
||||||
|
kind: "embeddings",
|
||||||
|
modelId: "m",
|
||||||
|
modelVersion: "1",
|
||||||
|
vectors: vectorBytes([9]),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
["latestAnalysis", () => media.latestAnalysis(b, a.assetId, "embeddings")],
|
||||||
|
[
|
||||||
|
"replaceSegments",
|
||||||
|
() => media.replaceSegments(b, a.assetId, 2, [{ kind: "shot", startMs: 0, endMs: 1 }]),
|
||||||
|
],
|
||||||
|
["setSegmentsPaidOnly", () => media.setSegmentsPaidOnly(b, [a.segmentId], true)],
|
||||||
|
[
|
||||||
|
"beginUpload",
|
||||||
|
() =>
|
||||||
|
media.beginUpload(b, {
|
||||||
|
assetId: a.assetId,
|
||||||
|
userId: intruder.userId,
|
||||||
|
sizeBytes: 1,
|
||||||
|
sha256: "b".repeat(64),
|
||||||
|
fileName: "x",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
["uploadStatus", () => media.uploadStatus(b, a.uploadId)],
|
||||||
|
["getUploadRecord", () => media.getUploadRecord(b, a.uploadId)],
|
||||||
|
["markChunkReceived", () => media.markChunkReceived(b, a.uploadId, 0)],
|
||||||
|
["completeUpload", () => media.completeUpload(b, a.uploadId, { storagePath: "x" })],
|
||||||
|
["failUpload", () => media.failUpload(b, a.uploadId)],
|
||||||
|
[
|
||||||
|
"replaceClusters",
|
||||||
|
() =>
|
||||||
|
media.replaceClusters(b, "session", [
|
||||||
|
{
|
||||||
|
key: "s1",
|
||||||
|
title: "Stolen",
|
||||||
|
labelSource: "rule",
|
||||||
|
startAt: null,
|
||||||
|
endAt: null,
|
||||||
|
centroid: null,
|
||||||
|
coverAssetId: null,
|
||||||
|
assetIds: [a.assetId],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
["getCollection", () => media.getCollection(b, a.collectionId)],
|
||||||
|
["collectionAssetIds", () => media.collectionAssetIds(b, [a.collectionId])],
|
||||||
|
["renameCollection", () => media.renameCollection(b, a.collectionId, "Mine")],
|
||||||
|
["mergeCollections", () => media.mergeCollections(b, [a.collectionId, `${a.collectionId}x`])],
|
||||||
|
["splitCollection", () => media.splitCollection(b, a.collectionId, a.secondAssetId)],
|
||||||
|
["createManualCollection", () => media.createManualCollection(b, "Mine", [a.assetId])],
|
||||||
|
["getPerson", () => media.getPerson(b, a.personId)],
|
||||||
|
[
|
||||||
|
"createPersonSuggestions",
|
||||||
|
() =>
|
||||||
|
media.createPersonSuggestions(b, [{ centroid: vectorBytes([1]), trackIds: [a.trackId] }]),
|
||||||
|
],
|
||||||
|
["updatePerson", () => media.updatePerson(b, a.personId, { displayName: "Mine" })],
|
||||||
|
["mergePeople", () => media.mergePeople(b, [a.personId, `${a.personId}x`])],
|
||||||
|
["assignTrack", () => media.assignTrack(b, a.trackId, null)],
|
||||||
|
[
|
||||||
|
"recordConsent (foreign person)",
|
||||||
|
() =>
|
||||||
|
media.recordConsent(
|
||||||
|
{ spaceId: b, recordedByUserId: intruder.userId },
|
||||||
|
{
|
||||||
|
personId: a.personId,
|
||||||
|
consentArtifactId: null,
|
||||||
|
consentRecordedAt: new Date().toISOString(),
|
||||||
|
ageVerificationMethod: "none",
|
||||||
|
ageVerifiedAt: null,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"recordConsent (foreign recorder)",
|
||||||
|
() =>
|
||||||
|
media.recordConsent(
|
||||||
|
{ spaceId: owner.spaceId, recordedByUserId: intruder.userId },
|
||||||
|
{
|
||||||
|
personId: a.personId,
|
||||||
|
consentArtifactId: null,
|
||||||
|
consentRecordedAt: new Date().toISOString(),
|
||||||
|
ageVerificationMethod: "none",
|
||||||
|
ageVerifiedAt: null,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
["removePersonCompletely", () => media.removePersonCompletely(b, a.personId)],
|
||||||
|
["replaceFaceTracks", () => media.replaceFaceTracks(b, a.assetId, "faces-2", [])],
|
||||||
|
[
|
||||||
|
"createMark",
|
||||||
|
() =>
|
||||||
|
media.createMark(b, {
|
||||||
|
kind: "scar",
|
||||||
|
label: null,
|
||||||
|
personId: null,
|
||||||
|
assetId: a.assetId,
|
||||||
|
prompt: { tMs: 0 },
|
||||||
|
treatment: "blur",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
["getMark", () => media.getMark(b, a.markId)],
|
||||||
|
["updateMark", () => media.updateMark(b, a.markId, { treatment: "cover" })],
|
||||||
|
[
|
||||||
|
"recordMarkResult",
|
||||||
|
() =>
|
||||||
|
media.recordMarkResult(b, a.markId, {
|
||||||
|
qualityScore: 0,
|
||||||
|
suggestedFallback: "cover",
|
||||||
|
maskDir: "x",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
["removeMark", () => media.removeMark(b, a.markId)],
|
||||||
|
["upsertBrandKit", () => media.upsertBrandKit(b, { id: a.brandKitId, kit: testBrandKit })],
|
||||||
|
["getBrandKit", () => media.getBrandKit(b, a.brandKitId)],
|
||||||
|
["removeBrandKit", () => media.removeBrandKit(b, a.brandKitId)],
|
||||||
|
[
|
||||||
|
"createProject (foreign brand kit)",
|
||||||
|
() =>
|
||||||
|
media.createProject(b, intruder.userId, {
|
||||||
|
title: "x",
|
||||||
|
goal: "teaser",
|
||||||
|
profileIds: ["example-short-vertical"],
|
||||||
|
brandKitId: a.brandKitId,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"createProject (foreign collection)",
|
||||||
|
() =>
|
||||||
|
media.createProject(b, intruder.userId, {
|
||||||
|
title: "x",
|
||||||
|
goal: "teaser",
|
||||||
|
profileIds: ["example-short-vertical"],
|
||||||
|
collectionIds: [a.collectionId],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
["updateProject", () => media.updateProject(b, a.projectId, { title: "Mine" })],
|
||||||
|
["getProject", () => media.getProject(b, a.projectId)],
|
||||||
|
["getProjectRecord", () => media.getProjectRecord(b, a.projectId)],
|
||||||
|
["removeProject", () => media.removeProject(b, a.projectId)],
|
||||||
|
[
|
||||||
|
"createPlanVersion",
|
||||||
|
() =>
|
||||||
|
media.createPlanVersion(b, {
|
||||||
|
projectId: a.projectId,
|
||||||
|
profileId: "example-short-vertical",
|
||||||
|
edl: testEdl(a.assetId),
|
||||||
|
createdBy: "agent",
|
||||||
|
rationale: null,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
["getPlan", () => media.getPlan(b, a.planId)],
|
||||||
|
["latestPlan", () => media.latestPlan(b, a.projectId, "example-short-vertical")],
|
||||||
|
["listPlanVersions", () => media.listPlanVersions(b, a.projectId, "example-short-vertical")],
|
||||||
|
[
|
||||||
|
"createRender",
|
||||||
|
() => media.createRender(b, { planId: a.planId, quality: "draft", createdBy: "x" }),
|
||||||
|
],
|
||||||
|
["getRender", () => media.getRender(b, a.renderId)],
|
||||||
|
["getRenderRecord", () => media.getRenderRecord(b, a.renderId)],
|
||||||
|
["claimRender", () => media.claimRender(b, a.queuedRenderId)],
|
||||||
|
["updateRender", () => media.updateRender(b, a.queuedRenderId, { progress: 0.5 })],
|
||||||
|
["cancelRender", () => media.cancelRender(b, a.queuedRenderId)],
|
||||||
|
[
|
||||||
|
"createPublication",
|
||||||
|
() =>
|
||||||
|
media.createPublication(b, intruder.userId, {
|
||||||
|
renderId: a.renderId,
|
||||||
|
destination: "bundle",
|
||||||
|
targetUrl: null,
|
||||||
|
trackingUrl: null,
|
||||||
|
utm: null,
|
||||||
|
title: null,
|
||||||
|
caption: null,
|
||||||
|
tags: [],
|
||||||
|
thumbnailVariant: null,
|
||||||
|
scheduledAt: null,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
["getPublication", () => media.getPublication(b, a.publicationId)],
|
||||||
|
["getPublicationRecord", () => media.getPublicationRecord(b, a.publicationId)],
|
||||||
|
["schedulePublication", () => media.schedulePublication(b, a.publicationId, new Date())],
|
||||||
|
["cancelPublication", () => media.cancelPublication(b, a.publicationId)],
|
||||||
|
["markPublicationBundled", () => media.markPublicationBundled(b, a.publicationId, null)],
|
||||||
|
["markPublicationPublished", () => media.markPublicationPublished(b, a.publicationId, null)],
|
||||||
|
["markPublicationFailed", () => media.markPublicationFailed(b, a.publicationId)],
|
||||||
|
[
|
||||||
|
"recordMetric",
|
||||||
|
() =>
|
||||||
|
media.recordMetric(b, {
|
||||||
|
publicationId: a.publicationId,
|
||||||
|
observedAt: new Date().toISOString(),
|
||||||
|
views: 1,
|
||||||
|
clicks: 0,
|
||||||
|
conversions: 0,
|
||||||
|
revenueCents: 0,
|
||||||
|
currency: "EUR",
|
||||||
|
source: "manual",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"recordMetrics",
|
||||||
|
() =>
|
||||||
|
media.recordMetrics(b, [
|
||||||
|
{
|
||||||
|
publicationId: a.publicationId,
|
||||||
|
observedAt: new Date().toISOString(),
|
||||||
|
views: 1,
|
||||||
|
clicks: 0,
|
||||||
|
conversions: 0,
|
||||||
|
revenueCents: 0,
|
||||||
|
currency: "EUR",
|
||||||
|
source: "csv_import",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
["listMetrics", () => media.listMetrics(b, a.publicationId)],
|
||||||
|
[
|
||||||
|
"saveMetricMapping (foreign id)",
|
||||||
|
() =>
|
||||||
|
media.saveMetricMapping(b, {
|
||||||
|
id: a.mappingId,
|
||||||
|
name: "Stolen",
|
||||||
|
delimiter: ",",
|
||||||
|
columns: { publication: "id", observedAt: "date" },
|
||||||
|
publicationKey: "id",
|
||||||
|
revenueUnit: "major",
|
||||||
|
defaultCurrency: "EUR",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
["getMetricMapping", () => media.getMetricMapping(b, a.mappingId)],
|
||||||
|
["funnel (foreign project)", () => media.funnel(b, a.projectId)],
|
||||||
|
];
|
||||||
|
for (const [name, call] of denied) {
|
||||||
|
await expect(call(), name).rejects.toBeInstanceOf(IsolationError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads without ids return nothing of the other space.
|
||||||
|
expect(await media.listSources(b)).toEqual([]);
|
||||||
|
expect((await media.searchAssets(b, {})).items).toEqual([]);
|
||||||
|
expect((await media.searchAssets(b, { text: "clip" })).items).toEqual([]);
|
||||||
|
expect((await media.searchAssets(b, { personId: a.personId })).items).toEqual([]);
|
||||||
|
expect((await media.searchAssets(b, { collectionId: a.collectionId })).items).toEqual([]);
|
||||||
|
expect((await media.searchAssets(b, { sourceId: a.sourceId })).items).toEqual([]);
|
||||||
|
expect(await media.listAssetRecords(b, [a.assetId])).toEqual([]);
|
||||||
|
expect(await media.listAssetsForClustering(b)).toEqual([]);
|
||||||
|
expect(await media.assetsNeedingWork(b, "indexed", 10)).toEqual([]);
|
||||||
|
expect((await media.latestAnalysesByKind(b, "embeddings")).size).toBe(0);
|
||||||
|
expect(await media.listSegments(b, [a.assetId])).toEqual([]);
|
||||||
|
expect((await media.listCollections(b, {})).items).toEqual([]);
|
||||||
|
expect(await media.listPeople(b)).toEqual([]);
|
||||||
|
expect(await media.listPersonRecords(b, [a.personId])).toEqual([]);
|
||||||
|
expect(await media.personCentroids(b)).toEqual([]);
|
||||||
|
expect(await media.listFaceTracks(b, { assetId: a.assetId })).toEqual([]);
|
||||||
|
expect(await media.faceTrackRecords(b, [a.assetId])).toEqual([]);
|
||||||
|
expect(await media.faceTrackEmbeddings(b)).toEqual([]);
|
||||||
|
expect(await media.listMarks(b, { assetId: a.assetId })).toEqual([]);
|
||||||
|
expect(await media.marksForAsset(b, a.assetId)).toEqual([]);
|
||||||
|
expect(await media.listBrandKits(b)).toEqual([]);
|
||||||
|
expect((await media.listProjects(b)).items).toEqual([]);
|
||||||
|
expect(await media.latestPlans(b, a.projectId)).toEqual([]);
|
||||||
|
expect(await media.listRenders(b, a.projectId)).toEqual([]);
|
||||||
|
expect((await media.listPublications(b, { projectId: a.projectId })).items).toEqual([]);
|
||||||
|
expect((await media.resolvePublicationKeys(b, "id", [a.publicationId])).size).toBe(0);
|
||||||
|
expect(await media.listMetricMappings(b)).toEqual([]);
|
||||||
|
expect(await media.funnel(b)).toEqual([]);
|
||||||
|
expect(await media.publicationsWithMetricsCount(b)).toBe(0);
|
||||||
|
expect(await media.getTeaserModel(b)).toBeNull();
|
||||||
|
await media.resetTeaserModel(b);
|
||||||
|
|
||||||
|
expect(await snapshot()).toEqual(before);
|
||||||
|
// The owner still sees everything.
|
||||||
|
expect((await media.searchAssets(owner.spaceId, {})).items).toHaveLength(2);
|
||||||
|
expect(await media.getTeaserModel(owner.spaceId)).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves the owning space for worker jobs", async () => {
|
||||||
|
expect(await media.mediaSpaceOf("source", a.sourceId)).toBe(owner.spaceId);
|
||||||
|
expect(await media.mediaSpaceOf("asset", a.assetId)).toBe(owner.spaceId);
|
||||||
|
expect(await media.mediaSpaceOf("render", a.renderId)).toBe(owner.spaceId);
|
||||||
|
expect(await media.mediaSpaceOf("publication", a.publicationId)).toBe(owner.spaceId);
|
||||||
|
expect(await media.mediaSpaceOf("asset", "missing")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleting a space removes every media row of it and nothing else", async () => {
|
||||||
|
const other = await db.newUser();
|
||||||
|
const seeded = await seedMediaSpace(media, other.spaceId, other.userId);
|
||||||
|
expect(seeded.assetId).toBeTruthy();
|
||||||
|
const tables = [
|
||||||
|
"media_sources",
|
||||||
|
"media_assets",
|
||||||
|
"media_uploads",
|
||||||
|
"media_segments",
|
||||||
|
"media_analyses",
|
||||||
|
"media_collections",
|
||||||
|
"media_people",
|
||||||
|
"media_face_tracks",
|
||||||
|
"media_marks",
|
||||||
|
"media_brand_kits",
|
||||||
|
"media_projects",
|
||||||
|
"media_edit_plans",
|
||||||
|
"media_renders",
|
||||||
|
"media_publications",
|
||||||
|
"media_metrics",
|
||||||
|
"media_metric_mappings",
|
||||||
|
"media_teaser_models",
|
||||||
|
];
|
||||||
|
async function counts(spaceId: string) {
|
||||||
|
const out: Record<string, number> = {};
|
||||||
|
for (const table of tables) {
|
||||||
|
const rows = await db.prisma.$queryRawUnsafe<Array<{ count: number }>>(
|
||||||
|
`SELECT count(*)::int AS count FROM ${table} WHERE "spaceId" = $1`,
|
||||||
|
spaceId,
|
||||||
|
);
|
||||||
|
out[table] = rows[0]?.count ?? -1;
|
||||||
|
}
|
||||||
|
const items = await db.prisma.$queryRawUnsafe<Array<{ count: number }>>(
|
||||||
|
`SELECT count(*)::int AS count FROM media_collection_items ci
|
||||||
|
JOIN media_assets a ON a.id = ci."assetId" WHERE a."spaceId" = $1`,
|
||||||
|
spaceId,
|
||||||
|
);
|
||||||
|
out.media_collection_items = items[0]?.count ?? -1;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const populated = await counts(other.spaceId);
|
||||||
|
for (const table of tables) expect(populated[table], table).toBeGreaterThan(0);
|
||||||
|
const ownerBefore = await counts(owner.spaceId);
|
||||||
|
|
||||||
|
const itemCountBefore = await db.prisma.mediaCollectionItem.count();
|
||||||
|
await db.prisma.space.delete({ where: { id: other.spaceId } });
|
||||||
|
|
||||||
|
const after = await counts(other.spaceId);
|
||||||
|
for (const [table, count] of Object.entries(after)) expect(count, table).toBe(0);
|
||||||
|
expect(await db.prisma.mediaCollectionItem.count()).toBe(
|
||||||
|
itemCountBefore - (populated.media_collection_items ?? 0),
|
||||||
|
);
|
||||||
|
expect(await counts(owner.spaceId)).toEqual(ownerBefore);
|
||||||
|
});
|
||||||
|
});
|
||||||
756
packages/db/src/media-library.ts
Normal file
756
packages/db/src/media-library.ts
Normal file
@ -0,0 +1,756 @@
|
|||||||
|
import type { MediaSourceItem } from "@rakazo/adapter-kit";
|
||||||
|
import type {
|
||||||
|
MediaAnalysisKind,
|
||||||
|
MediaAsset,
|
||||||
|
MediaAssetQuery,
|
||||||
|
MediaFaceTrack,
|
||||||
|
MediaIngestState,
|
||||||
|
MediaSegment,
|
||||||
|
MediaSegmentKind,
|
||||||
|
MediaSource,
|
||||||
|
MediaSourceKind,
|
||||||
|
MediaSourceStatus,
|
||||||
|
TeaserFeature,
|
||||||
|
} from "@rakazo/contracts";
|
||||||
|
import { CLIENT_PUSHED_MEDIA_SOURCES } from "@rakazo/contracts";
|
||||||
|
import { roundCoordinate } from "@rakazo/media";
|
||||||
|
import type {
|
||||||
|
MediaAnalysis as MediaAnalysisRow,
|
||||||
|
MediaAsset as MediaAssetRow,
|
||||||
|
MediaSource as MediaSourceRow,
|
||||||
|
PrismaClient,
|
||||||
|
} from "./client.js";
|
||||||
|
import { Prisma } from "./client.js";
|
||||||
|
import type { MediaPage, MediaPageInput } from "./media-shared.js";
|
||||||
|
import {
|
||||||
|
decodeKeysetCursor,
|
||||||
|
InvalidMediaInputError,
|
||||||
|
keysetAfterSql,
|
||||||
|
likeContains,
|
||||||
|
lockMediaKey,
|
||||||
|
mapMediaAsset,
|
||||||
|
mapMediaFaceTrack,
|
||||||
|
mapMediaSegment,
|
||||||
|
mapMediaSource,
|
||||||
|
pageLimit,
|
||||||
|
toBytes,
|
||||||
|
toPage,
|
||||||
|
} from "./media-shared.js";
|
||||||
|
import { IsolationError } from "./scope.js";
|
||||||
|
|
||||||
|
export interface NewMediaSource {
|
||||||
|
kind: MediaSourceKind;
|
||||||
|
label: string;
|
||||||
|
ownerUserId: string;
|
||||||
|
/** Folder: { path }. Client-pushed: { deviceId }. Never secrets. */
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Source row for the sync worker, including its config and cursor. */
|
||||||
|
export interface MediaSourceRecord extends MediaSource {
|
||||||
|
spaceId: string;
|
||||||
|
ownerUserId: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
lastSyncCursor: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaSourceSyncPatch {
|
||||||
|
status?: MediaSourceStatus;
|
||||||
|
lastSyncCursor?: string | null;
|
||||||
|
lastSyncedAt?: Date | null;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpsertSourceItemsResult {
|
||||||
|
created: number;
|
||||||
|
updated: number;
|
||||||
|
unchanged: number;
|
||||||
|
/** New assets and assets whose revision changed, in input order. */
|
||||||
|
changedAssetIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MediaAssetSearch = Omit<MediaAssetQuery, "cursor" | "limit"> & MediaPageInput;
|
||||||
|
|
||||||
|
export interface MediaAssetDetail {
|
||||||
|
asset: MediaAsset;
|
||||||
|
segments: MediaSegment[];
|
||||||
|
faceTracks: MediaFaceTrack[];
|
||||||
|
transcript: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaProxyResult {
|
||||||
|
contentHash: string;
|
||||||
|
proxyPath: string | null;
|
||||||
|
thumbPath: string | null;
|
||||||
|
spritePath?: string | null;
|
||||||
|
durationMs?: number | null;
|
||||||
|
width?: number | null;
|
||||||
|
height?: number | null;
|
||||||
|
fps?: number | null;
|
||||||
|
codec?: string | null;
|
||||||
|
orientation?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewMediaAnalysis {
|
||||||
|
kind: MediaAnalysisKind;
|
||||||
|
modelId: string;
|
||||||
|
modelVersion: string;
|
||||||
|
data?: Prisma.InputJsonValue | null;
|
||||||
|
dataPath?: string | null;
|
||||||
|
/** Float32 little-endian vectors (see encodeVectors in @rakazo/media). */
|
||||||
|
vectors?: Uint8Array | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewMediaSegment {
|
||||||
|
kind: MediaSegmentKind;
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
scores?: Partial<Record<TeaserFeature, number>>;
|
||||||
|
teaserScore?: number | null;
|
||||||
|
transcriptExcerpt?: string | null;
|
||||||
|
labels?: string[];
|
||||||
|
paidOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MediaSpaceLookup = "source" | "asset" | "render" | "publication";
|
||||||
|
|
||||||
|
const EARTH_RADIUS_KM = 6371.0088;
|
||||||
|
const KM_PER_DEGREE = 111.32;
|
||||||
|
|
||||||
|
function sourceRecord(row: MediaSourceRow, assetCount: number): MediaSourceRecord {
|
||||||
|
const config =
|
||||||
|
row.config && typeof row.config === "object" && !Array.isArray(row.config)
|
||||||
|
? (row.config as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
return {
|
||||||
|
...mapMediaSource(row, assetCount),
|
||||||
|
spaceId: row.spaceId,
|
||||||
|
ownerUserId: row.ownerUserId,
|
||||||
|
config,
|
||||||
|
lastSyncCursor: row.lastSyncCursor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rounded(value: number | null): number | null {
|
||||||
|
return value === null ? null : roundCoordinate(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemData(item: MediaSourceItem) {
|
||||||
|
return {
|
||||||
|
kind: item.kind,
|
||||||
|
revision: item.revision,
|
||||||
|
capturedAt: item.capturedAt,
|
||||||
|
durationMs: item.durationMs,
|
||||||
|
width: item.width,
|
||||||
|
height: item.height,
|
||||||
|
fps: item.fps,
|
||||||
|
gpsLat: rounded(item.gpsLat),
|
||||||
|
gpsLon: rounded(item.gpsLon),
|
||||||
|
deviceModel: item.deviceModel,
|
||||||
|
albumNames: item.albumNames,
|
||||||
|
isFavorite: item.isFavorite,
|
||||||
|
fileName: item.fileName,
|
||||||
|
sizeBytes: item.sizeBytes === null ? null : BigInt(item.sizeBytes),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plain text of a stored transcript analysis (TranscribeResult words or a text field). */
|
||||||
|
function transcriptText(data: Prisma.JsonValue | null): string | null {
|
||||||
|
if (!data || typeof data !== "object" || Array.isArray(data)) return null;
|
||||||
|
if (typeof data.text === "string") return data.text;
|
||||||
|
if (!Array.isArray(data.words)) return null;
|
||||||
|
const words = data.words
|
||||||
|
.map((word) =>
|
||||||
|
word && typeof word === "object" && !Array.isArray(word) && typeof word.text === "string"
|
||||||
|
? word.text.trim()
|
||||||
|
: "",
|
||||||
|
)
|
||||||
|
.filter(Boolean);
|
||||||
|
return words.length > 0 ? words.join(" ") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearSql(near: NonNullable<MediaAssetQuery["near"]>): Prisma.Sql {
|
||||||
|
const { lat, lon, radiusKm } = near;
|
||||||
|
const dLat = radiusKm / KM_PER_DEGREE;
|
||||||
|
const conditions: Prisma.Sql[] = [
|
||||||
|
Prisma.sql`a."gpsLat" IS NOT NULL AND a."gpsLon" IS NOT NULL`,
|
||||||
|
Prisma.sql`a."gpsLat" BETWEEN ${lat - dLat}::float8 AND ${lat + dLat}::float8`,
|
||||||
|
];
|
||||||
|
// Longitude prefilter only where it is a simple band; near the poles the haversine alone decides.
|
||||||
|
const maxAbsLat = Math.min(90, Math.abs(lat) + dLat);
|
||||||
|
const cos = Math.cos((maxAbsLat * Math.PI) / 180);
|
||||||
|
if (cos > 0.01) {
|
||||||
|
const dLon = radiusKm / (KM_PER_DEGREE * cos);
|
||||||
|
if (dLon < 180) {
|
||||||
|
const west = lon - dLon;
|
||||||
|
const east = lon + dLon;
|
||||||
|
if (west < -180) {
|
||||||
|
conditions.push(
|
||||||
|
Prisma.sql`(a."gpsLon" >= ${west + 360}::float8 OR a."gpsLon" <= ${east}::float8)`,
|
||||||
|
);
|
||||||
|
} else if (east > 180) {
|
||||||
|
conditions.push(
|
||||||
|
Prisma.sql`(a."gpsLon" >= ${west}::float8 OR a."gpsLon" <= ${east - 360}::float8)`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
conditions.push(Prisma.sql`a."gpsLon" BETWEEN ${west}::float8 AND ${east}::float8`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
conditions.push(Prisma.sql`
|
||||||
|
2 * ${EARTH_RADIUS_KM}::float8 * asin(least(1, sqrt(
|
||||||
|
power(sin(radians(a."gpsLat" - ${lat}::float8) / 2), 2)
|
||||||
|
+ cos(radians(${lat}::float8)) * cos(radians(a."gpsLat"))
|
||||||
|
* power(sin(radians(a."gpsLon" - ${lon}::float8) / 2), 2)
|
||||||
|
))) <= ${radiusKm}::float8`);
|
||||||
|
return Prisma.join(conditions, " AND ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function textSql(spaceId: string, text: string): Prisma.Sql {
|
||||||
|
const pattern = likeContains(text);
|
||||||
|
return Prisma.sql`(
|
||||||
|
a."fileName" ILIKE ${pattern}
|
||||||
|
OR EXISTS (SELECT 1 FROM unnest(a."albumNames") AS album(name) WHERE album.name ILIKE ${pattern})
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM media_collection_items ci
|
||||||
|
JOIN media_collections c ON c.id = ci."collectionId"
|
||||||
|
WHERE ci."assetId" = a.id AND c."spaceId" = ${spaceId} AND c.title ILIKE ${pattern}
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM media_segments s
|
||||||
|
WHERE s."assetId" = a.id AND s."spaceId" = ${spaceId}
|
||||||
|
AND (
|
||||||
|
s."transcriptExcerpt" ILIKE ${pattern}
|
||||||
|
OR EXISTS (SELECT 1 FROM unnest(s.labels) AS label(name) WHERE label.name ILIKE ${pattern})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMediaLibraryRepos(prisma: PrismaClient) {
|
||||||
|
async function requireAsset(
|
||||||
|
db: Prisma.TransactionClient | PrismaClient,
|
||||||
|
spaceId: string,
|
||||||
|
assetId: string,
|
||||||
|
): Promise<MediaAssetRow> {
|
||||||
|
const asset = await db.mediaAsset.findFirst({ where: { id: assetId, spaceId } });
|
||||||
|
if (!asset) throw new IsolationError();
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateAsset(
|
||||||
|
spaceId: string,
|
||||||
|
assetId: string,
|
||||||
|
data: Prisma.MediaAssetUpdateManyMutationInput,
|
||||||
|
): Promise<MediaAsset> {
|
||||||
|
const result = await prisma.mediaAsset.updateMany({ where: { id: assetId, spaceId }, data });
|
||||||
|
if (result.count !== 1) throw new IsolationError();
|
||||||
|
return mapMediaAsset(await requireAsset(prisma, spaceId, assetId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Sources
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function createSource(spaceId: string, input: NewMediaSource): Promise<MediaSource> {
|
||||||
|
const row = await prisma.mediaSource.create({
|
||||||
|
data: {
|
||||||
|
spaceId,
|
||||||
|
kind: input.kind,
|
||||||
|
label: input.label,
|
||||||
|
ownerUserId: input.ownerUserId,
|
||||||
|
config: input.config as Prisma.InputJsonObject,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return mapMediaSource(row, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listSources(spaceId: string): Promise<MediaSource[]> {
|
||||||
|
const rows = await prisma.mediaSource.findMany({
|
||||||
|
where: { spaceId },
|
||||||
|
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||||
|
include: { _count: { select: { assets: true } } },
|
||||||
|
});
|
||||||
|
return rows.map((row) => mapMediaSource(row, row._count.assets));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSource(spaceId: string, sourceId: string): Promise<MediaSourceRecord> {
|
||||||
|
const row = await prisma.mediaSource.findFirst({
|
||||||
|
where: { id: sourceId, spaceId },
|
||||||
|
include: { _count: { select: { assets: true } } },
|
||||||
|
});
|
||||||
|
if (!row) throw new IsolationError();
|
||||||
|
return sourceRecord(row, row._count.assets);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateSourceSync(
|
||||||
|
spaceId: string,
|
||||||
|
sourceId: string,
|
||||||
|
patch: MediaSourceSyncPatch,
|
||||||
|
): Promise<MediaSourceRecord> {
|
||||||
|
const result = await prisma.mediaSource.updateMany({
|
||||||
|
where: { id: sourceId, spaceId },
|
||||||
|
data: {
|
||||||
|
...(patch.status !== undefined ? { status: patch.status } : {}),
|
||||||
|
...(patch.lastSyncCursor !== undefined ? { lastSyncCursor: patch.lastSyncCursor } : {}),
|
||||||
|
...(patch.lastSyncedAt !== undefined ? { lastSyncedAt: patch.lastSyncedAt } : {}),
|
||||||
|
...(patch.error !== undefined ? { error: patch.error } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (result.count !== 1) throw new IsolationError();
|
||||||
|
return getSource(spaceId, sourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes the source and, by cascade, its assets and everything derived from them. */
|
||||||
|
async function removeSource(spaceId: string, sourceId: string): Promise<void> {
|
||||||
|
const result = await prisma.mediaSource.deleteMany({ where: { id: sourceId, spaceId } });
|
||||||
|
if (result.count !== 1) throw new IsolationError();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Worker entry point: jobs carry only a record id. Returns the owning space so every
|
||||||
|
* further call stays scoped, or null when the record no longer exists.
|
||||||
|
*/
|
||||||
|
async function mediaSpaceOf(kind: MediaSpaceLookup, id: string): Promise<string | null> {
|
||||||
|
const select = { spaceId: true } as const;
|
||||||
|
const row =
|
||||||
|
kind === "source"
|
||||||
|
? await prisma.mediaSource.findUnique({ where: { id }, select })
|
||||||
|
: kind === "asset"
|
||||||
|
? await prisma.mediaAsset.findUnique({ where: { id }, select })
|
||||||
|
: kind === "render"
|
||||||
|
? await prisma.mediaRender.findUnique({ where: { id }, select })
|
||||||
|
: await prisma.mediaPublication.findUnique({ where: { id }, select });
|
||||||
|
return row?.spaceId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Assets
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index one page of source items keyed by (sourceId, externalId). An item whose
|
||||||
|
* revision matches the stored one is unchanged; syncs of the same source are serialized.
|
||||||
|
*/
|
||||||
|
async function upsertSourceItems(
|
||||||
|
spaceId: string,
|
||||||
|
sourceId: string,
|
||||||
|
items: MediaSourceItem[],
|
||||||
|
): Promise<UpsertSourceItemsResult> {
|
||||||
|
const unique = new Map<string, MediaSourceItem>();
|
||||||
|
for (const item of items) unique.set(item.externalId, item);
|
||||||
|
return prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
const source = await tx.mediaSource.findFirst({
|
||||||
|
where: { id: sourceId, spaceId },
|
||||||
|
select: { kind: true },
|
||||||
|
});
|
||||||
|
if (!source) throw new IsolationError();
|
||||||
|
await lockMediaKey(tx, `source:${sourceId}`);
|
||||||
|
const storage = (CLIENT_PUSHED_MEDIA_SOURCES as readonly string[]).includes(source.kind)
|
||||||
|
? "remote"
|
||||||
|
: "referenced";
|
||||||
|
const existing = await tx.mediaAsset.findMany({
|
||||||
|
where: { sourceId, externalId: { in: [...unique.keys()] } },
|
||||||
|
select: { id: true, externalId: true, revision: true },
|
||||||
|
});
|
||||||
|
const byExternalId = new Map(existing.map((row) => [row.externalId, row]));
|
||||||
|
const toCreate: MediaSourceItem[] = [];
|
||||||
|
const changed = new Map<string, string>();
|
||||||
|
let unchanged = 0;
|
||||||
|
for (const item of unique.values()) {
|
||||||
|
const current = byExternalId.get(item.externalId);
|
||||||
|
if (!current) {
|
||||||
|
toCreate.push(item);
|
||||||
|
} else if (current.revision === item.revision) {
|
||||||
|
unchanged += 1;
|
||||||
|
} else {
|
||||||
|
await tx.mediaAsset.update({ where: { id: current.id }, data: itemData(item) });
|
||||||
|
changed.set(item.externalId, current.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const updated = changed.size;
|
||||||
|
if (toCreate.length > 0) {
|
||||||
|
const created = await tx.mediaAsset.createManyAndReturn({
|
||||||
|
data: toCreate.map((item) => ({
|
||||||
|
...itemData(item),
|
||||||
|
spaceId,
|
||||||
|
sourceId,
|
||||||
|
externalId: item.externalId,
|
||||||
|
storage,
|
||||||
|
ingestState: storage === "remote" ? "awaiting_upload" : "indexed",
|
||||||
|
})),
|
||||||
|
select: { id: true, externalId: true },
|
||||||
|
});
|
||||||
|
for (const row of created) changed.set(row.externalId, row.id);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
created: toCreate.length,
|
||||||
|
updated,
|
||||||
|
unchanged,
|
||||||
|
changedAssetIds: [...unique.keys()].flatMap((externalId) => {
|
||||||
|
const id = changed.get(externalId);
|
||||||
|
return id ? [id] : [];
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyset-paginated search, newest capture first, undated assets last. The cursor is
|
||||||
|
* the (capturedAt, id) of the previous page's last row, so inserts never shift pages.
|
||||||
|
*/
|
||||||
|
async function searchAssets(
|
||||||
|
spaceId: string,
|
||||||
|
query: MediaAssetSearch,
|
||||||
|
): Promise<MediaPage<MediaAsset>> {
|
||||||
|
const limit = pageLimit(query.limit);
|
||||||
|
const cursor = query.cursor ? decodeKeysetCursor(query.cursor) : null;
|
||||||
|
const conditions: Prisma.Sql[] = [Prisma.sql`a."spaceId" = ${spaceId}`];
|
||||||
|
if (query.sourceId) conditions.push(Prisma.sql`a."sourceId" = ${query.sourceId}`);
|
||||||
|
if (query.kinds && query.kinds.length > 0) {
|
||||||
|
conditions.push(Prisma.sql`a.kind IN (${Prisma.join(query.kinds)})`);
|
||||||
|
}
|
||||||
|
if (query.from) conditions.push(Prisma.sql`a."capturedAt" >= ${new Date(query.from)}`);
|
||||||
|
if (query.to) conditions.push(Prisma.sql`a."capturedAt" <= ${new Date(query.to)}`);
|
||||||
|
if (query.favoritesOnly) conditions.push(Prisma.sql`a."isFavorite" = TRUE`);
|
||||||
|
if (query.minQuality !== undefined) {
|
||||||
|
conditions.push(Prisma.sql`a."qualityScore" >= ${query.minQuality}::float8`);
|
||||||
|
}
|
||||||
|
if (query.near) conditions.push(nearSql(query.near));
|
||||||
|
if (query.personId) {
|
||||||
|
conditions.push(Prisma.sql`EXISTS (
|
||||||
|
SELECT 1 FROM media_face_tracks ft
|
||||||
|
WHERE ft."assetId" = a.id AND ft."spaceId" = ${spaceId} AND ft."personId" = ${query.personId}
|
||||||
|
)`);
|
||||||
|
}
|
||||||
|
if (query.collectionId) {
|
||||||
|
conditions.push(Prisma.sql`EXISTS (
|
||||||
|
SELECT 1 FROM media_collection_items ci
|
||||||
|
JOIN media_collections c ON c.id = ci."collectionId"
|
||||||
|
WHERE ci."assetId" = a.id AND c.id = ${query.collectionId} AND c."spaceId" = ${spaceId}
|
||||||
|
)`);
|
||||||
|
}
|
||||||
|
const text = query.text?.trim();
|
||||||
|
if (text) conditions.push(textSql(spaceId, text));
|
||||||
|
conditions.push(keysetAfterSql(cursor, Prisma.sql`a."capturedAt"`, Prisma.sql`a.id`));
|
||||||
|
|
||||||
|
const keys = await prisma.$queryRaw<Array<{ id: string }>>(Prisma.sql`
|
||||||
|
SELECT a.id FROM media_assets a
|
||||||
|
WHERE ${Prisma.join(conditions, " AND ")}
|
||||||
|
ORDER BY a."capturedAt" DESC NULLS LAST, a.id DESC
|
||||||
|
LIMIT ${limit + 1}
|
||||||
|
`);
|
||||||
|
const rows = await prisma.mediaAsset.findMany({
|
||||||
|
where: { spaceId, id: { in: keys.map((key) => key.id) } },
|
||||||
|
});
|
||||||
|
const byId = new Map(rows.map((row) => [row.id, row]));
|
||||||
|
const ordered = keys.flatMap((key) => {
|
||||||
|
const row = byId.get(key.id);
|
||||||
|
return row ? [row] : [];
|
||||||
|
});
|
||||||
|
return toPage(ordered, limit, (row) => ({ at: row.capturedAt, id: row.id }), mapMediaAsset);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAsset(spaceId: string, assetId: string): Promise<MediaAssetDetail> {
|
||||||
|
const asset = await requireAsset(prisma, spaceId, assetId);
|
||||||
|
const [segments, faceTracks, transcript] = await Promise.all([
|
||||||
|
prisma.mediaSegment.findMany({
|
||||||
|
where: { assetId, spaceId },
|
||||||
|
orderBy: [{ startMs: "asc" }, { id: "asc" }],
|
||||||
|
}),
|
||||||
|
prisma.mediaFaceTrack.findMany({
|
||||||
|
where: { assetId, spaceId },
|
||||||
|
orderBy: [{ startMs: "asc" }, { id: "asc" }],
|
||||||
|
}),
|
||||||
|
prisma.mediaAnalysis.findFirst({
|
||||||
|
where: { assetId, spaceId, kind: "transcript" },
|
||||||
|
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||||
|
select: { data: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
asset: mapMediaAsset(asset),
|
||||||
|
segments: segments.map(mapMediaSegment),
|
||||||
|
faceTracks: faceTracks.map(mapMediaFaceTrack),
|
||||||
|
transcript: transcriptText(transcript?.data ?? null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full row for workers: storage path, hashes, derived file paths. */
|
||||||
|
async function getAssetRecord(spaceId: string, assetId: string): Promise<MediaAssetRow> {
|
||||||
|
return requireAsset(prisma, spaceId, assetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listAssetRecords(spaceId: string, assetIds: string[]): Promise<MediaAssetRow[]> {
|
||||||
|
if (assetIds.length === 0) return [];
|
||||||
|
return prisma.mediaAsset.findMany({
|
||||||
|
where: { spaceId, id: { in: [...new Set(assetIds)] } },
|
||||||
|
orderBy: [{ capturedAt: { sort: "asc", nulls: "last" } }, { id: "asc" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every asset of the space in capture order, with the fields clustering reads. */
|
||||||
|
async function listAssetsForClustering(spaceId: string) {
|
||||||
|
return prisma.mediaAsset.findMany({
|
||||||
|
where: { spaceId },
|
||||||
|
orderBy: [{ capturedAt: { sort: "asc", nulls: "last" } }, { id: "asc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
kind: true,
|
||||||
|
capturedAt: true,
|
||||||
|
durationMs: true,
|
||||||
|
gpsLat: true,
|
||||||
|
gpsLon: true,
|
||||||
|
deviceModel: true,
|
||||||
|
albumNames: true,
|
||||||
|
contentHash: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomic ingest state change: only applies when the asset is currently in one of
|
||||||
|
* `from`, so two workers cannot both take the same step. Returns whether it applied.
|
||||||
|
*/
|
||||||
|
async function transitionIngestState(
|
||||||
|
spaceId: string,
|
||||||
|
assetId: string,
|
||||||
|
transition: { from: MediaIngestState[]; to: MediaIngestState; error?: string | null },
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await prisma.mediaAsset.updateMany({
|
||||||
|
where: { id: assetId, spaceId, ingestState: { in: transition.from } },
|
||||||
|
data: {
|
||||||
|
ingestState: transition.to,
|
||||||
|
ingestError: transition.to === "failed" ? (transition.error ?? null) : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (result.count === 1) return true;
|
||||||
|
await requireAsset(prisma, spaceId, assetId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordProxy(
|
||||||
|
spaceId: string,
|
||||||
|
assetId: string,
|
||||||
|
proxy: MediaProxyResult,
|
||||||
|
): Promise<MediaAsset> {
|
||||||
|
return updateAsset(spaceId, assetId, {
|
||||||
|
contentHash: proxy.contentHash,
|
||||||
|
proxyPath: proxy.proxyPath,
|
||||||
|
thumbPath: proxy.thumbPath,
|
||||||
|
...(proxy.spritePath !== undefined ? { spritePath: proxy.spritePath } : {}),
|
||||||
|
...(proxy.durationMs !== undefined ? { durationMs: proxy.durationMs } : {}),
|
||||||
|
...(proxy.width !== undefined ? { width: proxy.width } : {}),
|
||||||
|
...(proxy.height !== undefined ? { height: proxy.height } : {}),
|
||||||
|
...(proxy.fps !== undefined ? { fps: proxy.fps } : {}),
|
||||||
|
...(proxy.codec !== undefined ? { codec: proxy.codec } : {}),
|
||||||
|
...(proxy.orientation !== undefined ? { orientation: proxy.orientation } : {}),
|
||||||
|
ingestState: "proxied",
|
||||||
|
ingestError: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordAnalysisVersion(
|
||||||
|
spaceId: string,
|
||||||
|
assetId: string,
|
||||||
|
analysisVersion: number,
|
||||||
|
qualityScore?: number | null,
|
||||||
|
): Promise<MediaAsset> {
|
||||||
|
return updateAsset(spaceId, assetId, {
|
||||||
|
analysisVersion,
|
||||||
|
...(qualityScore !== undefined ? { qualityScore } : {}),
|
||||||
|
ingestState: "analyzed",
|
||||||
|
ingestError: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remote assets move to awaiting_upload; returns the ids that now wait for bytes. */
|
||||||
|
async function requestUpload(spaceId: string, assetIds: string[]): Promise<string[]> {
|
||||||
|
const ids = [...new Set(assetIds)];
|
||||||
|
const owned = await prisma.mediaAsset.count({ where: { spaceId, id: { in: ids } } });
|
||||||
|
if (owned !== ids.length) throw new IsolationError();
|
||||||
|
await prisma.mediaAsset.updateMany({
|
||||||
|
where: {
|
||||||
|
spaceId,
|
||||||
|
id: { in: ids },
|
||||||
|
storage: "remote",
|
||||||
|
ingestState: { in: ["indexed", "failed"] },
|
||||||
|
},
|
||||||
|
data: { ingestState: "awaiting_upload", ingestError: null },
|
||||||
|
});
|
||||||
|
const awaiting = await prisma.mediaAsset.findMany({
|
||||||
|
where: { spaceId, id: { in: ids }, ingestState: "awaiting_upload" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const set = new Set(awaiting.map((row) => row.id));
|
||||||
|
return ids.filter((id) => set.has(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assetsNeedingWork(
|
||||||
|
spaceId: string,
|
||||||
|
state: MediaIngestState,
|
||||||
|
limit: number,
|
||||||
|
): Promise<MediaAssetRow[]> {
|
||||||
|
return prisma.mediaAsset.findMany({
|
||||||
|
where: { spaceId, ingestState: state },
|
||||||
|
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||||
|
take: pageLimit(limit),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Analyses
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Insert or replace the result for (asset, kind, modelVersion); other versions stay. */
|
||||||
|
async function upsertAnalysis(
|
||||||
|
spaceId: string,
|
||||||
|
assetId: string,
|
||||||
|
analysis: NewMediaAnalysis,
|
||||||
|
): Promise<MediaAnalysisRow> {
|
||||||
|
await requireAsset(prisma, spaceId, assetId);
|
||||||
|
const data = {
|
||||||
|
modelId: analysis.modelId,
|
||||||
|
data: analysis.data ?? Prisma.DbNull,
|
||||||
|
dataPath: analysis.dataPath ?? null,
|
||||||
|
vectors: analysis.vectors ? toBytes(analysis.vectors) : null,
|
||||||
|
};
|
||||||
|
return prisma.mediaAnalysis.upsert({
|
||||||
|
where: {
|
||||||
|
assetId_kind_modelVersion: {
|
||||||
|
assetId,
|
||||||
|
kind: analysis.kind,
|
||||||
|
modelVersion: analysis.modelVersion,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
...data,
|
||||||
|
spaceId,
|
||||||
|
assetId,
|
||||||
|
kind: analysis.kind,
|
||||||
|
modelVersion: analysis.modelVersion,
|
||||||
|
},
|
||||||
|
update: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function latestAnalysis(
|
||||||
|
spaceId: string,
|
||||||
|
assetId: string,
|
||||||
|
kind: MediaAnalysisKind,
|
||||||
|
): Promise<MediaAnalysisRow | null> {
|
||||||
|
await requireAsset(prisma, spaceId, assetId);
|
||||||
|
return prisma.mediaAnalysis.findFirst({
|
||||||
|
where: { spaceId, assetId, kind },
|
||||||
|
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Latest analysis of one kind per asset across the space (e.g. embeddings for clustering). */
|
||||||
|
async function latestAnalysesByKind(
|
||||||
|
spaceId: string,
|
||||||
|
kind: MediaAnalysisKind,
|
||||||
|
): Promise<Map<string, MediaAnalysisRow>> {
|
||||||
|
const rows = await prisma.mediaAnalysis.findMany({
|
||||||
|
where: { spaceId, kind },
|
||||||
|
orderBy: [{ assetId: "asc" }, { createdAt: "desc" }, { id: "desc" }],
|
||||||
|
});
|
||||||
|
const latest = new Map<string, MediaAnalysisRow>();
|
||||||
|
for (const row of rows) if (!latest.has(row.assetId)) latest.set(row.assetId, row);
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Segments
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Replace every segment of the asset with the result of one analysis version. */
|
||||||
|
async function replaceSegments(
|
||||||
|
spaceId: string,
|
||||||
|
assetId: string,
|
||||||
|
analysisVersion: number,
|
||||||
|
segments: NewMediaSegment[],
|
||||||
|
): Promise<number> {
|
||||||
|
for (const segment of segments) {
|
||||||
|
if (segment.endMs <= segment.startMs || segment.startMs < 0) {
|
||||||
|
throw new InvalidMediaInputError("Segment range is empty");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
await requireAsset(tx, spaceId, assetId);
|
||||||
|
await lockMediaKey(tx, `segments:${assetId}`);
|
||||||
|
await tx.mediaSegment.deleteMany({ where: { assetId, spaceId } });
|
||||||
|
const created = await tx.mediaSegment.createMany({
|
||||||
|
data: segments.map((segment) => ({
|
||||||
|
spaceId,
|
||||||
|
assetId,
|
||||||
|
analysisVersion,
|
||||||
|
kind: segment.kind,
|
||||||
|
startMs: segment.startMs,
|
||||||
|
endMs: segment.endMs,
|
||||||
|
scores: segment.scores ?? {},
|
||||||
|
teaserScore: segment.teaserScore ?? null,
|
||||||
|
transcriptExcerpt: segment.transcriptExcerpt ?? null,
|
||||||
|
labels: segment.labels ?? [],
|
||||||
|
paidOnly: segment.paidOnly ?? false,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
return created.count;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listSegments(spaceId: string, assetIds: string[]): Promise<MediaSegment[]> {
|
||||||
|
if (assetIds.length === 0) return [];
|
||||||
|
const rows = await prisma.mediaSegment.findMany({
|
||||||
|
where: { spaceId, assetId: { in: [...new Set(assetIds)] } },
|
||||||
|
orderBy: [{ assetId: "asc" }, { startMs: "asc" }, { id: "asc" }],
|
||||||
|
});
|
||||||
|
return rows.map(mapMediaSegment);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All or nothing: any id outside the space fails the whole call. */
|
||||||
|
async function setSegmentsPaidOnly(
|
||||||
|
spaceId: string,
|
||||||
|
segmentIds: string[],
|
||||||
|
paidOnly: boolean,
|
||||||
|
): Promise<number> {
|
||||||
|
const ids = [...new Set(segmentIds)];
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const result = await tx.mediaSegment.updateMany({
|
||||||
|
where: { spaceId, id: { in: ids } },
|
||||||
|
data: { paidOnly },
|
||||||
|
});
|
||||||
|
if (result.count !== ids.length) throw new IsolationError();
|
||||||
|
return result.count;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
createSource,
|
||||||
|
listSources,
|
||||||
|
getSource,
|
||||||
|
updateSourceSync,
|
||||||
|
removeSource,
|
||||||
|
mediaSpaceOf,
|
||||||
|
upsertSourceItems,
|
||||||
|
searchAssets,
|
||||||
|
getAsset,
|
||||||
|
getAssetRecord,
|
||||||
|
listAssetRecords,
|
||||||
|
listAssetsForClustering,
|
||||||
|
transitionIngestState,
|
||||||
|
recordProxy,
|
||||||
|
recordAnalysisVersion,
|
||||||
|
requestUpload,
|
||||||
|
assetsNeedingWork,
|
||||||
|
upsertAnalysis,
|
||||||
|
latestAnalysis,
|
||||||
|
latestAnalysesByKind,
|
||||||
|
replaceSegments,
|
||||||
|
listSegments,
|
||||||
|
setSegmentsPaidOnly,
|
||||||
|
};
|
||||||
|
}
|
||||||
598
packages/db/src/media-people.ts
Normal file
598
packages/db/src/media-people.ts
Normal file
@ -0,0 +1,598 @@
|
|||||||
|
import type {
|
||||||
|
AnonymizeMode,
|
||||||
|
ConsentInput,
|
||||||
|
MarkTreatment,
|
||||||
|
MediaFaceTrack,
|
||||||
|
MediaMark,
|
||||||
|
MediaMarkKind,
|
||||||
|
MediaMarkPrompt,
|
||||||
|
MediaPerson,
|
||||||
|
} from "@rakazo/contracts";
|
||||||
|
import type { MediaMark as MediaMarkRow, PrismaClient } from "./client.js";
|
||||||
|
import type { MediaDb } from "./media-shared.js";
|
||||||
|
import {
|
||||||
|
assertAssetsInSpace,
|
||||||
|
InvalidMediaInputError,
|
||||||
|
lockMediaKey,
|
||||||
|
mapMediaFaceTrack,
|
||||||
|
mapMediaMark,
|
||||||
|
mapMediaPerson,
|
||||||
|
toBytes,
|
||||||
|
} from "./media-shared.js";
|
||||||
|
import { IsolationError } from "./scope.js";
|
||||||
|
|
||||||
|
export interface MediaPersonSuggestion {
|
||||||
|
/** Float32 mean identity embedding. Biometric: stays in this database. */
|
||||||
|
centroid: Uint8Array;
|
||||||
|
trackIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaPersonPatch {
|
||||||
|
displayName?: string;
|
||||||
|
isSelf?: boolean;
|
||||||
|
confirmed?: boolean;
|
||||||
|
anonymizeByDefault?: AnonymizeMode;
|
||||||
|
syntheticSeed?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who records consent. A user id is mandatory by type and checked against the space,
|
||||||
|
* so a caller without an authenticated user (an agent tool) cannot produce one.
|
||||||
|
*/
|
||||||
|
export interface ConsentRecorder {
|
||||||
|
spaceId: string;
|
||||||
|
recordedByUserId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemovedPerson {
|
||||||
|
deletedFaceTracks: number;
|
||||||
|
deletedMarks: number;
|
||||||
|
/** Files the caller must delete too: box keyframes and mark masks. */
|
||||||
|
keyframesPaths: string[];
|
||||||
|
maskDirs: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewMediaFaceTrack {
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
confidenceMin: number;
|
||||||
|
keyframesPath: string;
|
||||||
|
/** Float32 identity embedding. Biometric: stays in this database. */
|
||||||
|
embedding: Uint8Array | null;
|
||||||
|
personId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaFaceTrackRecord extends MediaFaceTrack {
|
||||||
|
keyframesPath: string;
|
||||||
|
modelVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaFaceEmbedding {
|
||||||
|
trackId: string;
|
||||||
|
assetId: string;
|
||||||
|
personId: string | null;
|
||||||
|
embedding: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewMediaMark {
|
||||||
|
kind: MediaMarkKind;
|
||||||
|
label: string | null;
|
||||||
|
personId: string | null;
|
||||||
|
assetId: string;
|
||||||
|
prompt: MediaMarkPrompt;
|
||||||
|
treatment: MarkTreatment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaMarkPatch {
|
||||||
|
treatment?: MarkTreatment;
|
||||||
|
label?: string | null;
|
||||||
|
personId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaMarkResult {
|
||||||
|
qualityScore: number | null;
|
||||||
|
suggestedFallback: MarkTreatment | null;
|
||||||
|
maskDir: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaMarkRecord extends MediaMark {
|
||||||
|
maskDir: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markRecord(row: MediaMarkRow): MediaMarkRecord {
|
||||||
|
return { ...mapMediaMark(row), maskDir: row.maskDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** People, face tracks and identifying marks. Biometric data stays in this database. */
|
||||||
|
export function createMediaPeopleRepos(prisma: PrismaClient) {
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// People
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const personInclude = {
|
||||||
|
_count: { select: { faceTracks: true } },
|
||||||
|
faceTracks: {
|
||||||
|
orderBy: [{ confidenceMin: "desc" as const }, { id: "asc" as const }],
|
||||||
|
take: 1,
|
||||||
|
select: { assetId: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function personDto(db: MediaDb, spaceId: string, personId: string): Promise<MediaPerson> {
|
||||||
|
const row = await db.mediaPerson.findFirst({
|
||||||
|
where: { id: personId, spaceId },
|
||||||
|
include: personInclude,
|
||||||
|
});
|
||||||
|
if (!row) throw new IsolationError();
|
||||||
|
return mapMediaPerson(row, row._count.faceTracks, row.faceTracks[0]?.assetId ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listPeople(spaceId: string): Promise<MediaPerson[]> {
|
||||||
|
const rows = await prisma.mediaPerson.findMany({
|
||||||
|
where: { spaceId },
|
||||||
|
orderBy: [{ isSelf: "desc" }, { createdAt: "asc" }, { id: "asc" }],
|
||||||
|
include: personInclude,
|
||||||
|
});
|
||||||
|
return rows.map((row) =>
|
||||||
|
mapMediaPerson(row, row._count.faceTracks, row.faceTracks[0]?.assetId ?? null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPerson(spaceId: string, personId: string): Promise<MediaPerson> {
|
||||||
|
return personDto(prisma, spaceId, personId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** People with consent fields and seed, for the render gate. */
|
||||||
|
async function listPersonRecords(spaceId: string, personIds?: string[]) {
|
||||||
|
return prisma.mediaPerson.findMany({
|
||||||
|
where: { spaceId, ...(personIds ? { id: { in: personIds } } : {}) },
|
||||||
|
omit: { centroid: true },
|
||||||
|
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Identity centroids for matching new face tracks to known people. Local only. */
|
||||||
|
async function personCentroids(
|
||||||
|
spaceId: string,
|
||||||
|
): Promise<Array<{ personId: string; centroid: Uint8Array }>> {
|
||||||
|
const rows = await prisma.mediaPerson.findMany({
|
||||||
|
where: { spaceId, centroid: { not: null } },
|
||||||
|
select: { id: true, centroid: true },
|
||||||
|
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||||
|
});
|
||||||
|
return rows.map((row) => ({ personId: row.id, centroid: row.centroid as Uint8Array }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn face clusters into unconfirmed people ("Person N"). Only tracks that have no
|
||||||
|
* person yet are assigned, so a user's earlier assignment is never overwritten.
|
||||||
|
*/
|
||||||
|
async function createPersonSuggestions(
|
||||||
|
spaceId: string,
|
||||||
|
suggestions: MediaPersonSuggestion[],
|
||||||
|
): Promise<MediaPerson[]> {
|
||||||
|
if (suggestions.length === 0) return [];
|
||||||
|
const ids = await prisma.$transaction(async (tx) => {
|
||||||
|
await lockMediaKey(tx, `people:${spaceId}`);
|
||||||
|
const trackIds = [...new Set(suggestions.flatMap((suggestion) => suggestion.trackIds))];
|
||||||
|
const owned = await tx.mediaFaceTrack.count({ where: { spaceId, id: { in: trackIds } } });
|
||||||
|
if (owned !== trackIds.length) throw new IsolationError();
|
||||||
|
let number = await tx.mediaPerson.count({ where: { spaceId } });
|
||||||
|
const created: string[] = [];
|
||||||
|
for (const suggestion of suggestions) {
|
||||||
|
number += 1;
|
||||||
|
const person = await tx.mediaPerson.create({
|
||||||
|
data: {
|
||||||
|
spaceId,
|
||||||
|
displayName: `Person ${number}`,
|
||||||
|
centroid: toBytes(suggestion.centroid),
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
await tx.mediaFaceTrack.updateMany({
|
||||||
|
where: { spaceId, id: { in: suggestion.trackIds }, personId: null },
|
||||||
|
data: { personId: person.id },
|
||||||
|
});
|
||||||
|
created.push(person.id);
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
return Promise.all(ids.map((id) => personDto(prisma, spaceId, id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePerson(
|
||||||
|
spaceId: string,
|
||||||
|
personId: string,
|
||||||
|
patch: MediaPersonPatch,
|
||||||
|
): Promise<MediaPerson> {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
const result = await tx.mediaPerson.updateMany({
|
||||||
|
where: { id: personId, spaceId },
|
||||||
|
data: {
|
||||||
|
...(patch.displayName !== undefined ? { displayName: patch.displayName } : {}),
|
||||||
|
...(patch.isSelf !== undefined ? { isSelf: patch.isSelf } : {}),
|
||||||
|
...(patch.confirmed !== undefined ? { confirmed: patch.confirmed } : {}),
|
||||||
|
...(patch.anonymizeByDefault !== undefined
|
||||||
|
? { anonymizeByDefault: patch.anonymizeByDefault }
|
||||||
|
: {}),
|
||||||
|
...(patch.syntheticSeed !== undefined ? { syntheticSeed: patch.syntheticSeed } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (result.count !== 1) throw new IsolationError();
|
||||||
|
// One person per space is the user themself.
|
||||||
|
if (patch.isSelf) {
|
||||||
|
await tx.mediaPerson.updateMany({
|
||||||
|
where: { spaceId, isSelf: true, id: { not: personId } },
|
||||||
|
data: { isSelf: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return personDto(prisma, spaceId, personId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge into the first person: tracks and marks move, the others are deleted along
|
||||||
|
* with their centroids. The target keeps its own consent record.
|
||||||
|
*/
|
||||||
|
async function mergePeople(spaceId: string, personIds: string[]): Promise<MediaPerson> {
|
||||||
|
const ids = [...new Set(personIds)];
|
||||||
|
if (ids.length < 2) throw new InvalidMediaInputError("Merge needs two people");
|
||||||
|
const [targetId, ...others] = ids as [string, ...string[]];
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
const owned = await tx.mediaPerson.count({ where: { spaceId, id: { in: ids } } });
|
||||||
|
if (owned !== ids.length) throw new IsolationError();
|
||||||
|
await tx.mediaFaceTrack.updateMany({
|
||||||
|
where: { spaceId, personId: { in: others } },
|
||||||
|
data: { personId: targetId },
|
||||||
|
});
|
||||||
|
await tx.mediaMark.updateMany({
|
||||||
|
where: { spaceId, personId: { in: others } },
|
||||||
|
data: { personId: targetId },
|
||||||
|
});
|
||||||
|
await tx.mediaPerson.updateMany({
|
||||||
|
where: { spaceId, id: { in: others } },
|
||||||
|
data: { centroid: null },
|
||||||
|
});
|
||||||
|
await tx.mediaPerson.deleteMany({ where: { spaceId, id: { in: others } } });
|
||||||
|
await tx.mediaPerson.update({ where: { id: targetId }, data: { confirmed: true } });
|
||||||
|
});
|
||||||
|
return personDto(prisma, spaceId, targetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assignTrack(
|
||||||
|
spaceId: string,
|
||||||
|
trackId: string,
|
||||||
|
personId: string | null,
|
||||||
|
): Promise<MediaFaceTrack> {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
if (personId) {
|
||||||
|
const person = await tx.mediaPerson.findFirst({
|
||||||
|
where: { id: personId, spaceId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!person) throw new IsolationError();
|
||||||
|
}
|
||||||
|
const result = await tx.mediaFaceTrack.updateMany({
|
||||||
|
where: { id: trackId, spaceId },
|
||||||
|
data: { personId },
|
||||||
|
});
|
||||||
|
if (result.count !== 1) throw new IsolationError();
|
||||||
|
const row = await tx.mediaFaceTrack.findUniqueOrThrow({ where: { id: trackId } });
|
||||||
|
return mapMediaFaceTrack(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record consent and age verification for a person. Only a signed-in member of the
|
||||||
|
* space can do this; the recorder is stored with the record.
|
||||||
|
*/
|
||||||
|
async function recordConsent(
|
||||||
|
recorder: ConsentRecorder,
|
||||||
|
input: ConsentInput,
|
||||||
|
): Promise<MediaPerson> {
|
||||||
|
const { spaceId, recordedByUserId } = recorder;
|
||||||
|
if (typeof recordedByUserId !== "string" || recordedByUserId.trim() === "") {
|
||||||
|
throw new InvalidMediaInputError("Consent needs the recording user");
|
||||||
|
}
|
||||||
|
if (input.ageVerificationMethod !== "none" && !input.ageVerifiedAt) {
|
||||||
|
throw new InvalidMediaInputError("Age verification needs a date");
|
||||||
|
}
|
||||||
|
const member = await prisma.spaceMember.findFirst({
|
||||||
|
where: { spaceId, userId: recordedByUserId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!member) throw new IsolationError();
|
||||||
|
const result = await prisma.mediaPerson.updateMany({
|
||||||
|
where: { id: input.personId, spaceId },
|
||||||
|
data: {
|
||||||
|
consentArtifactId: input.consentArtifactId,
|
||||||
|
consentRecordedAt: new Date(input.consentRecordedAt),
|
||||||
|
consentRecordedBy: recordedByUserId,
|
||||||
|
ageVerificationMethod: input.ageVerificationMethod,
|
||||||
|
ageVerifiedAt:
|
||||||
|
input.ageVerificationMethod === "none" || !input.ageVerifiedAt
|
||||||
|
? null
|
||||||
|
: new Date(input.ageVerifiedAt),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (result.count !== 1) throw new IsolationError();
|
||||||
|
return personDto(prisma, spaceId, input.personId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a person and every biometric record tied to them: the centroid, the
|
||||||
|
* embeddings of their face tracks, the tracks themselves, and person-bound marks.
|
||||||
|
* Embeddings are blanked before the rows go so no copy survives in the same
|
||||||
|
* transaction's row versions; the caller deletes the returned files.
|
||||||
|
*/
|
||||||
|
async function removePersonCompletely(spaceId: string, personId: string): Promise<RemovedPerson> {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const person = await tx.mediaPerson.findFirst({
|
||||||
|
where: { id: personId, spaceId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!person) throw new IsolationError();
|
||||||
|
const tracks = await tx.mediaFaceTrack.findMany({
|
||||||
|
where: { spaceId, personId },
|
||||||
|
select: { keyframesPath: true },
|
||||||
|
});
|
||||||
|
const marks = await tx.mediaMark.findMany({
|
||||||
|
where: { spaceId, personId },
|
||||||
|
select: { maskDir: true },
|
||||||
|
});
|
||||||
|
await tx.mediaFaceTrack.updateMany({
|
||||||
|
where: { spaceId, personId },
|
||||||
|
data: { embedding: null },
|
||||||
|
});
|
||||||
|
const deletedTracks = await tx.mediaFaceTrack.deleteMany({ where: { spaceId, personId } });
|
||||||
|
const deletedMarks = await tx.mediaMark.deleteMany({ where: { spaceId, personId } });
|
||||||
|
await tx.mediaPerson.update({ where: { id: personId }, data: { centroid: null } });
|
||||||
|
await tx.mediaPerson.delete({ where: { id: personId } });
|
||||||
|
return {
|
||||||
|
deletedFaceTracks: deletedTracks.count,
|
||||||
|
deletedMarks: deletedMarks.count,
|
||||||
|
keyframesPaths: tracks.map((track) => track.keyframesPath),
|
||||||
|
maskDirs: marks.flatMap((mark) => (mark.maskDir ? [mark.maskDir] : [])),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Face tracks
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Replace the face tracks of one asset with a fresh tracking run. */
|
||||||
|
async function replaceFaceTracks(
|
||||||
|
spaceId: string,
|
||||||
|
assetId: string,
|
||||||
|
modelVersion: string,
|
||||||
|
tracks: NewMediaFaceTrack[],
|
||||||
|
): Promise<MediaFaceTrack[]> {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const asset = await tx.mediaAsset.findFirst({
|
||||||
|
where: { id: assetId, spaceId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!asset) throw new IsolationError();
|
||||||
|
const personIds = [
|
||||||
|
...new Set(tracks.flatMap((track) => (track.personId ? [track.personId] : []))),
|
||||||
|
];
|
||||||
|
if (personIds.length > 0) {
|
||||||
|
const owned = await tx.mediaPerson.count({ where: { spaceId, id: { in: personIds } } });
|
||||||
|
if (owned !== personIds.length) throw new IsolationError();
|
||||||
|
}
|
||||||
|
await lockMediaKey(tx, `faces:${assetId}`);
|
||||||
|
await tx.mediaFaceTrack.updateMany({
|
||||||
|
where: { spaceId, assetId },
|
||||||
|
data: { embedding: null },
|
||||||
|
});
|
||||||
|
await tx.mediaFaceTrack.deleteMany({ where: { spaceId, assetId } });
|
||||||
|
const rows = await tx.mediaFaceTrack.createManyAndReturn({
|
||||||
|
data: tracks.map((track) => ({
|
||||||
|
spaceId,
|
||||||
|
assetId,
|
||||||
|
modelVersion,
|
||||||
|
startMs: track.startMs,
|
||||||
|
endMs: track.endMs,
|
||||||
|
confidenceMin: track.confidenceMin,
|
||||||
|
keyframesPath: track.keyframesPath,
|
||||||
|
embedding: track.embedding ? toBytes(track.embedding) : null,
|
||||||
|
personId: track.personId ?? null,
|
||||||
|
})),
|
||||||
|
omit: { embedding: true },
|
||||||
|
});
|
||||||
|
return rows
|
||||||
|
.sort((a, b) => a.startMs - b.startMs || a.id.localeCompare(b.id))
|
||||||
|
.map(mapMediaFaceTrack);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listFaceTracks(
|
||||||
|
spaceId: string,
|
||||||
|
filter: { assetId?: string; personId?: string } = {},
|
||||||
|
): Promise<MediaFaceTrack[]> {
|
||||||
|
const rows = await prisma.mediaFaceTrack.findMany({
|
||||||
|
where: { spaceId, ...filter },
|
||||||
|
omit: { embedding: true },
|
||||||
|
orderBy: [{ assetId: "asc" }, { startMs: "asc" }, { id: "asc" }],
|
||||||
|
});
|
||||||
|
return rows.map(mapMediaFaceTrack);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tracks with their keyframe files, for anonymization and the gate. */
|
||||||
|
async function faceTrackRecords(
|
||||||
|
spaceId: string,
|
||||||
|
assetIds: string[],
|
||||||
|
): Promise<MediaFaceTrackRecord[]> {
|
||||||
|
if (assetIds.length === 0) return [];
|
||||||
|
const rows = await prisma.mediaFaceTrack.findMany({
|
||||||
|
where: { spaceId, assetId: { in: [...new Set(assetIds)] } },
|
||||||
|
omit: { embedding: true },
|
||||||
|
orderBy: [{ assetId: "asc" }, { startMs: "asc" }, { id: "asc" }],
|
||||||
|
});
|
||||||
|
return rows.map((row) => ({
|
||||||
|
...mapMediaFaceTrack(row),
|
||||||
|
keyframesPath: row.keyframesPath,
|
||||||
|
modelVersion: row.modelVersion,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Identity embeddings for local person clustering. Never leaves the host. */
|
||||||
|
async function faceTrackEmbeddings(
|
||||||
|
spaceId: string,
|
||||||
|
options: { unassignedOnly?: boolean } = {},
|
||||||
|
): Promise<MediaFaceEmbedding[]> {
|
||||||
|
const rows = await prisma.mediaFaceTrack.findMany({
|
||||||
|
where: {
|
||||||
|
spaceId,
|
||||||
|
embedding: { not: null },
|
||||||
|
...(options.unassignedOnly ? { personId: null } : {}),
|
||||||
|
},
|
||||||
|
select: { id: true, assetId: true, personId: true, embedding: true },
|
||||||
|
orderBy: [{ assetId: "asc" }, { startMs: "asc" }, { id: "asc" }],
|
||||||
|
});
|
||||||
|
return rows.map((row) => ({
|
||||||
|
trackId: row.id,
|
||||||
|
assetId: row.assetId,
|
||||||
|
personId: row.personId,
|
||||||
|
embedding: row.embedding as Uint8Array,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Marks
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function assertPerson(
|
||||||
|
db: MediaDb,
|
||||||
|
spaceId: string,
|
||||||
|
personId: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!personId) return;
|
||||||
|
const person = await db.mediaPerson.findFirst({
|
||||||
|
where: { id: personId, spaceId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!person) throw new IsolationError();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireMark(spaceId: string, markId: string): Promise<MediaMarkRow> {
|
||||||
|
const row = await prisma.mediaMark.findFirst({ where: { id: markId, spaceId } });
|
||||||
|
if (!row) throw new IsolationError();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createMark(spaceId: string, input: NewMediaMark): Promise<MediaMark> {
|
||||||
|
await assertAssetsInSpace(prisma, spaceId, [input.assetId]);
|
||||||
|
await assertPerson(prisma, spaceId, input.personId);
|
||||||
|
const row = await prisma.mediaMark.create({
|
||||||
|
data: {
|
||||||
|
spaceId,
|
||||||
|
kind: input.kind,
|
||||||
|
label: input.label,
|
||||||
|
personId: input.personId,
|
||||||
|
assetId: input.assetId,
|
||||||
|
prompt: input.prompt,
|
||||||
|
treatment: input.treatment,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return mapMediaMark(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listMarks(
|
||||||
|
spaceId: string,
|
||||||
|
filter: { personId?: string; assetId?: string } = {},
|
||||||
|
): Promise<MediaMark[]> {
|
||||||
|
const rows = await prisma.mediaMark.findMany({
|
||||||
|
where: { spaceId, ...filter },
|
||||||
|
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||||
|
});
|
||||||
|
return rows.map(mapMediaMark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks that apply to an asset: those drawn on it plus the standing marks of every
|
||||||
|
* person seen in it (person rules hold across all projects).
|
||||||
|
*/
|
||||||
|
async function marksForAsset(spaceId: string, assetId: string): Promise<MediaMarkRecord[]> {
|
||||||
|
const rows = await prisma.mediaMark.findMany({
|
||||||
|
where: {
|
||||||
|
spaceId,
|
||||||
|
OR: [{ assetId }, { person: { faceTracks: { some: { assetId, spaceId } } } }],
|
||||||
|
},
|
||||||
|
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||||
|
});
|
||||||
|
return rows.map(markRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMark(spaceId: string, markId: string): Promise<MediaMarkRecord> {
|
||||||
|
return markRecord(await requireMark(spaceId, markId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateMark(
|
||||||
|
spaceId: string,
|
||||||
|
markId: string,
|
||||||
|
patch: MediaMarkPatch,
|
||||||
|
): Promise<MediaMark> {
|
||||||
|
if (patch.personId !== undefined) await assertPerson(prisma, spaceId, patch.personId);
|
||||||
|
const result = await prisma.mediaMark.updateMany({
|
||||||
|
where: { id: markId, spaceId },
|
||||||
|
data: {
|
||||||
|
...(patch.treatment !== undefined ? { treatment: patch.treatment } : {}),
|
||||||
|
...(patch.label !== undefined ? { label: patch.label } : {}),
|
||||||
|
...(patch.personId !== undefined ? { personId: patch.personId } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (result.count !== 1) throw new IsolationError();
|
||||||
|
return mapMediaMark(await requireMark(spaceId, markId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Store the segmentation/treatment outcome: mask, quality, and any fallback proposal. */
|
||||||
|
async function recordMarkResult(
|
||||||
|
spaceId: string,
|
||||||
|
markId: string,
|
||||||
|
outcome: MediaMarkResult,
|
||||||
|
): Promise<MediaMarkRecord> {
|
||||||
|
const result = await prisma.mediaMark.updateMany({
|
||||||
|
where: { id: markId, spaceId },
|
||||||
|
data: {
|
||||||
|
qualityScore: outcome.qualityScore,
|
||||||
|
suggestedFallback: outcome.suggestedFallback,
|
||||||
|
maskDir: outcome.maskDir,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (result.count !== 1) throw new IsolationError();
|
||||||
|
return getMark(spaceId, markId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeMark(spaceId: string, markId: string): Promise<{ maskDir: string | null }> {
|
||||||
|
const row = await requireMark(spaceId, markId);
|
||||||
|
const result = await prisma.mediaMark.deleteMany({ where: { id: markId, spaceId } });
|
||||||
|
if (result.count !== 1) throw new IsolationError();
|
||||||
|
return { maskDir: row.maskDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
listPeople,
|
||||||
|
getPerson,
|
||||||
|
listPersonRecords,
|
||||||
|
personCentroids,
|
||||||
|
createPersonSuggestions,
|
||||||
|
updatePerson,
|
||||||
|
mergePeople,
|
||||||
|
assignTrack,
|
||||||
|
recordConsent,
|
||||||
|
removePersonCompletely,
|
||||||
|
replaceFaceTracks,
|
||||||
|
listFaceTracks,
|
||||||
|
faceTrackRecords,
|
||||||
|
faceTrackEmbeddings,
|
||||||
|
createMark,
|
||||||
|
listMarks,
|
||||||
|
marksForAsset,
|
||||||
|
getMark,
|
||||||
|
updateMark,
|
||||||
|
recordMarkResult,
|
||||||
|
removeMark,
|
||||||
|
};
|
||||||
|
}
|
||||||
462
packages/db/src/media-production.postgres.test.ts
Normal file
462
packages/db/src/media-production.postgres.test.ts
Normal file
@ -0,0 +1,462 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import type { MediaRepos, NewMediaPublication } from "./media.js";
|
||||||
|
import { createMediaRepos } from "./media.js";
|
||||||
|
import type { MediaTestDb } from "./media-fixtures.js";
|
||||||
|
import {
|
||||||
|
BASE_TIME,
|
||||||
|
openMediaTestDb,
|
||||||
|
sourceItem,
|
||||||
|
testBrandKit,
|
||||||
|
testEdl,
|
||||||
|
testTeaserWeights,
|
||||||
|
} from "./media-fixtures.js";
|
||||||
|
import { InvalidMediaInputError, MediaStateConflictError } from "./media-shared.js";
|
||||||
|
import { IsolationError } from "./scope.js";
|
||||||
|
|
||||||
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
const describePostgres =
|
||||||
|
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
|
||||||
|
|
||||||
|
const PROFILE = "example-short-vertical";
|
||||||
|
|
||||||
|
describePostgres("media production, publishing and metrics (PostgreSQL)", () => {
|
||||||
|
let db: MediaTestDb;
|
||||||
|
let media: MediaRepos;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
db = openMediaTestDb(databaseUrl!);
|
||||||
|
media = createMediaRepos(db.prisma);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await db?.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function newProject() {
|
||||||
|
const { spaceId, userId } = await db.newUser();
|
||||||
|
const source = await media.createSource(spaceId, {
|
||||||
|
kind: "emulator",
|
||||||
|
label: "Emulator",
|
||||||
|
ownerUserId: userId,
|
||||||
|
config: {},
|
||||||
|
});
|
||||||
|
const { changedAssetIds } = await media.upsertSourceItems(spaceId, source.id, [sourceItem(1)]);
|
||||||
|
const assetId = changedAssetIds[0] as string;
|
||||||
|
const project = await media.createProject(spaceId, userId, {
|
||||||
|
title: "Launch",
|
||||||
|
goal: "teaser",
|
||||||
|
profileIds: [PROFILE, "example-paid-video"],
|
||||||
|
assetIds: [assetId],
|
||||||
|
});
|
||||||
|
return { spaceId, userId, assetId, projectId: project.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doneRender(spaceId: string, userId: string, projectId: string, assetId: string) {
|
||||||
|
const plan = await media.createPlanVersion(spaceId, {
|
||||||
|
projectId,
|
||||||
|
profileId: PROFILE,
|
||||||
|
edl: testEdl(assetId),
|
||||||
|
createdBy: "user",
|
||||||
|
rationale: null,
|
||||||
|
});
|
||||||
|
const render = await media.createRender(spaceId, {
|
||||||
|
planId: plan.id,
|
||||||
|
quality: "final",
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
await media.claimRender(spaceId, render.id);
|
||||||
|
await media.updateRender(spaceId, render.id, { status: "done", progress: 1 });
|
||||||
|
return render.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicationInput = (renderId: string, content: string): NewMediaPublication => ({
|
||||||
|
renderId,
|
||||||
|
destination: "bundle",
|
||||||
|
targetUrl: "https://example.com/offer",
|
||||||
|
trackingUrl: `https://example.com/offer?utm_content=${content}`,
|
||||||
|
utm: { source: "shorts", medium: "video", campaign: "launch", content },
|
||||||
|
title: null,
|
||||||
|
caption: null,
|
||||||
|
tags: [],
|
||||||
|
thumbnailVariant: `thumb-${content}`,
|
||||||
|
scheduledAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates and stores brand kits", async () => {
|
||||||
|
const { spaceId } = await db.newUser();
|
||||||
|
await expect(
|
||||||
|
media.upsertBrandKit(spaceId, { kit: { ...testBrandKit, colors: { primary: "red" } } }),
|
||||||
|
).rejects.toBeInstanceOf(InvalidMediaInputError);
|
||||||
|
const kit = await media.upsertBrandKit(spaceId, { kit: testBrandKit });
|
||||||
|
const renamed = await media.upsertBrandKit(spaceId, {
|
||||||
|
id: kit.id,
|
||||||
|
kit: { ...testBrandKit, name: "Renamed" },
|
||||||
|
});
|
||||||
|
expect(renamed).toMatchObject({ id: kit.id, kit: { name: "Renamed" } });
|
||||||
|
expect(await media.listBrandKits(spaceId)).toHaveLength(1);
|
||||||
|
await media.removeBrandKit(spaceId, kit.id);
|
||||||
|
expect(await media.listBrandKits(spaceId)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates, pages, updates and removes projects", async () => {
|
||||||
|
const { spaceId, userId } = await db.newUser();
|
||||||
|
await expect(
|
||||||
|
media.createProject(spaceId, userId, {
|
||||||
|
title: "Bad",
|
||||||
|
goal: "teaser",
|
||||||
|
profileIds: [PROFILE],
|
||||||
|
assetIds: ["missing"],
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(IsolationError);
|
||||||
|
const created = [];
|
||||||
|
for (let index = 0; index < 5; index++) {
|
||||||
|
created.push(
|
||||||
|
await media.createProject(spaceId, userId, {
|
||||||
|
title: `Project ${index}`,
|
||||||
|
goal: "highlight",
|
||||||
|
profileIds: [PROFILE, PROFILE],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(created[0]?.profileIds).toEqual([PROFILE]);
|
||||||
|
const seen: string[] = [];
|
||||||
|
let cursor: string | null = null;
|
||||||
|
do {
|
||||||
|
const page = await media.listProjects(spaceId, { cursor, limit: 2 });
|
||||||
|
seen.push(...page.items.map((project) => project.title));
|
||||||
|
cursor = page.nextCursor;
|
||||||
|
} while (cursor);
|
||||||
|
expect(seen).toEqual(["Project 4", "Project 3", "Project 2", "Project 1", "Project 0"]);
|
||||||
|
|
||||||
|
const updated = await media.updateProject(spaceId, created[0]?.id as string, {
|
||||||
|
title: "Renamed",
|
||||||
|
status: "review",
|
||||||
|
});
|
||||||
|
expect(updated).toMatchObject({ title: "Renamed", status: "review" });
|
||||||
|
expect((await media.listProjects(spaceId, { limit: 1 })).items[0]?.id).toBe(created[0]?.id);
|
||||||
|
await media.removeProject(spaceId, created[0]?.id as string);
|
||||||
|
await expect(media.getProject(spaceId, created[0]?.id as string)).rejects.toBeInstanceOf(
|
||||||
|
IsolationError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("numbers concurrent plan versions without gaps or duplicates", async () => {
|
||||||
|
const { spaceId, assetId, projectId } = await newProject();
|
||||||
|
await expect(
|
||||||
|
media.createPlanVersion(spaceId, {
|
||||||
|
projectId,
|
||||||
|
profileId: PROFILE,
|
||||||
|
edl: { edlVersion: 1 },
|
||||||
|
createdBy: "agent",
|
||||||
|
rationale: null,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(InvalidMediaInputError);
|
||||||
|
await expect(
|
||||||
|
media.createPlanVersion(spaceId, {
|
||||||
|
projectId,
|
||||||
|
profileId: "example-paid-video",
|
||||||
|
edl: testEdl(assetId),
|
||||||
|
createdBy: "agent",
|
||||||
|
rationale: null,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(InvalidMediaInputError);
|
||||||
|
|
||||||
|
const plans = await Promise.all(
|
||||||
|
Array.from({ length: 12 }, (_, index) =>
|
||||||
|
media.createPlanVersion(spaceId, {
|
||||||
|
projectId,
|
||||||
|
profileId: PROFILE,
|
||||||
|
edl: testEdl(assetId),
|
||||||
|
createdBy: index % 2 ? "agent" : "user",
|
||||||
|
rationale: `try ${index}`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(plans.map((plan) => plan.version).sort((a, b) => a - b)).toEqual(
|
||||||
|
Array.from({ length: 12 }, (_, index) => index + 1),
|
||||||
|
);
|
||||||
|
await media.createPlanVersion(spaceId, {
|
||||||
|
projectId,
|
||||||
|
profileId: "example-paid-video",
|
||||||
|
edl: testEdl(assetId, "example-paid-video"),
|
||||||
|
createdBy: "agent",
|
||||||
|
rationale: null,
|
||||||
|
});
|
||||||
|
const latest = await media.latestPlans(spaceId, projectId);
|
||||||
|
expect(latest.map((plan) => [plan.profileId, plan.version])).toEqual([
|
||||||
|
["example-paid-video", 1],
|
||||||
|
[PROFILE, 12],
|
||||||
|
]);
|
||||||
|
expect((await media.latestPlan(spaceId, projectId, PROFILE))?.version).toBe(12);
|
||||||
|
expect(await media.listPlanVersions(spaceId, projectId, PROFILE)).toHaveLength(12);
|
||||||
|
expect(latest[1]?.edl.tracks.video[0]?.speed).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets exactly one worker claim a render and never revives a canceled one", async () => {
|
||||||
|
const { spaceId, userId, assetId, projectId } = await newProject();
|
||||||
|
const plan = await media.createPlanVersion(spaceId, {
|
||||||
|
projectId,
|
||||||
|
profileId: PROFILE,
|
||||||
|
edl: testEdl(assetId),
|
||||||
|
createdBy: "agent",
|
||||||
|
rationale: null,
|
||||||
|
});
|
||||||
|
const render = await media.createRender(spaceId, {
|
||||||
|
planId: plan.id,
|
||||||
|
quality: "draft",
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
expect(render).toMatchObject({ status: "queued", projectId, profileId: PROFILE, progress: 0 });
|
||||||
|
|
||||||
|
const claims = await Promise.all(
|
||||||
|
Array.from({ length: 5 }, () => media.claimRender(spaceId, render.id)),
|
||||||
|
);
|
||||||
|
expect(claims.filter((claim) => claim !== null)).toHaveLength(1);
|
||||||
|
|
||||||
|
const progressed = await media.updateRender(spaceId, render.id, { progress: 0.4 });
|
||||||
|
expect(progressed?.progress).toBe(0.4);
|
||||||
|
await expect(
|
||||||
|
media.updateRender(spaceId, render.id, { autoFixRound: 4 }),
|
||||||
|
).rejects.toBeInstanceOf(InvalidMediaInputError);
|
||||||
|
const canceled = await media.cancelRender(spaceId, render.id);
|
||||||
|
expect(canceled.status).toBe("canceled");
|
||||||
|
expect(await media.updateRender(spaceId, render.id, { status: "done" })).toBeNull();
|
||||||
|
expect((await media.getRender(spaceId, render.id)).status).toBe("canceled");
|
||||||
|
await expect(media.cancelRender(spaceId, render.id)).rejects.toBeInstanceOf(
|
||||||
|
MediaStateConflictError,
|
||||||
|
);
|
||||||
|
|
||||||
|
const second = await media.createRender(spaceId, {
|
||||||
|
planId: plan.id,
|
||||||
|
quality: "final",
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
await media.claimRender(spaceId, second.id);
|
||||||
|
const gated = await media.updateRender(spaceId, second.id, {
|
||||||
|
status: "blocked",
|
||||||
|
progress: 1,
|
||||||
|
autoFixRound: 3,
|
||||||
|
outputs: [
|
||||||
|
{
|
||||||
|
kind: "video",
|
||||||
|
artifactId: null,
|
||||||
|
fileName: "out.mp4",
|
||||||
|
sizeBytes: 1234,
|
||||||
|
path: "renders/x/out.mp4",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
gateReport: {
|
||||||
|
profileId: PROFILE,
|
||||||
|
passed: false,
|
||||||
|
checkedAt: new Date(BASE_TIME).toISOString(),
|
||||||
|
sampledFrames: 10,
|
||||||
|
issues: [{ check: "consent", severity: "block", code: "missing_consent", message: "x" }],
|
||||||
|
autoFixRound: 3,
|
||||||
|
},
|
||||||
|
ffprobe: {
|
||||||
|
durationMs: 5000,
|
||||||
|
width: 1080,
|
||||||
|
height: 1920,
|
||||||
|
fps: 30,
|
||||||
|
videoCodec: "h264",
|
||||||
|
audioCodec: "aac",
|
||||||
|
sizeBytes: 1234,
|
||||||
|
integratedLufs: -14,
|
||||||
|
truePeakDb: -1,
|
||||||
|
tags: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(gated).toMatchObject({
|
||||||
|
status: "blocked",
|
||||||
|
outputs: [{ kind: "video", fileName: "out.mp4", sizeBytes: 1234, artifactId: null }],
|
||||||
|
gateReport: { passed: false },
|
||||||
|
ffprobe: { width: 1080 },
|
||||||
|
});
|
||||||
|
expect(gated?.outputs[0]).not.toHaveProperty("path");
|
||||||
|
const record = await media.getRenderRecord(spaceId, second.id);
|
||||||
|
expect(record.outputs).toEqual([expect.objectContaining({ path: "renders/x/out.mp4" })]);
|
||||||
|
expect(record.autoFixRound).toBe(3);
|
||||||
|
await expect(media.cancelRender(spaceId, second.id)).rejects.toBeInstanceOf(
|
||||||
|
MediaStateConflictError,
|
||||||
|
);
|
||||||
|
expect((await media.listRenders(spaceId, projectId)).map((r) => r.id)).toEqual([
|
||||||
|
second.id,
|
||||||
|
render.id,
|
||||||
|
]);
|
||||||
|
const detail = await media.getProject(spaceId, projectId);
|
||||||
|
expect(detail.plans).toHaveLength(1);
|
||||||
|
expect(detail.renders).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes only finished renders and moves publications through their states", async () => {
|
||||||
|
const { spaceId, userId, assetId, projectId } = await newProject();
|
||||||
|
const plan = await media.createPlanVersion(spaceId, {
|
||||||
|
projectId,
|
||||||
|
profileId: PROFILE,
|
||||||
|
edl: testEdl(assetId),
|
||||||
|
createdBy: "agent",
|
||||||
|
rationale: null,
|
||||||
|
});
|
||||||
|
const queued = await media.createRender(spaceId, {
|
||||||
|
planId: plan.id,
|
||||||
|
quality: "final",
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
media.createPublication(spaceId, userId, publicationInput(queued.id, "a")),
|
||||||
|
).rejects.toBeInstanceOf(MediaStateConflictError);
|
||||||
|
const renderId = await doneRender(spaceId, userId, projectId, assetId);
|
||||||
|
|
||||||
|
const draft = await media.createPublication(spaceId, userId, publicationInput(renderId, "a"));
|
||||||
|
expect(draft).toMatchObject({ status: "draft", profileId: PROFILE, utm: { content: "a" } });
|
||||||
|
const scheduledAt = new Date(BASE_TIME + 86_400_000);
|
||||||
|
const scheduled = await media.schedulePublication(spaceId, draft.id, scheduledAt);
|
||||||
|
expect(scheduled).toMatchObject({
|
||||||
|
status: "scheduled",
|
||||||
|
scheduledAt: scheduledAt.toISOString(),
|
||||||
|
});
|
||||||
|
const bundled = await media.markPublicationBundled(spaceId, draft.id, "artifact-bundle");
|
||||||
|
expect(bundled).toMatchObject({ status: "bundled", bundleArtifactId: "artifact-bundle" });
|
||||||
|
const published = await media.markPublicationPublished(
|
||||||
|
spaceId,
|
||||||
|
draft.id,
|
||||||
|
"https://video.example.com/v/1",
|
||||||
|
);
|
||||||
|
expect(published.status).toBe("published");
|
||||||
|
expect(published.publishedAt).not.toBeNull();
|
||||||
|
await expect(media.cancelPublication(spaceId, draft.id)).rejects.toBeInstanceOf(
|
||||||
|
MediaStateConflictError,
|
||||||
|
);
|
||||||
|
|
||||||
|
const other = await media.createPublication(spaceId, userId, {
|
||||||
|
...publicationInput(renderId, "b"),
|
||||||
|
scheduledAt,
|
||||||
|
});
|
||||||
|
expect(other.status).toBe("scheduled");
|
||||||
|
expect((await media.markPublicationFailed(spaceId, other.id)).status).toBe("failed");
|
||||||
|
expect((await media.cancelPublication(spaceId, other.id)).status).toBe("canceled");
|
||||||
|
|
||||||
|
const page1 = await media.listPublications(spaceId, { projectId, limit: 1 });
|
||||||
|
expect(page1.items.map((item) => item.id)).toEqual([other.id]);
|
||||||
|
const page2 = await media.listPublications(spaceId, { projectId, cursor: page1.nextCursor });
|
||||||
|
expect(page2.items.map((item) => item.id)).toEqual([draft.id]);
|
||||||
|
expect(page2.nextCursor).toBeNull();
|
||||||
|
const elsewhere = await newProject();
|
||||||
|
expect(
|
||||||
|
(await media.listPublications(spaceId, { projectId: elsewhere.projectId })).items,
|
||||||
|
).toEqual([]);
|
||||||
|
|
||||||
|
const byContent = await media.resolvePublicationKeys(spaceId, "utm_content", ["a", "b", "z"]);
|
||||||
|
expect(Object.fromEntries(byContent)).toEqual({ a: draft.id, b: other.id });
|
||||||
|
const byUrl = await media.resolvePublicationKeys(spaceId, "external_url", [
|
||||||
|
"https://video.example.com/v/1",
|
||||||
|
]);
|
||||||
|
expect(byUrl.get("https://video.example.com/v/1")).toBe(draft.id);
|
||||||
|
const byTracking = await media.resolvePublicationKeys(spaceId, "tracking_url", [
|
||||||
|
"https://example.com/offer?utm_content=b",
|
||||||
|
]);
|
||||||
|
expect([...byTracking.values()]).toEqual([other.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts the funnel from the latest observation per source without double counting", async () => {
|
||||||
|
const { spaceId, userId, assetId, projectId } = await newProject();
|
||||||
|
const renderId = await doneRender(spaceId, userId, projectId, assetId);
|
||||||
|
const a = await media.createPublication(spaceId, userId, publicationInput(renderId, "a"));
|
||||||
|
const b = await media.createPublication(spaceId, userId, publicationInput(renderId, "b"));
|
||||||
|
const day = (n: number) => new Date(BASE_TIME + n * 86_400_000).toISOString();
|
||||||
|
const report = [
|
||||||
|
{
|
||||||
|
publicationId: a.id,
|
||||||
|
observedAt: day(1),
|
||||||
|
views: 100,
|
||||||
|
clicks: 10,
|
||||||
|
conversions: 1,
|
||||||
|
revenueCents: 500,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
publicationId: a.id,
|
||||||
|
observedAt: day(2),
|
||||||
|
views: 250,
|
||||||
|
clicks: 30,
|
||||||
|
conversions: 3,
|
||||||
|
revenueCents: 1500,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
publicationId: b.id,
|
||||||
|
observedAt: day(2),
|
||||||
|
views: 40,
|
||||||
|
clicks: 4,
|
||||||
|
conversions: 0,
|
||||||
|
revenueCents: 0,
|
||||||
|
},
|
||||||
|
].map((row) => ({ ...row, currency: "EUR", source: "csv_import" as const }));
|
||||||
|
|
||||||
|
expect(await media.recordMetrics(spaceId, report)).toBe(3);
|
||||||
|
expect(await media.recordMetrics(spaceId, report)).toBe(3);
|
||||||
|
await media.recordMetric(spaceId, {
|
||||||
|
publicationId: a.id,
|
||||||
|
observedAt: day(1),
|
||||||
|
views: 0,
|
||||||
|
clicks: 0,
|
||||||
|
conversions: 2,
|
||||||
|
revenueCents: 1000,
|
||||||
|
currency: "EUR",
|
||||||
|
source: "manual",
|
||||||
|
});
|
||||||
|
expect(await media.listMetrics(spaceId, a.id)).toHaveLength(3);
|
||||||
|
|
||||||
|
const rows = await media.funnel(spaceId, projectId);
|
||||||
|
const byId = new Map(rows.map((row) => [row.publicationId, row]));
|
||||||
|
expect(byId.get(a.id)).toEqual({
|
||||||
|
publicationId: a.id,
|
||||||
|
projectId,
|
||||||
|
profileId: PROFILE,
|
||||||
|
variant: "a",
|
||||||
|
thumbnailVariant: "thumb-a",
|
||||||
|
views: 250,
|
||||||
|
clicks: 30,
|
||||||
|
conversions: 5,
|
||||||
|
revenueCents: 2500,
|
||||||
|
currency: "EUR",
|
||||||
|
});
|
||||||
|
expect(byId.get(b.id)).toMatchObject({ views: 40, clicks: 4, conversions: 0 });
|
||||||
|
expect(await media.funnel(spaceId)).toHaveLength(2);
|
||||||
|
expect(await media.publicationsWithMetricsCount(spaceId)).toBe(2);
|
||||||
|
|
||||||
|
await expect(media.recordMetric(spaceId, { ...report[0]!, views: -1 })).rejects.toBeInstanceOf(
|
||||||
|
InvalidMediaInputError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves CSV mappings and the teaser model", async () => {
|
||||||
|
const { spaceId } = await db.newUser();
|
||||||
|
const mapping = {
|
||||||
|
name: "Platform export",
|
||||||
|
delimiter: ";" as const,
|
||||||
|
columns: { publication: "Link", observedAt: "Date", views: "Views", revenue: "Revenue" },
|
||||||
|
publicationKey: "tracking_url" as const,
|
||||||
|
revenueUnit: "major" as const,
|
||||||
|
defaultCurrency: "USD",
|
||||||
|
};
|
||||||
|
const saved = await media.saveMetricMapping(spaceId, mapping);
|
||||||
|
expect(saved).toMatchObject({ ...mapping, id: expect.any(String) });
|
||||||
|
const again = await media.saveMetricMapping(spaceId, { ...mapping, delimiter: "," });
|
||||||
|
expect(again.id).toBe(saved.id);
|
||||||
|
const other = await media.saveMetricMapping(spaceId, { ...mapping, name: "Other" });
|
||||||
|
await expect(
|
||||||
|
media.saveMetricMapping(spaceId, { ...mapping, id: other.id, name: "Platform export" }),
|
||||||
|
).rejects.toBeInstanceOf(InvalidMediaInputError);
|
||||||
|
expect((await media.listMetricMappings(spaceId)).map((m) => m.name)).toEqual([
|
||||||
|
"Other",
|
||||||
|
"Platform export",
|
||||||
|
]);
|
||||||
|
expect((await media.getMetricMapping(spaceId, saved.id as string)).delimiter).toBe(",");
|
||||||
|
|
||||||
|
expect(await media.getTeaserModel(spaceId)).toBeNull();
|
||||||
|
const model = await media.putTeaserModel(spaceId, testTeaserWeights, 21);
|
||||||
|
expect(model).toMatchObject({ sampleCount: 21, weights: testTeaserWeights });
|
||||||
|
expect((await media.getTeaserModel(spaceId))?.sampleCount).toBe(21);
|
||||||
|
await media.resetTeaserModel(spaceId);
|
||||||
|
expect(await media.getTeaserModel(spaceId)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
490
packages/db/src/media-projects.ts
Normal file
490
packages/db/src/media-projects.ts
Normal file
@ -0,0 +1,490 @@
|
|||||||
|
import type {
|
||||||
|
BrandKitRecord,
|
||||||
|
FfprobeReport,
|
||||||
|
GateReport,
|
||||||
|
MediaEditPlan,
|
||||||
|
MediaProject,
|
||||||
|
MediaProjectGoal,
|
||||||
|
MediaProjectStatus,
|
||||||
|
MediaRender,
|
||||||
|
MediaRenderOutput,
|
||||||
|
MediaRenderQuality,
|
||||||
|
MediaRenderStatus,
|
||||||
|
} from "@rakazo/contracts";
|
||||||
|
import { BrandKitSchema, EdlSchema, MAX_AUTO_FIX_ROUNDS } from "@rakazo/contracts";
|
||||||
|
import type {
|
||||||
|
MediaProject as MediaProjectRow,
|
||||||
|
MediaRender as MediaRenderRow,
|
||||||
|
PrismaClient,
|
||||||
|
} from "./client.js";
|
||||||
|
import { Prisma } from "./client.js";
|
||||||
|
import type { MediaDb, MediaPage, MediaPageInput } from "./media-shared.js";
|
||||||
|
import {
|
||||||
|
decodeKeysetCursor,
|
||||||
|
InvalidMediaInputError,
|
||||||
|
isUniqueViolation,
|
||||||
|
MediaStateConflictError,
|
||||||
|
mapBrandKit,
|
||||||
|
mapMediaEditPlan,
|
||||||
|
mapMediaProject,
|
||||||
|
mapMediaRender,
|
||||||
|
pageLimit,
|
||||||
|
toPage,
|
||||||
|
} from "./media-shared.js";
|
||||||
|
import { IsolationError } from "./scope.js";
|
||||||
|
|
||||||
|
export interface NewMediaProject {
|
||||||
|
title: string;
|
||||||
|
goal: MediaProjectGoal;
|
||||||
|
profileIds: string[];
|
||||||
|
collectionIds?: string[];
|
||||||
|
assetIds?: string[];
|
||||||
|
brandKitId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaProjectPatch {
|
||||||
|
title?: string;
|
||||||
|
goal?: MediaProjectGoal;
|
||||||
|
collectionIds?: string[];
|
||||||
|
assetIds?: string[];
|
||||||
|
profileIds?: string[];
|
||||||
|
brandKitId?: string | null;
|
||||||
|
status?: MediaProjectStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaProjectRecord extends MediaProject {
|
||||||
|
assetIds: string[];
|
||||||
|
createdBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewMediaEditPlan {
|
||||||
|
projectId: string;
|
||||||
|
profileId: string;
|
||||||
|
/** Validated against EdlSchema here; semantic validation happens before (validateEdl). */
|
||||||
|
edl: unknown;
|
||||||
|
createdBy: "agent" | "user";
|
||||||
|
rationale: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fields a render worker reports; outputs may carry internal paths next to the public fields. */
|
||||||
|
export interface MediaRenderPatch {
|
||||||
|
status?: MediaRenderStatus;
|
||||||
|
progress?: number;
|
||||||
|
outputs?: Array<MediaRenderOutput & Record<string, unknown>>;
|
||||||
|
gateReport?: GateReport | null;
|
||||||
|
ffprobe?: FfprobeReport | null;
|
||||||
|
autoFixRound?: number;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Statuses a render can still be changed or canceled from. */
|
||||||
|
export const ACTIVE_RENDER_STATUSES: MediaRenderStatus[] = ["queued", "rendering", "gating"];
|
||||||
|
|
||||||
|
const PLAN_VERSION_ATTEMPTS = 50;
|
||||||
|
|
||||||
|
function projectRecord(row: MediaProjectRow): MediaProjectRecord {
|
||||||
|
return { ...mapMediaProject(row), assetIds: row.assetIds, createdBy: row.createdBy };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Brand kits, projects, versioned edit plans and renders. */
|
||||||
|
export function createMediaProjectRepos(prisma: PrismaClient) {
|
||||||
|
async function assertOwned(
|
||||||
|
db: MediaDb,
|
||||||
|
spaceId: string,
|
||||||
|
model: "mediaBrandKit" | "mediaCollection" | "mediaAsset",
|
||||||
|
ids: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const unique = [...new Set(ids)];
|
||||||
|
if (unique.length === 0) return;
|
||||||
|
const where = { spaceId, id: { in: unique } };
|
||||||
|
const owned =
|
||||||
|
model === "mediaBrandKit"
|
||||||
|
? await db.mediaBrandKit.count({ where })
|
||||||
|
: model === "mediaCollection"
|
||||||
|
? await db.mediaCollection.count({ where })
|
||||||
|
: await db.mediaAsset.count({ where });
|
||||||
|
if (owned !== unique.length) throw new IsolationError();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Brand kits
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function upsertBrandKit(
|
||||||
|
spaceId: string,
|
||||||
|
input: { id?: string; kit: unknown },
|
||||||
|
): Promise<BrandKitRecord> {
|
||||||
|
const parsed = BrandKitSchema.safeParse(input.kit);
|
||||||
|
if (!parsed.success) throw new InvalidMediaInputError("Invalid brand kit");
|
||||||
|
const kit = parsed.data as Prisma.InputJsonObject;
|
||||||
|
if (!input.id) {
|
||||||
|
return mapBrandKit(await prisma.mediaBrandKit.create({ data: { spaceId, kit } }));
|
||||||
|
}
|
||||||
|
const result = await prisma.mediaBrandKit.updateMany({
|
||||||
|
where: { id: input.id, spaceId },
|
||||||
|
data: { kit },
|
||||||
|
});
|
||||||
|
if (result.count !== 1) throw new IsolationError();
|
||||||
|
return getBrandKit(spaceId, input.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listBrandKits(spaceId: string): Promise<BrandKitRecord[]> {
|
||||||
|
const rows = await prisma.mediaBrandKit.findMany({
|
||||||
|
where: { spaceId },
|
||||||
|
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
|
||||||
|
});
|
||||||
|
return rows.map(mapBrandKit);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBrandKit(spaceId: string, id: string): Promise<BrandKitRecord> {
|
||||||
|
const row = await prisma.mediaBrandKit.findFirst({ where: { id, spaceId } });
|
||||||
|
if (!row) throw new IsolationError();
|
||||||
|
return mapBrandKit(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeBrandKit(spaceId: string, id: string): Promise<void> {
|
||||||
|
const result = await prisma.mediaBrandKit.deleteMany({ where: { id, spaceId } });
|
||||||
|
if (result.count !== 1) throw new IsolationError();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Projects and plans
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function requireProject(db: MediaDb, spaceId: string, projectId: string) {
|
||||||
|
const row = await db.mediaProject.findFirst({ where: { id: projectId, spaceId } });
|
||||||
|
if (!row) throw new IsolationError();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createProject(
|
||||||
|
spaceId: string,
|
||||||
|
createdBy: string,
|
||||||
|
input: NewMediaProject,
|
||||||
|
): Promise<MediaProject> {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
await assertOwned(tx, spaceId, "mediaBrandKit", input.brandKitId ? [input.brandKitId] : []);
|
||||||
|
await assertOwned(tx, spaceId, "mediaCollection", input.collectionIds ?? []);
|
||||||
|
await assertOwned(tx, spaceId, "mediaAsset", input.assetIds ?? []);
|
||||||
|
const row = await tx.mediaProject.create({
|
||||||
|
data: {
|
||||||
|
spaceId,
|
||||||
|
createdBy,
|
||||||
|
title: input.title,
|
||||||
|
goal: input.goal,
|
||||||
|
profileIds: [...new Set(input.profileIds)],
|
||||||
|
collectionIds: [...new Set(input.collectionIds ?? [])],
|
||||||
|
assetIds: [...new Set(input.assetIds ?? [])],
|
||||||
|
brandKitId: input.brandKitId ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return mapMediaProject(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateProject(
|
||||||
|
spaceId: string,
|
||||||
|
projectId: string,
|
||||||
|
patch: MediaProjectPatch,
|
||||||
|
): Promise<MediaProject> {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
await assertOwned(tx, spaceId, "mediaBrandKit", patch.brandKitId ? [patch.brandKitId] : []);
|
||||||
|
await assertOwned(tx, spaceId, "mediaCollection", patch.collectionIds ?? []);
|
||||||
|
await assertOwned(tx, spaceId, "mediaAsset", patch.assetIds ?? []);
|
||||||
|
const result = await tx.mediaProject.updateMany({
|
||||||
|
where: { id: projectId, spaceId },
|
||||||
|
data: {
|
||||||
|
...(patch.title !== undefined ? { title: patch.title } : {}),
|
||||||
|
...(patch.goal !== undefined ? { goal: patch.goal } : {}),
|
||||||
|
...(patch.status !== undefined ? { status: patch.status } : {}),
|
||||||
|
...(patch.brandKitId !== undefined ? { brandKitId: patch.brandKitId } : {}),
|
||||||
|
...(patch.profileIds !== undefined ? { profileIds: [...new Set(patch.profileIds)] } : {}),
|
||||||
|
...(patch.collectionIds !== undefined
|
||||||
|
? { collectionIds: [...new Set(patch.collectionIds)] }
|
||||||
|
: {}),
|
||||||
|
...(patch.assetIds !== undefined ? { assetIds: [...new Set(patch.assetIds)] } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (result.count !== 1) throw new IsolationError();
|
||||||
|
return mapMediaProject(await requireProject(tx, spaceId, projectId));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Most recently changed first. */
|
||||||
|
async function listProjects(
|
||||||
|
spaceId: string,
|
||||||
|
input: MediaPageInput = {},
|
||||||
|
): Promise<MediaPage<MediaProject>> {
|
||||||
|
const limit = pageLimit(input.limit);
|
||||||
|
const cursor = input.cursor ? decodeKeysetCursor(input.cursor) : null;
|
||||||
|
if (cursor && !cursor.at) throw new InvalidMediaInputError("Invalid cursor");
|
||||||
|
const rows = await prisma.mediaProject.findMany({
|
||||||
|
where: {
|
||||||
|
spaceId,
|
||||||
|
...(cursor?.at
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ updatedAt: { lt: cursor.at } },
|
||||||
|
{ updatedAt: cursor.at, id: { lt: cursor.id } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
|
||||||
|
take: limit + 1,
|
||||||
|
});
|
||||||
|
return toPage(rows, limit, (row) => ({ at: row.updatedAt, id: row.id }), mapMediaProject);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProjectRecord(spaceId: string, projectId: string): Promise<MediaProjectRecord> {
|
||||||
|
return projectRecord(await requireProject(prisma, spaceId, projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project with the latest plan per profile and its renders, newest first. */
|
||||||
|
async function getProject(spaceId: string, projectId: string) {
|
||||||
|
const row = await requireProject(prisma, spaceId, projectId);
|
||||||
|
const [plans, renders] = await Promise.all([
|
||||||
|
latestPlans(spaceId, projectId),
|
||||||
|
listRenders(spaceId, projectId),
|
||||||
|
]);
|
||||||
|
return { project: mapMediaProject(row), plans, renders };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeProject(spaceId: string, projectId: string): Promise<void> {
|
||||||
|
const result = await prisma.mediaProject.deleteMany({ where: { id: projectId, spaceId } });
|
||||||
|
if (result.count !== 1) throw new IsolationError();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store the next plan version for (project, profile). Concurrent writers race on the
|
||||||
|
* unique (projectId, profileId, version); the loser re-reads and takes the next number.
|
||||||
|
*/
|
||||||
|
async function createPlanVersion(
|
||||||
|
spaceId: string,
|
||||||
|
input: NewMediaEditPlan,
|
||||||
|
): Promise<MediaEditPlan> {
|
||||||
|
const parsed = EdlSchema.safeParse(input.edl);
|
||||||
|
if (!parsed.success) throw new InvalidMediaInputError("Invalid edit decision list");
|
||||||
|
if (parsed.data.output.profileId !== input.profileId) {
|
||||||
|
throw new InvalidMediaInputError("Plan profile does not match its EDL");
|
||||||
|
}
|
||||||
|
await requireProject(prisma, spaceId, input.projectId);
|
||||||
|
for (let attempt = 1; ; attempt += 1) {
|
||||||
|
const latest = await prisma.mediaEditPlan.findFirst({
|
||||||
|
where: { projectId: input.projectId, profileId: input.profileId },
|
||||||
|
orderBy: { version: "desc" },
|
||||||
|
select: { version: true },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const row = await prisma.mediaEditPlan.create({
|
||||||
|
data: {
|
||||||
|
spaceId,
|
||||||
|
projectId: input.projectId,
|
||||||
|
profileId: input.profileId,
|
||||||
|
version: (latest?.version ?? 0) + 1,
|
||||||
|
edl: parsed.data as Prisma.InputJsonObject,
|
||||||
|
createdBy: input.createdBy,
|
||||||
|
rationale: input.rationale,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return mapMediaEditPlan(row);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isUniqueViolation(error) || attempt >= PLAN_VERSION_ATTEMPTS) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPlan(spaceId: string, planId: string): Promise<MediaEditPlan> {
|
||||||
|
const row = await prisma.mediaEditPlan.findFirst({ where: { id: planId, spaceId } });
|
||||||
|
if (!row) throw new IsolationError();
|
||||||
|
return mapMediaEditPlan(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function latestPlan(
|
||||||
|
spaceId: string,
|
||||||
|
projectId: string,
|
||||||
|
profileId: string,
|
||||||
|
): Promise<MediaEditPlan | null> {
|
||||||
|
await requireProject(prisma, spaceId, projectId);
|
||||||
|
const row = await prisma.mediaEditPlan.findFirst({
|
||||||
|
where: { spaceId, projectId, profileId },
|
||||||
|
orderBy: { version: "desc" },
|
||||||
|
});
|
||||||
|
return row ? mapMediaEditPlan(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The newest plan of every profile in the project, ordered by profile id. */
|
||||||
|
async function latestPlans(spaceId: string, projectId: string): Promise<MediaEditPlan[]> {
|
||||||
|
const rows = await prisma.mediaEditPlan.findMany({
|
||||||
|
where: { spaceId, projectId },
|
||||||
|
orderBy: [{ profileId: "asc" }, { version: "desc" }],
|
||||||
|
distinct: ["profileId"],
|
||||||
|
});
|
||||||
|
return rows.map(mapMediaEditPlan);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listPlanVersions(
|
||||||
|
spaceId: string,
|
||||||
|
projectId: string,
|
||||||
|
profileId: string,
|
||||||
|
): Promise<MediaEditPlan[]> {
|
||||||
|
await requireProject(prisma, spaceId, projectId);
|
||||||
|
const rows = await prisma.mediaEditPlan.findMany({
|
||||||
|
where: { spaceId, projectId, profileId },
|
||||||
|
orderBy: { version: "desc" },
|
||||||
|
});
|
||||||
|
return rows.map(mapMediaEditPlan);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Renders
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function requireRender(db: MediaDb, spaceId: string, renderId: string) {
|
||||||
|
const row = await db.mediaRender.findFirst({ where: { id: renderId, spaceId } });
|
||||||
|
if (!row) throw new IsolationError();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRender(
|
||||||
|
spaceId: string,
|
||||||
|
input: { planId: string; quality: MediaRenderQuality; createdBy: string },
|
||||||
|
): Promise<MediaRender> {
|
||||||
|
const plan = await prisma.mediaEditPlan.findFirst({
|
||||||
|
where: { id: input.planId, spaceId },
|
||||||
|
select: { id: true, projectId: true, profileId: true },
|
||||||
|
});
|
||||||
|
if (!plan) throw new IsolationError();
|
||||||
|
const row = await prisma.mediaRender.create({
|
||||||
|
data: {
|
||||||
|
spaceId,
|
||||||
|
projectId: plan.projectId,
|
||||||
|
planId: plan.id,
|
||||||
|
profileId: plan.profileId,
|
||||||
|
quality: input.quality,
|
||||||
|
createdBy: input.createdBy,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return mapMediaRender(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRender(spaceId: string, renderId: string): Promise<MediaRender> {
|
||||||
|
return mapMediaRender(await requireRender(prisma, spaceId, renderId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full row, including outputs with internal paths. */
|
||||||
|
async function getRenderRecord(spaceId: string, renderId: string): Promise<MediaRenderRow> {
|
||||||
|
return requireRender(prisma, spaceId, renderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listRenders(spaceId: string, projectId: string): Promise<MediaRender[]> {
|
||||||
|
const rows = await prisma.mediaRender.findMany({
|
||||||
|
where: { spaceId, projectId },
|
||||||
|
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||||
|
});
|
||||||
|
return rows.map(mapMediaRender);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take a queued render for processing. The conditional update makes exactly one
|
||||||
|
* concurrent caller win; the others get null.
|
||||||
|
*/
|
||||||
|
async function claimRender(spaceId: string, renderId: string): Promise<MediaRender | null> {
|
||||||
|
const result = await prisma.mediaRender.updateMany({
|
||||||
|
where: { id: renderId, spaceId, status: "queued" },
|
||||||
|
data: { status: "rendering", progress: 0, error: null },
|
||||||
|
});
|
||||||
|
const row = await requireRender(prisma, spaceId, renderId);
|
||||||
|
return result.count === 1 ? mapMediaRender(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply worker progress or results while the render is in one of `from` (default:
|
||||||
|
* still active). Returns null when the render already left those states, e.g. after
|
||||||
|
* a cancel, so a late worker never overwrites it.
|
||||||
|
*/
|
||||||
|
async function updateRender(
|
||||||
|
spaceId: string,
|
||||||
|
renderId: string,
|
||||||
|
patch: MediaRenderPatch,
|
||||||
|
from: MediaRenderStatus[] = ACTIVE_RENDER_STATUSES,
|
||||||
|
): Promise<MediaRender | null> {
|
||||||
|
if (
|
||||||
|
patch.autoFixRound !== undefined &&
|
||||||
|
(!Number.isInteger(patch.autoFixRound) ||
|
||||||
|
patch.autoFixRound < 0 ||
|
||||||
|
patch.autoFixRound > MAX_AUTO_FIX_ROUNDS)
|
||||||
|
) {
|
||||||
|
throw new InvalidMediaInputError("Auto-fix round out of range");
|
||||||
|
}
|
||||||
|
const result = await prisma.mediaRender.updateMany({
|
||||||
|
where: { id: renderId, spaceId, status: { in: from } },
|
||||||
|
data: {
|
||||||
|
...(patch.status !== undefined ? { status: patch.status } : {}),
|
||||||
|
...(patch.progress !== undefined
|
||||||
|
? { progress: Math.min(1, Math.max(0, patch.progress)) }
|
||||||
|
: {}),
|
||||||
|
...(patch.outputs !== undefined
|
||||||
|
? { outputs: patch.outputs as unknown as Prisma.InputJsonArray }
|
||||||
|
: {}),
|
||||||
|
...(patch.gateReport !== undefined
|
||||||
|
? {
|
||||||
|
gateReport:
|
||||||
|
patch.gateReport === null
|
||||||
|
? Prisma.DbNull
|
||||||
|
: (patch.gateReport as unknown as Prisma.InputJsonObject),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(patch.ffprobe !== undefined
|
||||||
|
? {
|
||||||
|
ffprobe:
|
||||||
|
patch.ffprobe === null
|
||||||
|
? Prisma.DbNull
|
||||||
|
: (patch.ffprobe as unknown as Prisma.InputJsonObject),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(patch.autoFixRound !== undefined ? { autoFixRound: patch.autoFixRound } : {}),
|
||||||
|
...(patch.error !== undefined ? { error: patch.error } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const row = await requireRender(prisma, spaceId, renderId);
|
||||||
|
return result.count === 1 ? mapMediaRender(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelRender(spaceId: string, renderId: string): Promise<MediaRender> {
|
||||||
|
const result = await prisma.mediaRender.updateMany({
|
||||||
|
where: { id: renderId, spaceId, status: { in: ACTIVE_RENDER_STATUSES } },
|
||||||
|
data: { status: "canceled" },
|
||||||
|
});
|
||||||
|
const row = await requireRender(prisma, spaceId, renderId);
|
||||||
|
if (result.count !== 1) {
|
||||||
|
throw new MediaStateConflictError(`Render is already ${row.status}`);
|
||||||
|
}
|
||||||
|
return mapMediaRender(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
upsertBrandKit,
|
||||||
|
listBrandKits,
|
||||||
|
getBrandKit,
|
||||||
|
removeBrandKit,
|
||||||
|
createProject,
|
||||||
|
updateProject,
|
||||||
|
listProjects,
|
||||||
|
getProject,
|
||||||
|
getProjectRecord,
|
||||||
|
removeProject,
|
||||||
|
createPlanVersion,
|
||||||
|
getPlan,
|
||||||
|
latestPlan,
|
||||||
|
latestPlans,
|
||||||
|
listPlanVersions,
|
||||||
|
createRender,
|
||||||
|
getRender,
|
||||||
|
getRenderRecord,
|
||||||
|
listRenders,
|
||||||
|
claimRender,
|
||||||
|
updateRender,
|
||||||
|
cancelRender,
|
||||||
|
};
|
||||||
|
}
|
||||||
497
packages/db/src/media-publishing.ts
Normal file
497
packages/db/src/media-publishing.ts
Normal file
@ -0,0 +1,497 @@
|
|||||||
|
import type {
|
||||||
|
FunnelRow,
|
||||||
|
MediaMetric,
|
||||||
|
MediaPublication,
|
||||||
|
MediaPublicationStatus,
|
||||||
|
MetricCsvMapping,
|
||||||
|
TeaserWeights,
|
||||||
|
Utm,
|
||||||
|
} from "@rakazo/contracts";
|
||||||
|
import { MetricCsvMappingSchema, TeaserWeightsSchema } from "@rakazo/contracts";
|
||||||
|
import type { MediaPublication as MediaPublicationRow, PrismaClient } from "./client.js";
|
||||||
|
import { Prisma } from "./client.js";
|
||||||
|
import type { MediaDb, MediaPage, MediaPageInput } from "./media-shared.js";
|
||||||
|
import {
|
||||||
|
decodeKeysetCursor,
|
||||||
|
InvalidMediaInputError,
|
||||||
|
isUniqueViolation,
|
||||||
|
MediaStateConflictError,
|
||||||
|
mapMediaMetric,
|
||||||
|
mapMediaPublication,
|
||||||
|
mapMetricMapping,
|
||||||
|
pageLimit,
|
||||||
|
toPage,
|
||||||
|
} from "./media-shared.js";
|
||||||
|
import { IsolationError } from "./scope.js";
|
||||||
|
|
||||||
|
export interface NewMediaPublication {
|
||||||
|
renderId: string;
|
||||||
|
destination: string;
|
||||||
|
targetUrl: string | null;
|
||||||
|
trackingUrl: string | null;
|
||||||
|
utm: Utm | null;
|
||||||
|
title: string | null;
|
||||||
|
caption: string | null;
|
||||||
|
tags: string[];
|
||||||
|
thumbnailVariant: string | null;
|
||||||
|
scheduledAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PublicationKey = MetricCsvMapping["publicationKey"];
|
||||||
|
|
||||||
|
export type NewMediaMetric = Omit<MediaMetric, "id">;
|
||||||
|
|
||||||
|
export interface TeaserModelRecord {
|
||||||
|
weights: TeaserWeights;
|
||||||
|
sampleCount: number;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertMetric(metric: NewMediaMetric): void {
|
||||||
|
for (const value of [metric.views, metric.clicks, metric.conversions, metric.revenueCents]) {
|
||||||
|
if (!Number.isInteger(value) || value < 0) {
|
||||||
|
throw new InvalidMediaInputError("Metric values must be non-negative integers");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!/^[A-Z]{3}$/.test(metric.currency)) throw new InvalidMediaInputError("Invalid currency");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Publications, their metrics and the learned teaser weights. */
|
||||||
|
export function createMediaPublishingRepos(prisma: PrismaClient) {
|
||||||
|
async function requireRender(spaceId: string, renderId: string) {
|
||||||
|
const row = await prisma.mediaRender.findFirst({ where: { id: renderId, spaceId } });
|
||||||
|
if (!row) throw new IsolationError();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireProject(spaceId: string, projectId: string): Promise<void> {
|
||||||
|
const row = await prisma.mediaProject.findFirst({
|
||||||
|
where: { id: projectId, spaceId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!row) throw new IsolationError();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requirePublication(spaceId: string, publicationId: string) {
|
||||||
|
const row = await prisma.mediaPublication.findFirst({ where: { id: publicationId, spaceId } });
|
||||||
|
if (!row) throw new IsolationError();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only a finished render that passed its gate can be published. */
|
||||||
|
async function createPublication(
|
||||||
|
spaceId: string,
|
||||||
|
createdBy: string,
|
||||||
|
input: NewMediaPublication,
|
||||||
|
): Promise<MediaPublication> {
|
||||||
|
const render = await requireRender(spaceId, input.renderId);
|
||||||
|
if (render.status !== "done") {
|
||||||
|
throw new MediaStateConflictError("Only a finished render can be published");
|
||||||
|
}
|
||||||
|
const row = await prisma.mediaPublication.create({
|
||||||
|
data: {
|
||||||
|
spaceId,
|
||||||
|
createdBy,
|
||||||
|
renderId: render.id,
|
||||||
|
profileId: render.profileId,
|
||||||
|
destination: input.destination,
|
||||||
|
status: input.scheduledAt ? "scheduled" : "draft",
|
||||||
|
scheduledAt: input.scheduledAt,
|
||||||
|
targetUrl: input.targetUrl,
|
||||||
|
trackingUrl: input.trackingUrl,
|
||||||
|
utm: input.utm === null ? Prisma.DbNull : (input.utm as Prisma.InputJsonObject),
|
||||||
|
title: input.title,
|
||||||
|
caption: input.caption,
|
||||||
|
tags: input.tags,
|
||||||
|
thumbnailVariant: input.thumbnailVariant,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return mapMediaPublication(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPublication(spaceId: string, publicationId: string): Promise<MediaPublication> {
|
||||||
|
return mapMediaPublication(await requirePublication(spaceId, publicationId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPublicationRecord(
|
||||||
|
spaceId: string,
|
||||||
|
publicationId: string,
|
||||||
|
): Promise<MediaPublicationRow> {
|
||||||
|
return requirePublication(spaceId, publicationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listPublications(
|
||||||
|
spaceId: string,
|
||||||
|
input: MediaPageInput & { projectId?: string } = {},
|
||||||
|
): Promise<MediaPage<MediaPublication>> {
|
||||||
|
const limit = pageLimit(input.limit);
|
||||||
|
const cursor = input.cursor ? decodeKeysetCursor(input.cursor) : null;
|
||||||
|
if (cursor && !cursor.at) throw new InvalidMediaInputError("Invalid cursor");
|
||||||
|
const rows = await prisma.mediaPublication.findMany({
|
||||||
|
where: {
|
||||||
|
spaceId,
|
||||||
|
...(input.projectId ? { render: { projectId: input.projectId, spaceId } } : {}),
|
||||||
|
...(cursor?.at
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ createdAt: { lt: cursor.at } },
|
||||||
|
{ createdAt: cursor.at, id: { lt: cursor.id } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||||
|
take: limit + 1,
|
||||||
|
});
|
||||||
|
return toPage(rows, limit, (row) => ({ at: row.createdAt, id: row.id }), mapMediaPublication);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transitionPublication(
|
||||||
|
spaceId: string,
|
||||||
|
publicationId: string,
|
||||||
|
from: MediaPublicationStatus[],
|
||||||
|
data: Prisma.MediaPublicationUpdateManyMutationInput,
|
||||||
|
): Promise<MediaPublication> {
|
||||||
|
const result = await prisma.mediaPublication.updateMany({
|
||||||
|
where: { id: publicationId, spaceId, status: { in: from } },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
const row = await requirePublication(spaceId, publicationId);
|
||||||
|
if (result.count !== 1) {
|
||||||
|
throw new MediaStateConflictError(`Publication is already ${row.status}`);
|
||||||
|
}
|
||||||
|
return mapMediaPublication(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function schedulePublication(
|
||||||
|
spaceId: string,
|
||||||
|
publicationId: string,
|
||||||
|
scheduledAt: Date,
|
||||||
|
): Promise<MediaPublication> {
|
||||||
|
return transitionPublication(spaceId, publicationId, ["draft", "scheduled", "failed"], {
|
||||||
|
status: "scheduled",
|
||||||
|
scheduledAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelPublication(
|
||||||
|
spaceId: string,
|
||||||
|
publicationId: string,
|
||||||
|
): Promise<MediaPublication> {
|
||||||
|
return transitionPublication(
|
||||||
|
spaceId,
|
||||||
|
publicationId,
|
||||||
|
["draft", "scheduled", "bundled", "failed"],
|
||||||
|
{ status: "canceled" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markPublicationBundled(
|
||||||
|
spaceId: string,
|
||||||
|
publicationId: string,
|
||||||
|
bundleArtifactId: string | null,
|
||||||
|
): Promise<MediaPublication> {
|
||||||
|
return transitionPublication(
|
||||||
|
spaceId,
|
||||||
|
publicationId,
|
||||||
|
["draft", "scheduled", "bundled", "failed"],
|
||||||
|
{ status: "bundled", bundleArtifactId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markPublicationPublished(
|
||||||
|
spaceId: string,
|
||||||
|
publicationId: string,
|
||||||
|
externalUrl: string | null,
|
||||||
|
publishedAt: Date = new Date(),
|
||||||
|
): Promise<MediaPublication> {
|
||||||
|
return transitionPublication(spaceId, publicationId, ["draft", "scheduled", "bundled"], {
|
||||||
|
status: "published",
|
||||||
|
externalUrl,
|
||||||
|
publishedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markPublicationFailed(
|
||||||
|
spaceId: string,
|
||||||
|
publicationId: string,
|
||||||
|
): Promise<MediaPublication> {
|
||||||
|
return transitionPublication(spaceId, publicationId, ["draft", "scheduled", "bundled"], {
|
||||||
|
status: "failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map CSV keys to publication ids of this space. Values that match nothing (or a
|
||||||
|
* publication in another space) are absent from the result.
|
||||||
|
*/
|
||||||
|
async function resolvePublicationKeys(
|
||||||
|
spaceId: string,
|
||||||
|
key: PublicationKey,
|
||||||
|
values: string[],
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
const wanted = new Set(values.map((value) => value.trim()).filter(Boolean));
|
||||||
|
if (wanted.size === 0) return new Map();
|
||||||
|
const rows = await prisma.mediaPublication.findMany({
|
||||||
|
where: {
|
||||||
|
spaceId,
|
||||||
|
...(key === "id" ? { id: { in: [...wanted] } } : {}),
|
||||||
|
...(key === "tracking_url" ? { trackingUrl: { in: [...wanted] } } : {}),
|
||||||
|
...(key === "external_url" ? { externalUrl: { in: [...wanted] } } : {}),
|
||||||
|
},
|
||||||
|
select: { id: true, trackingUrl: true, externalUrl: true, utm: true },
|
||||||
|
});
|
||||||
|
const resolved = new Map<string, string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const utm = row.utm as { content?: unknown } | null;
|
||||||
|
const value =
|
||||||
|
key === "id"
|
||||||
|
? row.id
|
||||||
|
: key === "tracking_url"
|
||||||
|
? row.trackingUrl
|
||||||
|
: key === "external_url"
|
||||||
|
? row.externalUrl
|
||||||
|
: typeof utm?.content === "string"
|
||||||
|
? utm.content
|
||||||
|
: null;
|
||||||
|
if (value && wanted.has(value) && !resolved.has(value)) resolved.set(value, row.id);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Metrics
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function upsertMetric(db: MediaDb, spaceId: string, metric: NewMediaMetric) {
|
||||||
|
assertMetric(metric);
|
||||||
|
const observedAt = new Date(metric.observedAt);
|
||||||
|
if (Number.isNaN(observedAt.getTime())) throw new InvalidMediaInputError("Invalid date");
|
||||||
|
const values = {
|
||||||
|
views: metric.views,
|
||||||
|
clicks: metric.clicks,
|
||||||
|
conversions: metric.conversions,
|
||||||
|
revenueCents: metric.revenueCents,
|
||||||
|
currency: metric.currency,
|
||||||
|
};
|
||||||
|
return db.mediaMetric.upsert({
|
||||||
|
where: {
|
||||||
|
publicationId_observedAt_source: {
|
||||||
|
publicationId: metric.publicationId,
|
||||||
|
observedAt,
|
||||||
|
source: metric.source,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
...values,
|
||||||
|
spaceId,
|
||||||
|
publicationId: metric.publicationId,
|
||||||
|
observedAt,
|
||||||
|
source: metric.source,
|
||||||
|
},
|
||||||
|
update: values,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One observation; re-recording the same (publication, time, source) replaces it. */
|
||||||
|
async function recordMetric(spaceId: string, metric: NewMediaMetric): Promise<MediaMetric> {
|
||||||
|
await requirePublication(spaceId, metric.publicationId);
|
||||||
|
return mapMediaMetric(await upsertMetric(prisma, spaceId, metric));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Batch import, all or nothing. */
|
||||||
|
async function recordMetrics(spaceId: string, metrics: NewMediaMetric[]): Promise<number> {
|
||||||
|
if (metrics.length === 0) return 0;
|
||||||
|
return prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
const ids = [...new Set(metrics.map((metric) => metric.publicationId))];
|
||||||
|
const owned = await tx.mediaPublication.count({ where: { spaceId, id: { in: ids } } });
|
||||||
|
if (owned !== ids.length) throw new IsolationError();
|
||||||
|
for (const metric of metrics) await upsertMetric(tx, spaceId, metric);
|
||||||
|
return metrics.length;
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listMetrics(spaceId: string, publicationId: string): Promise<MediaMetric[]> {
|
||||||
|
await requirePublication(spaceId, publicationId);
|
||||||
|
const rows = await prisma.mediaMetric.findMany({
|
||||||
|
where: { spaceId, publicationId },
|
||||||
|
orderBy: [{ observedAt: "asc" }, { source: "asc" }],
|
||||||
|
});
|
||||||
|
return rows.map(mapMediaMetric);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMetricMapping(
|
||||||
|
spaceId: string,
|
||||||
|
input: MetricCsvMapping,
|
||||||
|
): Promise<MetricCsvMapping> {
|
||||||
|
const parsed = MetricCsvMappingSchema.safeParse(input);
|
||||||
|
if (!parsed.success) throw new InvalidMediaInputError("Invalid CSV mapping");
|
||||||
|
const { id, ...mapping } = parsed.data;
|
||||||
|
const json = mapping as Prisma.InputJsonObject;
|
||||||
|
try {
|
||||||
|
if (id) {
|
||||||
|
const result = await prisma.mediaMetricMapping.updateMany({
|
||||||
|
where: { id, spaceId },
|
||||||
|
data: { name: mapping.name, mapping: json },
|
||||||
|
});
|
||||||
|
if (result.count !== 1) throw new IsolationError();
|
||||||
|
return getMetricMapping(spaceId, id);
|
||||||
|
}
|
||||||
|
const row = await prisma.mediaMetricMapping.upsert({
|
||||||
|
where: { spaceId_name: { spaceId, name: mapping.name } },
|
||||||
|
create: { spaceId, name: mapping.name, mapping: json },
|
||||||
|
update: { mapping: json },
|
||||||
|
});
|
||||||
|
return mapMetricMapping(row);
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) throw new InvalidMediaInputError("Mapping name already used");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listMetricMappings(spaceId: string): Promise<MetricCsvMapping[]> {
|
||||||
|
const rows = await prisma.mediaMetricMapping.findMany({
|
||||||
|
where: { spaceId },
|
||||||
|
orderBy: [{ name: "asc" }, { id: "asc" }],
|
||||||
|
});
|
||||||
|
return rows.map(mapMetricMapping);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMetricMapping(spaceId: string, id: string): Promise<MetricCsvMapping> {
|
||||||
|
const row = await prisma.mediaMetricMapping.findFirst({ where: { id, spaceId } });
|
||||||
|
if (!row) throw new IsolationError();
|
||||||
|
return mapMetricMapping(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Views → clicks → conversions → revenue per publication. Observations are
|
||||||
|
* cumulative snapshots, so only the latest one per (publication, source) counts;
|
||||||
|
* importing the same report twice never doubles the numbers.
|
||||||
|
*/
|
||||||
|
async function funnel(spaceId: string, projectId?: string): Promise<FunnelRow[]> {
|
||||||
|
if (projectId) await requireProject(spaceId, projectId);
|
||||||
|
const rows = await prisma.$queryRaw<
|
||||||
|
Array<{
|
||||||
|
publicationId: string;
|
||||||
|
projectId: string;
|
||||||
|
profileId: string;
|
||||||
|
variant: string | null;
|
||||||
|
thumbnailVariant: string | null;
|
||||||
|
views: bigint;
|
||||||
|
clicks: bigint;
|
||||||
|
conversions: bigint;
|
||||||
|
revenueCents: bigint;
|
||||||
|
currency: string | null;
|
||||||
|
}>
|
||||||
|
>(Prisma.sql`
|
||||||
|
WITH latest AS (
|
||||||
|
SELECT DISTINCT ON (m."publicationId", m.source)
|
||||||
|
m."publicationId", m.views, m.clicks, m.conversions, m."revenueCents", m.currency
|
||||||
|
FROM media_metrics m
|
||||||
|
WHERE m."spaceId" = ${spaceId}
|
||||||
|
ORDER BY m."publicationId", m.source, m."observedAt" DESC, m.id DESC
|
||||||
|
)
|
||||||
|
SELECT p.id AS "publicationId", r."projectId", p."profileId",
|
||||||
|
p.utm ->> 'content' AS variant, p."thumbnailVariant",
|
||||||
|
coalesce(sum(l.views), 0)::bigint AS views,
|
||||||
|
coalesce(sum(l.clicks), 0)::bigint AS clicks,
|
||||||
|
coalesce(sum(l.conversions), 0)::bigint AS conversions,
|
||||||
|
coalesce(sum(l."revenueCents"), 0)::bigint AS "revenueCents",
|
||||||
|
min(l.currency) AS currency
|
||||||
|
FROM media_publications p
|
||||||
|
JOIN media_renders r ON r.id = p."renderId" AND r."spaceId" = p."spaceId"
|
||||||
|
LEFT JOIN latest l ON l."publicationId" = p.id
|
||||||
|
WHERE p."spaceId" = ${spaceId}
|
||||||
|
${projectId ? Prisma.sql`AND r."projectId" = ${projectId}` : Prisma.empty}
|
||||||
|
GROUP BY p.id, r."projectId"
|
||||||
|
ORDER BY p."createdAt" DESC, p.id DESC
|
||||||
|
`);
|
||||||
|
return rows.map((row) => ({
|
||||||
|
publicationId: row.publicationId,
|
||||||
|
projectId: row.projectId,
|
||||||
|
profileId: row.profileId,
|
||||||
|
variant: row.variant,
|
||||||
|
thumbnailVariant: row.thumbnailVariant,
|
||||||
|
views: Number(row.views),
|
||||||
|
clicks: Number(row.clicks),
|
||||||
|
conversions: Number(row.conversions),
|
||||||
|
revenueCents: Number(row.revenueCents),
|
||||||
|
currency: row.currency ?? "EUR",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Publications with at least one metric: the sample size for teaser learning. */
|
||||||
|
async function publicationsWithMetricsCount(spaceId: string): Promise<number> {
|
||||||
|
return prisma.mediaPublication.count({ where: { spaceId, metrics: { some: {} } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Teaser model
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function getTeaserModel(spaceId: string): Promise<TeaserModelRecord | null> {
|
||||||
|
const row = await prisma.mediaTeaserModel.findUnique({ where: { spaceId } });
|
||||||
|
if (!row) return null;
|
||||||
|
const weights = TeaserWeightsSchema.safeParse(row.weights);
|
||||||
|
if (!weights.success) return null;
|
||||||
|
return {
|
||||||
|
weights: weights.data,
|
||||||
|
sampleCount: row.sampleCount,
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putTeaserModel(
|
||||||
|
spaceId: string,
|
||||||
|
weights: TeaserWeights,
|
||||||
|
sampleCount: number,
|
||||||
|
): Promise<TeaserModelRecord> {
|
||||||
|
const parsed = TeaserWeightsSchema.safeParse(weights);
|
||||||
|
if (!parsed.success || !Number.isInteger(sampleCount) || sampleCount < 0) {
|
||||||
|
throw new InvalidMediaInputError("Invalid teaser model");
|
||||||
|
}
|
||||||
|
const json = parsed.data as Prisma.InputJsonObject;
|
||||||
|
const row = await prisma.mediaTeaserModel.upsert({
|
||||||
|
where: { spaceId },
|
||||||
|
create: { spaceId, weights: json, sampleCount },
|
||||||
|
update: { weights: json, sampleCount },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
weights: parsed.data,
|
||||||
|
sampleCount: row.sampleCount,
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back to default weights. */
|
||||||
|
async function resetTeaserModel(spaceId: string): Promise<void> {
|
||||||
|
await prisma.mediaTeaserModel.deleteMany({ where: { spaceId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
createPublication,
|
||||||
|
getPublication,
|
||||||
|
getPublicationRecord,
|
||||||
|
listPublications,
|
||||||
|
schedulePublication,
|
||||||
|
cancelPublication,
|
||||||
|
markPublicationBundled,
|
||||||
|
markPublicationPublished,
|
||||||
|
markPublicationFailed,
|
||||||
|
resolvePublicationKeys,
|
||||||
|
recordMetric,
|
||||||
|
recordMetrics,
|
||||||
|
listMetrics,
|
||||||
|
saveMetricMapping,
|
||||||
|
listMetricMappings,
|
||||||
|
getMetricMapping,
|
||||||
|
funnel,
|
||||||
|
publicationsWithMetricsCount,
|
||||||
|
getTeaserModel,
|
||||||
|
putTeaserModel,
|
||||||
|
resetTeaserModel,
|
||||||
|
};
|
||||||
|
}
|
||||||
525
packages/db/src/media-shared.ts
Normal file
525
packages/db/src/media-shared.ts
Normal file
@ -0,0 +1,525 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import type { MediaSourceItem } from "@rakazo/adapter-kit";
|
||||||
|
import type {
|
||||||
|
AgeVerificationMethod,
|
||||||
|
AnonymizeMode,
|
||||||
|
BrandKitRecord,
|
||||||
|
ClientMediaItem,
|
||||||
|
MarkTreatment,
|
||||||
|
MediaAsset,
|
||||||
|
MediaAssetKind,
|
||||||
|
MediaAssetStorage,
|
||||||
|
MediaCollection,
|
||||||
|
MediaCollectionKind,
|
||||||
|
MediaEditPlan,
|
||||||
|
MediaFaceTrack,
|
||||||
|
MediaIngestState,
|
||||||
|
MediaLabelSource,
|
||||||
|
MediaMark,
|
||||||
|
MediaMarkKind,
|
||||||
|
MediaMetric,
|
||||||
|
MediaMetricSource,
|
||||||
|
MediaPerson,
|
||||||
|
MediaProject,
|
||||||
|
MediaProjectGoal,
|
||||||
|
MediaProjectStatus,
|
||||||
|
MediaPublication,
|
||||||
|
MediaPublicationStatus,
|
||||||
|
MediaRender,
|
||||||
|
MediaRenderOutput,
|
||||||
|
MediaRenderQuality,
|
||||||
|
MediaRenderStatus,
|
||||||
|
MediaSegment,
|
||||||
|
MediaSegmentKind,
|
||||||
|
MediaSource,
|
||||||
|
MediaSourceKind,
|
||||||
|
MediaSourceStatus,
|
||||||
|
MediaUpload,
|
||||||
|
MetricCsvMapping,
|
||||||
|
TeaserFeature,
|
||||||
|
} from "@rakazo/contracts";
|
||||||
|
import {
|
||||||
|
BrandKitSchema,
|
||||||
|
EdlSchema,
|
||||||
|
FfprobeReportSchema,
|
||||||
|
GateReportSchema,
|
||||||
|
MediaMarkPromptSchema,
|
||||||
|
MediaRenderOutputSchema,
|
||||||
|
MetricCsvMappingSchema,
|
||||||
|
UtmSchema,
|
||||||
|
} from "@rakazo/contracts";
|
||||||
|
import type {
|
||||||
|
MediaAsset as MediaAssetRow,
|
||||||
|
MediaBrandKit as MediaBrandKitRow,
|
||||||
|
MediaCollection as MediaCollectionRow,
|
||||||
|
MediaEditPlan as MediaEditPlanRow,
|
||||||
|
MediaFaceTrack as MediaFaceTrackRow,
|
||||||
|
MediaMark as MediaMarkRow,
|
||||||
|
MediaMetricMapping as MediaMetricMappingRow,
|
||||||
|
MediaMetric as MediaMetricRow,
|
||||||
|
MediaPerson as MediaPersonRow,
|
||||||
|
MediaProject as MediaProjectRow,
|
||||||
|
MediaPublication as MediaPublicationRow,
|
||||||
|
MediaRender as MediaRenderRow,
|
||||||
|
MediaSegment as MediaSegmentRow,
|
||||||
|
MediaSource as MediaSourceRow,
|
||||||
|
MediaUpload as MediaUploadRow,
|
||||||
|
PrismaClient,
|
||||||
|
} from "./client.js";
|
||||||
|
import { Prisma } from "./client.js";
|
||||||
|
import { IsolationError } from "./scope.js";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Shared pieces of the Media Studio repositories: errors, keyset cursors, and
|
||||||
|
* the row → DTO mappers. Not-found and foreign-space access both surface as
|
||||||
|
* IsolationError (from scope.ts), like every other repository.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Input the repository refuses: bad cursor, invalid EDL or brand kit, foreign ids. */
|
||||||
|
export class InvalidMediaInputError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "InvalidMediaInputError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The record exists but its state does not allow the requested change. */
|
||||||
|
export class MediaStateConflictError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "MediaStateConflictError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MediaDb = Prisma.TransactionClient | PrismaClient;
|
||||||
|
|
||||||
|
/** Throws IsolationError unless every id is an asset of the space. */
|
||||||
|
export async function assertAssetsInSpace(
|
||||||
|
db: MediaDb,
|
||||||
|
spaceId: string,
|
||||||
|
assetIds: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const ids = [...new Set(assetIds)];
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
const owned = await db.mediaAsset.count({ where: { spaceId, id: { in: ids } } });
|
||||||
|
if (owned !== ids.length) throw new IsolationError();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MEDIA_PAGE_MAX = 500;
|
||||||
|
export const MEDIA_PAGE_DEFAULT = 100;
|
||||||
|
|
||||||
|
export interface MediaPage<T> {
|
||||||
|
items: T[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaPageInput {
|
||||||
|
cursor?: string | null;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageLimit(limit: number | undefined): number {
|
||||||
|
if (limit === undefined || !Number.isFinite(limit)) return MEDIA_PAGE_DEFAULT;
|
||||||
|
return Math.min(MEDIA_PAGE_MAX, Math.max(1, Math.trunc(limit)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Position after the last row of a page ordered by `(at DESC NULLS LAST, id DESC)`.
|
||||||
|
* `at` is null for rows without a timestamp, which sort after every dated row.
|
||||||
|
*/
|
||||||
|
export interface KeysetCursor {
|
||||||
|
at: Date | null;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opaque base64url token for a keyset position. */
|
||||||
|
export function encodeKeysetCursor(cursor: KeysetCursor): string {
|
||||||
|
return Buffer.from(JSON.stringify([cursor.at?.toISOString() ?? null, cursor.id])).toString(
|
||||||
|
"base64url",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeKeysetCursor(token: string): KeysetCursor {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(Buffer.from(token, "base64url").toString("utf8"));
|
||||||
|
} catch {
|
||||||
|
throw new InvalidMediaInputError("Invalid cursor");
|
||||||
|
}
|
||||||
|
if (!Array.isArray(parsed) || parsed.length !== 2) {
|
||||||
|
throw new InvalidMediaInputError("Invalid cursor");
|
||||||
|
}
|
||||||
|
const [at, id] = parsed as unknown[];
|
||||||
|
if (typeof id !== "string" || id.length === 0 || id.length > 100) {
|
||||||
|
throw new InvalidMediaInputError("Invalid cursor");
|
||||||
|
}
|
||||||
|
if (at === null) return { at: null, id };
|
||||||
|
if (typeof at !== "string") throw new InvalidMediaInputError("Invalid cursor");
|
||||||
|
const date = new Date(at);
|
||||||
|
if (Number.isNaN(date.getTime())) throw new InvalidMediaInputError("Invalid cursor");
|
||||||
|
return { at: date, id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQL predicate for "after the cursor" under `ORDER BY at DESC NULLS LAST, id DESC`.
|
||||||
|
* `atColumn` and `idColumn` are trusted column references.
|
||||||
|
*/
|
||||||
|
export function keysetAfterSql(
|
||||||
|
cursor: KeysetCursor | null,
|
||||||
|
atColumn: Prisma.Sql,
|
||||||
|
idColumn: Prisma.Sql,
|
||||||
|
): Prisma.Sql {
|
||||||
|
if (!cursor) return Prisma.sql`TRUE`;
|
||||||
|
if (cursor.at === null) {
|
||||||
|
return Prisma.sql`(${atColumn} IS NULL AND ${idColumn} < ${cursor.id})`;
|
||||||
|
}
|
||||||
|
return Prisma.sql`(${atColumn} < ${cursor.at} OR (${atColumn} = ${cursor.at} AND ${idColumn} < ${cursor.id}) OR ${atColumn} IS NULL)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Slice a `limit + 1` result into a page with its next cursor. */
|
||||||
|
export function toPage<Row, Item>(
|
||||||
|
rows: Row[],
|
||||||
|
limit: number,
|
||||||
|
keyOf: (row: Row) => KeysetCursor,
|
||||||
|
map: (row: Row) => Item,
|
||||||
|
): MediaPage<Item> {
|
||||||
|
const hasMore = rows.length > limit;
|
||||||
|
const pageRows = hasMore ? rows.slice(0, limit) : rows;
|
||||||
|
const last = pageRows.at(-1);
|
||||||
|
return {
|
||||||
|
items: pageRows.map(map),
|
||||||
|
nextCursor: hasMore && last ? encodeKeysetCursor(keyOf(last)) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escape LIKE wildcards so user text matches literally. */
|
||||||
|
export function likeContains(text: string): string {
|
||||||
|
return `%${text.replace(/[\\%_]/g, (char) => `\\${char}`)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUniqueViolation(error: unknown): boolean {
|
||||||
|
return Boolean(error && typeof error === "object" && "code" in error && error.code === "P2002");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Advisory lock held until the surrounding transaction ends. */
|
||||||
|
export async function lockMediaKey(tx: Prisma.TransactionClient, key: string): Promise<void> {
|
||||||
|
await tx.$queryRaw(Prisma.sql`
|
||||||
|
SELECT pg_advisory_xact_lock(hashtextextended(${`media:${key}`}, 0))::text AS "lock"
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prisma Bytes columns want an ArrayBuffer-backed view; copy when the input is a slice. */
|
||||||
|
export function toBytes(value: Uint8Array): Uint8Array<ArrayBuffer> {
|
||||||
|
return new Uint8Array(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change detector for a client-pushed item, which has no revision of its own:
|
||||||
|
* any metadata change produces a different value.
|
||||||
|
*/
|
||||||
|
export function clientMediaItemToSourceItem(item: ClientMediaItem): MediaSourceItem {
|
||||||
|
const canonical = JSON.stringify([
|
||||||
|
item.kind,
|
||||||
|
item.capturedAt,
|
||||||
|
item.durationMs,
|
||||||
|
item.width,
|
||||||
|
item.height,
|
||||||
|
item.fps,
|
||||||
|
item.gpsLat,
|
||||||
|
item.gpsLon,
|
||||||
|
item.deviceModel,
|
||||||
|
item.albumNames,
|
||||||
|
item.isFavorite,
|
||||||
|
item.fileName,
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
externalId: item.externalId,
|
||||||
|
kind: item.kind,
|
||||||
|
capturedAt: item.capturedAt ? new Date(item.capturedAt) : null,
|
||||||
|
durationMs: item.durationMs,
|
||||||
|
width: item.width,
|
||||||
|
height: item.height,
|
||||||
|
fps: item.fps,
|
||||||
|
gpsLat: item.gpsLat,
|
||||||
|
gpsLon: item.gpsLon,
|
||||||
|
deviceModel: item.deviceModel,
|
||||||
|
albumNames: item.albumNames,
|
||||||
|
isFavorite: item.isFavorite,
|
||||||
|
fileName: item.fileName,
|
||||||
|
sizeBytes: null,
|
||||||
|
revision: createHash("sha256").update(canonical).digest("hex").slice(0, 32),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mappers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function iso(value: Date | null): string | null {
|
||||||
|
return value ? value.toISOString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapMediaSource(row: MediaSourceRow, assetCount: number): MediaSource {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
kind: row.kind as MediaSourceKind,
|
||||||
|
label: row.label,
|
||||||
|
status: row.status as MediaSourceStatus,
|
||||||
|
assetCount,
|
||||||
|
lastSyncedAt: iso(row.lastSyncedAt),
|
||||||
|
error: row.error,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapMediaAsset(row: MediaAssetRow): MediaAsset {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
sourceId: row.sourceId,
|
||||||
|
kind: row.kind as MediaAssetKind,
|
||||||
|
storage: row.storage as MediaAssetStorage,
|
||||||
|
capturedAt: iso(row.capturedAt),
|
||||||
|
durationMs: row.durationMs,
|
||||||
|
width: row.width,
|
||||||
|
height: row.height,
|
||||||
|
fps: row.fps,
|
||||||
|
gpsLat: row.gpsLat,
|
||||||
|
gpsLon: row.gpsLon,
|
||||||
|
deviceModel: row.deviceModel,
|
||||||
|
albumNames: row.albumNames,
|
||||||
|
isFavorite: row.isFavorite,
|
||||||
|
ingestState: row.ingestState as MediaIngestState,
|
||||||
|
hasProxy: row.proxyPath !== null,
|
||||||
|
hasThumbnail: row.thumbPath !== null,
|
||||||
|
qualityScore: row.qualityScore,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberRecord(value: Prisma.JsonValue): Partial<Record<TeaserFeature, number>> {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||||
|
const out: Partial<Record<TeaserFeature, number>> = {};
|
||||||
|
for (const [key, score] of Object.entries(value)) {
|
||||||
|
if (typeof score === "number" && Number.isFinite(score)) {
|
||||||
|
out[key as TeaserFeature] = score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapMediaSegment(row: MediaSegmentRow): MediaSegment {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
assetId: row.assetId,
|
||||||
|
kind: row.kind as MediaSegmentKind,
|
||||||
|
startMs: row.startMs,
|
||||||
|
endMs: row.endMs,
|
||||||
|
scores: numberRecord(row.scores),
|
||||||
|
teaserScore: row.teaserScore,
|
||||||
|
transcriptExcerpt: row.transcriptExcerpt,
|
||||||
|
labels: row.labels,
|
||||||
|
paidOnly: row.paidOnly,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectionStats {
|
||||||
|
assetCount: number;
|
||||||
|
videoCount: number;
|
||||||
|
totalDurationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_COLLECTION_STATS: CollectionStats = {
|
||||||
|
assetCount: 0,
|
||||||
|
videoCount: 0,
|
||||||
|
totalDurationMs: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function mapMediaCollection(
|
||||||
|
row: MediaCollectionRow,
|
||||||
|
stats: CollectionStats,
|
||||||
|
): MediaCollection {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
kind: row.kind as MediaCollectionKind,
|
||||||
|
title: row.title,
|
||||||
|
labelSource: row.labelSource as MediaLabelSource,
|
||||||
|
startAt: iso(row.startAt),
|
||||||
|
endAt: iso(row.endAt),
|
||||||
|
coverAssetId: row.coverAssetId,
|
||||||
|
assetCount: stats.assetCount,
|
||||||
|
videoCount: stats.videoCount,
|
||||||
|
totalDurationMs: stats.totalDurationMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapMediaPerson(
|
||||||
|
row: MediaPersonRow,
|
||||||
|
faceTrackCount: number,
|
||||||
|
coverThumbAssetId: string | null,
|
||||||
|
): MediaPerson {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
displayName: row.displayName,
|
||||||
|
isSelf: row.isSelf,
|
||||||
|
confirmed: row.confirmed,
|
||||||
|
anonymizeByDefault: row.anonymizeByDefault as AnonymizeMode,
|
||||||
|
consentRecordedAt: iso(row.consentRecordedAt),
|
||||||
|
consentArtifactId: row.consentArtifactId,
|
||||||
|
ageVerifiedAt: iso(row.ageVerifiedAt),
|
||||||
|
ageVerificationMethod: row.ageVerificationMethod as AgeVerificationMethod,
|
||||||
|
faceTrackCount,
|
||||||
|
coverThumbAssetId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapMediaFaceTrack(
|
||||||
|
row: Pick<
|
||||||
|
MediaFaceTrackRow,
|
||||||
|
"id" | "assetId" | "personId" | "startMs" | "endMs" | "confidenceMin"
|
||||||
|
>,
|
||||||
|
): MediaFaceTrack {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
assetId: row.assetId,
|
||||||
|
personId: row.personId,
|
||||||
|
startMs: row.startMs,
|
||||||
|
endMs: row.endMs,
|
||||||
|
confidenceMin: row.confidenceMin,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapMediaMark(row: MediaMarkRow): MediaMark {
|
||||||
|
const prompt = row.prompt === null ? null : MediaMarkPromptSchema.safeParse(row.prompt);
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
kind: row.kind as MediaMarkKind,
|
||||||
|
label: row.label,
|
||||||
|
personId: row.personId,
|
||||||
|
assetId: row.assetId,
|
||||||
|
prompt: prompt?.success ? prompt.data : null,
|
||||||
|
treatment: row.treatment as MarkTreatment,
|
||||||
|
qualityScore: row.qualityScore,
|
||||||
|
suggestedFallback: row.suggestedFallback as MarkTreatment | null,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapBrandKit(row: MediaBrandKitRow): BrandKitRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
kit: BrandKitSchema.parse(row.kit),
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapMediaProject(row: MediaProjectRow): MediaProject {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
goal: row.goal as MediaProjectGoal,
|
||||||
|
status: row.status as MediaProjectStatus,
|
||||||
|
brandKitId: row.brandKitId,
|
||||||
|
collectionIds: row.collectionIds,
|
||||||
|
profileIds: row.profileIds,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapMediaEditPlan(row: MediaEditPlanRow): MediaEditPlan {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
projectId: row.projectId,
|
||||||
|
profileId: row.profileId,
|
||||||
|
version: row.version,
|
||||||
|
edl: EdlSchema.parse(row.edl),
|
||||||
|
createdBy: row.createdBy === "agent" ? "agent" : "user",
|
||||||
|
rationale: row.rationale,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public render outputs; internal paths stored next to them are dropped. */
|
||||||
|
export function renderOutputsOf(value: Prisma.JsonValue): MediaRenderOutput[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
const outputs: MediaRenderOutput[] = [];
|
||||||
|
for (const entry of value) {
|
||||||
|
const parsed = MediaRenderOutputSchema.safeParse(entry);
|
||||||
|
if (parsed.success) outputs.push(parsed.data);
|
||||||
|
}
|
||||||
|
return outputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapMediaRender(row: MediaRenderRow): MediaRender {
|
||||||
|
const gate = row.gateReport === null ? null : GateReportSchema.safeParse(row.gateReport);
|
||||||
|
const probe = row.ffprobe === null ? null : FfprobeReportSchema.safeParse(row.ffprobe);
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
planId: row.planId,
|
||||||
|
projectId: row.projectId,
|
||||||
|
profileId: row.profileId,
|
||||||
|
quality: row.quality as MediaRenderQuality,
|
||||||
|
status: row.status as MediaRenderStatus,
|
||||||
|
progress: Math.min(1, Math.max(0, row.progress)),
|
||||||
|
outputs: renderOutputsOf(row.outputs),
|
||||||
|
gateReport: gate?.success ? gate.data : null,
|
||||||
|
ffprobe: probe?.success ? probe.data : null,
|
||||||
|
error: row.error,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapMediaUpload(row: MediaUploadRow): MediaUpload {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
assetId: row.assetId,
|
||||||
|
sizeBytes: Number(row.sizeBytes),
|
||||||
|
chunkBytes: row.chunkBytes,
|
||||||
|
receivedChunks: [...row.receivedChunks].sort((a, b) => a - b),
|
||||||
|
status: row.status as MediaUpload["status"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapMediaPublication(row: MediaPublicationRow): MediaPublication {
|
||||||
|
const utm = row.utm === null ? null : UtmSchema.safeParse(row.utm);
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
renderId: row.renderId,
|
||||||
|
profileId: row.profileId,
|
||||||
|
destination: row.destination,
|
||||||
|
status: row.status as MediaPublicationStatus,
|
||||||
|
scheduledAt: iso(row.scheduledAt),
|
||||||
|
publishedAt: iso(row.publishedAt),
|
||||||
|
externalUrl: row.externalUrl,
|
||||||
|
targetUrl: row.targetUrl,
|
||||||
|
trackingUrl: row.trackingUrl,
|
||||||
|
utm: utm?.success ? utm.data : null,
|
||||||
|
title: row.title,
|
||||||
|
caption: row.caption,
|
||||||
|
tags: row.tags,
|
||||||
|
thumbnailVariant: row.thumbnailVariant,
|
||||||
|
bundleArtifactId: row.bundleArtifactId,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapMediaMetric(row: MediaMetricRow): MediaMetric {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
publicationId: row.publicationId,
|
||||||
|
observedAt: row.observedAt.toISOString(),
|
||||||
|
views: row.views,
|
||||||
|
clicks: row.clicks,
|
||||||
|
conversions: row.conversions,
|
||||||
|
revenueCents: row.revenueCents,
|
||||||
|
currency: row.currency,
|
||||||
|
source: row.source as MediaMetricSource,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapMetricMapping(row: MediaMetricMappingRow): MetricCsvMapping {
|
||||||
|
const mapping = MetricCsvMappingSchema.parse(row.mapping);
|
||||||
|
return { ...mapping, id: row.id, name: row.name };
|
||||||
|
}
|
||||||
163
packages/db/src/media-uploads.ts
Normal file
163
packages/db/src/media-uploads.ts
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import type { MediaUpload } from "@rakazo/contracts";
|
||||||
|
import { MEDIA_UPLOAD_CHUNK_BYTES } from "@rakazo/contracts";
|
||||||
|
import type { PrismaClient } from "./client.js";
|
||||||
|
import { Prisma } from "./client.js";
|
||||||
|
import {
|
||||||
|
InvalidMediaInputError,
|
||||||
|
lockMediaKey,
|
||||||
|
MediaStateConflictError,
|
||||||
|
mapMediaUpload,
|
||||||
|
} from "./media-shared.js";
|
||||||
|
import { IsolationError } from "./scope.js";
|
||||||
|
|
||||||
|
export interface BeginMediaUpload {
|
||||||
|
assetId: string;
|
||||||
|
userId: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
sha256: string;
|
||||||
|
fileName: string;
|
||||||
|
chunkBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunkCountOf(row: { sizeBytes: bigint; chunkBytes: number }): number {
|
||||||
|
return Number((row.sizeBytes + BigInt(row.chunkBytes) - 1n) / BigInt(row.chunkBytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resumable chunked uploads of originals from client-pushed sources. */
|
||||||
|
export function createMediaUploadRepos(prisma: PrismaClient) {
|
||||||
|
/** Opens an upload, or resumes the open one for the same asset and content. */
|
||||||
|
async function beginUpload(spaceId: string, input: BeginMediaUpload): Promise<MediaUpload> {
|
||||||
|
const chunkBytes = input.chunkBytes ?? MEDIA_UPLOAD_CHUNK_BYTES;
|
||||||
|
if (!Number.isInteger(input.sizeBytes) || input.sizeBytes <= 0) {
|
||||||
|
throw new InvalidMediaInputError("Upload size must be positive");
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(chunkBytes) || chunkBytes <= 0) {
|
||||||
|
throw new InvalidMediaInputError("Chunk size must be positive");
|
||||||
|
}
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const asset = await tx.mediaAsset.findFirst({
|
||||||
|
where: { id: input.assetId, spaceId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!asset) throw new IsolationError();
|
||||||
|
await lockMediaKey(tx, `upload:${input.assetId}`);
|
||||||
|
const open = await tx.mediaUpload.findFirst({
|
||||||
|
where: {
|
||||||
|
spaceId,
|
||||||
|
assetId: input.assetId,
|
||||||
|
status: "open",
|
||||||
|
sha256: input.sha256,
|
||||||
|
sizeBytes: BigInt(input.sizeBytes),
|
||||||
|
chunkBytes,
|
||||||
|
},
|
||||||
|
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||||
|
});
|
||||||
|
if (open) return mapMediaUpload(open);
|
||||||
|
const row = await tx.mediaUpload.create({
|
||||||
|
data: {
|
||||||
|
spaceId,
|
||||||
|
assetId: input.assetId,
|
||||||
|
userId: input.userId,
|
||||||
|
sizeBytes: BigInt(input.sizeBytes),
|
||||||
|
chunkBytes,
|
||||||
|
sha256: input.sha256,
|
||||||
|
fileName: input.fileName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return mapMediaUpload(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireUpload(spaceId: string, uploadId: string) {
|
||||||
|
const row = await prisma.mediaUpload.findFirst({ where: { id: uploadId, spaceId } });
|
||||||
|
if (!row) throw new IsolationError();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadStatus(spaceId: string, uploadId: string): Promise<MediaUpload> {
|
||||||
|
return mapMediaUpload(await requireUpload(spaceId, uploadId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full row (sha256, file name, owner) for the upload route. */
|
||||||
|
async function getUploadRecord(spaceId: string, uploadId: string) {
|
||||||
|
return requireUpload(spaceId, uploadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Idempotent: the index is appended once, atomically, however often it is reported. */
|
||||||
|
async function markChunkReceived(
|
||||||
|
spaceId: string,
|
||||||
|
uploadId: string,
|
||||||
|
index: number,
|
||||||
|
): Promise<MediaUpload> {
|
||||||
|
const row = await requireUpload(spaceId, uploadId);
|
||||||
|
if (!Number.isInteger(index) || index < 0 || index >= chunkCountOf(row)) {
|
||||||
|
throw new InvalidMediaInputError("Chunk index out of range");
|
||||||
|
}
|
||||||
|
if (row.status !== "open") throw new MediaStateConflictError("Upload is not open");
|
||||||
|
await prisma.$executeRaw(Prisma.sql`
|
||||||
|
UPDATE media_uploads
|
||||||
|
SET "receivedChunks" = array_append("receivedChunks", ${index}::int), "updatedAt" = now()
|
||||||
|
WHERE id = ${uploadId} AND "spaceId" = ${spaceId} AND status = 'open'
|
||||||
|
AND NOT (${index}::int = ANY("receivedChunks"))
|
||||||
|
`);
|
||||||
|
return uploadStatus(spaceId, uploadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes a fully received upload and points the asset at the stored original. The
|
||||||
|
* caller has verified the assembled file against `sha256` before calling.
|
||||||
|
*/
|
||||||
|
async function completeUpload(
|
||||||
|
spaceId: string,
|
||||||
|
uploadId: string,
|
||||||
|
stored: { storagePath: string },
|
||||||
|
): Promise<MediaUpload> {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const row = await tx.mediaUpload.findFirst({ where: { id: uploadId, spaceId } });
|
||||||
|
if (!row) throw new IsolationError();
|
||||||
|
if (row.status === "complete") return mapMediaUpload(row);
|
||||||
|
if (row.status !== "open") throw new MediaStateConflictError("Upload is not open");
|
||||||
|
const received = new Set(row.receivedChunks);
|
||||||
|
for (let index = 0; index < chunkCountOf(row); index++) {
|
||||||
|
if (!received.has(index)) throw new MediaStateConflictError("Upload is missing chunks");
|
||||||
|
}
|
||||||
|
const updated = await tx.mediaUpload.update({
|
||||||
|
where: { id: row.id },
|
||||||
|
data: { status: "complete" },
|
||||||
|
});
|
||||||
|
await tx.mediaAsset.update({
|
||||||
|
where: { id: row.assetId },
|
||||||
|
data: {
|
||||||
|
storage: "uploaded",
|
||||||
|
storagePath: stored.storagePath,
|
||||||
|
contentHash: row.sha256,
|
||||||
|
sizeBytes: row.sizeBytes,
|
||||||
|
ingestState: "indexed",
|
||||||
|
ingestError: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return mapMediaUpload(updated);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function failUpload(spaceId: string, uploadId: string): Promise<MediaUpload> {
|
||||||
|
const result = await prisma.mediaUpload.updateMany({
|
||||||
|
where: { id: uploadId, spaceId, status: "open" },
|
||||||
|
data: { status: "failed" },
|
||||||
|
});
|
||||||
|
if (result.count !== 1) {
|
||||||
|
await requireUpload(spaceId, uploadId);
|
||||||
|
throw new MediaStateConflictError("Upload is not open");
|
||||||
|
}
|
||||||
|
return uploadStatus(spaceId, uploadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
beginUpload,
|
||||||
|
uploadStatus,
|
||||||
|
getUploadRecord,
|
||||||
|
markChunkReceived,
|
||||||
|
completeUpload,
|
||||||
|
failUpload,
|
||||||
|
};
|
||||||
|
}
|
||||||
782
packages/db/src/media.postgres.test.ts
Normal file
782
packages/db/src/media.postgres.test.ts
Normal file
@ -0,0 +1,782 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import type { MediaRepos } from "./media.js";
|
||||||
|
import { createMediaRepos } from "./media.js";
|
||||||
|
import type { MediaTestDb } from "./media-fixtures.js";
|
||||||
|
import { BASE_TIME, openMediaTestDb, sourceItem, vectorBytes } from "./media-fixtures.js";
|
||||||
|
import { InvalidMediaInputError, MediaStateConflictError } from "./media-shared.js";
|
||||||
|
import { IsolationError } from "./scope.js";
|
||||||
|
|
||||||
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
const describePostgres =
|
||||||
|
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
|
||||||
|
|
||||||
|
describePostgres("media library and organization (PostgreSQL)", () => {
|
||||||
|
let db: MediaTestDb;
|
||||||
|
let media: MediaRepos;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
db = openMediaTestDb(databaseUrl!);
|
||||||
|
media = createMediaRepos(db.prisma);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await db?.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function newLibrary(kind: "folder" | "ios_library" = "folder") {
|
||||||
|
const user = await db.newUser();
|
||||||
|
const source = await media.createSource(user.spaceId, {
|
||||||
|
kind,
|
||||||
|
label: "Library",
|
||||||
|
ownerUserId: user.userId,
|
||||||
|
config: kind === "folder" ? { path: "/media/library" } : { deviceId: "device-1" },
|
||||||
|
});
|
||||||
|
return { ...user, sourceId: source.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("indexes source items once and reports only real changes on the next sync", async () => {
|
||||||
|
const { spaceId, sourceId } = await newLibrary();
|
||||||
|
const items = Array.from({ length: 1000 }, (_, index) =>
|
||||||
|
sourceItem(index, { gpsLat: 48.208_49, gpsLon: 16.372_08 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const first = await media.upsertSourceItems(spaceId, sourceId, items);
|
||||||
|
expect(first).toMatchObject({ created: 1000, updated: 0, unchanged: 0 });
|
||||||
|
expect(new Set(first.changedAssetIds).size).toBe(1000);
|
||||||
|
|
||||||
|
const second = await media.upsertSourceItems(spaceId, sourceId, items);
|
||||||
|
expect(second).toEqual({ created: 0, updated: 0, unchanged: 1000, changedAssetIds: [] });
|
||||||
|
|
||||||
|
const edited = items.map((item, index) =>
|
||||||
|
index < 3 ? { ...item, revision: "r2", isFavorite: true } : item,
|
||||||
|
);
|
||||||
|
const third = await media.upsertSourceItems(spaceId, sourceId, edited);
|
||||||
|
expect(third).toMatchObject({ created: 0, updated: 3, unchanged: 997 });
|
||||||
|
expect(third.changedAssetIds).toEqual(first.changedAssetIds.slice(0, 3));
|
||||||
|
|
||||||
|
const asset = await media.getAssetRecord(spaceId, first.changedAssetIds[0] as string);
|
||||||
|
expect(asset).toMatchObject({
|
||||||
|
gpsLat: 48.21,
|
||||||
|
gpsLon: 16.37,
|
||||||
|
isFavorite: true,
|
||||||
|
revision: "r2",
|
||||||
|
storage: "referenced",
|
||||||
|
ingestState: "indexed",
|
||||||
|
});
|
||||||
|
const sources = await media.listSources(spaceId);
|
||||||
|
expect(sources).toHaveLength(1);
|
||||||
|
expect(sources[0]).toMatchObject({ id: sourceId, assetCount: 1000, status: "idle" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes concurrent syncs of one source without duplicate assets", async () => {
|
||||||
|
const { spaceId, sourceId } = await newLibrary();
|
||||||
|
const items = Array.from({ length: 50 }, (_, index) => sourceItem(index));
|
||||||
|
const results = await Promise.all([
|
||||||
|
media.upsertSourceItems(spaceId, sourceId, items),
|
||||||
|
media.upsertSourceItems(spaceId, sourceId, items),
|
||||||
|
]);
|
||||||
|
expect(results.map((result) => result.created).sort()).toEqual([0, 50]);
|
||||||
|
expect(await db.prisma.mediaAsset.count({ where: { sourceId } })).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks client-pushed assets as remote and waiting for their upload", async () => {
|
||||||
|
const { spaceId, sourceId } = await newLibrary("ios_library");
|
||||||
|
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
|
||||||
|
const asset = await media.getAssetRecord(spaceId, changedAssetIds[0] as string);
|
||||||
|
expect(asset).toMatchObject({ storage: "remote", ingestState: "awaiting_upload" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keyset pagination covers 1,200 assets exactly once while new assets arrive", async () => {
|
||||||
|
const { spaceId, sourceId } = await newLibrary();
|
||||||
|
// Repeated timestamps and undated assets exercise the (capturedAt, id) tie-break.
|
||||||
|
const items = Array.from({ length: 1200 }, (_, index) =>
|
||||||
|
sourceItem(index, {
|
||||||
|
capturedAt: index % 10 === 0 ? null : new Date(BASE_TIME + Math.floor(index / 4) * 1000),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, items);
|
||||||
|
const original = new Set(changedAssetIds);
|
||||||
|
|
||||||
|
const seen: string[] = [];
|
||||||
|
const order: Array<number | null> = [];
|
||||||
|
let cursor: string | null = null;
|
||||||
|
let page = 0;
|
||||||
|
do {
|
||||||
|
const result = await media.searchAssets(spaceId, { cursor, limit: 97 });
|
||||||
|
for (const asset of result.items) {
|
||||||
|
seen.push(asset.id);
|
||||||
|
order.push(asset.capturedAt ? Date.parse(asset.capturedAt) : null);
|
||||||
|
}
|
||||||
|
cursor = result.nextCursor;
|
||||||
|
page += 1;
|
||||||
|
// Newer, older and undated assets land between page reads.
|
||||||
|
await media.upsertSourceItems(spaceId, sourceId, [
|
||||||
|
sourceItem(10_000 + page * 3, { capturedAt: new Date(BASE_TIME + 10_000_000 + page) }),
|
||||||
|
sourceItem(10_001 + page * 3, { capturedAt: new Date(BASE_TIME - 10_000_000 - page) }),
|
||||||
|
sourceItem(10_002 + page * 3, { capturedAt: null }),
|
||||||
|
]);
|
||||||
|
} while (cursor);
|
||||||
|
|
||||||
|
expect(new Set(seen).size).toBe(seen.length);
|
||||||
|
for (const id of original) expect(seen).toContain(id);
|
||||||
|
// Order: capturedAt descending, undated last.
|
||||||
|
const firstNull = order.indexOf(null);
|
||||||
|
expect(order.slice(firstNull).every((value) => value === null)).toBe(true);
|
||||||
|
const dated = order.slice(0, firstNull) as number[];
|
||||||
|
for (let index = 1; index < dated.length; index++) {
|
||||||
|
expect(dated[index - 1]).toBeGreaterThanOrEqual(dated[index] as number);
|
||||||
|
}
|
||||||
|
await expect(media.searchAssets(spaceId, { cursor: "not-a-cursor" })).rejects.toBeInstanceOf(
|
||||||
|
InvalidMediaInputError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters search by kind, time, favorites, quality, place, text, person and collection", async () => {
|
||||||
|
const { spaceId, sourceId } = await newLibrary();
|
||||||
|
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [
|
||||||
|
sourceItem(1, {
|
||||||
|
kind: "video",
|
||||||
|
gpsLat: 48.2085,
|
||||||
|
gpsLon: 16.3721,
|
||||||
|
albumNames: ["Summer Trip"],
|
||||||
|
isFavorite: true,
|
||||||
|
}),
|
||||||
|
sourceItem(2, { kind: "photo", gpsLat: 48.137, gpsLon: 11.575, fileName: "beach_day.jpg" }),
|
||||||
|
sourceItem(3, { kind: "video", gpsLat: 48.25, gpsLon: 16.4 }),
|
||||||
|
sourceItem(4, { kind: "video", capturedAt: null, gpsLat: -16.5, gpsLon: 179.99 }),
|
||||||
|
sourceItem(5, { kind: "photo", gpsLat: -16.5, gpsLon: -179.99, fileName: "100%_done.jpg" }),
|
||||||
|
]);
|
||||||
|
const [vienna, munich, nearVienna, dateline, otherSide] = changedAssetIds as [
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
];
|
||||||
|
const ids = async (query: Parameters<MediaRepos["searchAssets"]>[1]) =>
|
||||||
|
(await media.searchAssets(spaceId, query)).items.map((asset) => asset.id).sort();
|
||||||
|
|
||||||
|
expect(await ids({ kinds: ["photo"] })).toEqual([munich, otherSide].sort());
|
||||||
|
expect(await ids({ favoritesOnly: true })).toEqual([vienna]);
|
||||||
|
expect(
|
||||||
|
await ids({
|
||||||
|
from: new Date(BASE_TIME + 2 * 60_000).toISOString(),
|
||||||
|
to: new Date(BASE_TIME + 3 * 60_000).toISOString(),
|
||||||
|
}),
|
||||||
|
).toEqual([munich, nearVienna].sort());
|
||||||
|
expect(await ids({ near: { lat: 48.2082, lon: 16.3738, radiusKm: 2 } })).toEqual([vienna]);
|
||||||
|
expect(await ids({ near: { lat: 48.2082, lon: 16.3738, radiusKm: 10 } })).toEqual(
|
||||||
|
[vienna, nearVienna].sort(),
|
||||||
|
);
|
||||||
|
expect(await ids({ near: { lat: -16.5, lon: 180, radiusKm: 5 } })).toEqual(
|
||||||
|
[dateline, otherSide].sort(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await media.recordAnalysisVersion(spaceId, munich, 1, 0.9);
|
||||||
|
await media.recordAnalysisVersion(spaceId, vienna, 1, 0.3);
|
||||||
|
expect(await ids({ minQuality: 0.5 })).toEqual([munich]);
|
||||||
|
|
||||||
|
expect(await ids({ text: "summer" })).toEqual([vienna]);
|
||||||
|
expect(await ids({ text: "BEACH_DAY" })).toEqual([munich]);
|
||||||
|
expect(await ids({ text: "100%" })).toEqual([otherSide]);
|
||||||
|
expect(await ids({ text: "%" })).toEqual([otherSide]);
|
||||||
|
|
||||||
|
await media.replaceSegments(spaceId, nearVienna, 1, [
|
||||||
|
{ kind: "shot", startMs: 0, endMs: 1000, labels: ["Sunset"], transcriptExcerpt: "welcome" },
|
||||||
|
]);
|
||||||
|
expect(await ids({ text: "sunset" })).toEqual([nearVienna]);
|
||||||
|
expect(await ids({ text: "WELCOME" })).toEqual([nearVienna]);
|
||||||
|
|
||||||
|
const collection = await media.createManualCollection(spaceId, "Harbour walk", [dateline]);
|
||||||
|
expect(await ids({ text: "harbour" })).toEqual([dateline]);
|
||||||
|
expect(await ids({ collectionId: collection.id })).toEqual([dateline]);
|
||||||
|
|
||||||
|
const [track] = await media.replaceFaceTracks(spaceId, munich, "faces-1", [
|
||||||
|
{ startMs: 0, endMs: 1000, confidenceMin: 0.8, keyframesPath: "k.json", embedding: null },
|
||||||
|
]);
|
||||||
|
const [person] = await media.createPersonSuggestions(spaceId, [
|
||||||
|
{ centroid: vectorBytes([1, 2]), trackIds: [track?.id as string] },
|
||||||
|
]);
|
||||||
|
expect(await ids({ personId: person?.id as string })).toEqual([munich]);
|
||||||
|
expect(await ids({ sourceId })).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns asset details and moves ingest state only from the expected state", async () => {
|
||||||
|
const { spaceId, sourceId } = await newLibrary();
|
||||||
|
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
|
||||||
|
const assetId = changedAssetIds[0] as string;
|
||||||
|
|
||||||
|
const claims = await Promise.all([
|
||||||
|
media.transitionIngestState(spaceId, assetId, { from: ["indexed"], to: "proxying" }),
|
||||||
|
media.transitionIngestState(spaceId, assetId, { from: ["indexed"], to: "proxying" }),
|
||||||
|
]);
|
||||||
|
expect(claims.filter(Boolean)).toHaveLength(1);
|
||||||
|
expect((await media.assetsNeedingWork(spaceId, "proxying", 10)).map((row) => row.id)).toEqual([
|
||||||
|
assetId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const proxied = await media.recordProxy(spaceId, assetId, {
|
||||||
|
contentHash: "f".repeat(64),
|
||||||
|
proxyPath: "proxy.mp4",
|
||||||
|
thumbPath: "thumb.jpg",
|
||||||
|
durationMs: 12_000,
|
||||||
|
});
|
||||||
|
expect(proxied).toMatchObject({ ingestState: "proxied", hasProxy: true, hasThumbnail: true });
|
||||||
|
expect(
|
||||||
|
await media.transitionIngestState(spaceId, assetId, {
|
||||||
|
from: ["proxied"],
|
||||||
|
to: "failed",
|
||||||
|
error: "sidecar offline",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(await media.getAssetRecord(spaceId, assetId)).toMatchObject({
|
||||||
|
ingestState: "failed",
|
||||||
|
ingestError: "sidecar offline",
|
||||||
|
durationMs: 12_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await media.upsertAnalysis(spaceId, assetId, {
|
||||||
|
kind: "transcript",
|
||||||
|
modelId: "whisper",
|
||||||
|
modelVersion: "1",
|
||||||
|
data: { language: "en", words: [{ text: "hello" }, { text: "there" }] },
|
||||||
|
});
|
||||||
|
await media.replaceSegments(spaceId, assetId, 1, [
|
||||||
|
{ kind: "scene", startMs: 2000, endMs: 4000, scores: { motion: 0.5 } },
|
||||||
|
{ kind: "shot", startMs: 0, endMs: 2000, teaserScore: 0.7 },
|
||||||
|
]);
|
||||||
|
await media.replaceFaceTracks(spaceId, assetId, "faces-1", [
|
||||||
|
{ startMs: 0, endMs: 500, confidenceMin: 0.6, keyframesPath: "a.json", embedding: null },
|
||||||
|
]);
|
||||||
|
const detail = await media.getAsset(spaceId, assetId);
|
||||||
|
expect(detail.transcript).toBe("hello there");
|
||||||
|
expect(detail.segments.map((segment) => segment.startMs)).toEqual([0, 2000]);
|
||||||
|
expect(detail.segments[1]?.scores).toEqual({ motion: 0.5 });
|
||||||
|
expect(detail.faceTracks).toHaveLength(1);
|
||||||
|
expect(await media.recordAnalysisVersion(spaceId, assetId, 2)).toMatchObject({
|
||||||
|
ingestState: "analyzed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps each analysis model version and returns the latest per kind", async () => {
|
||||||
|
const { spaceId, sourceId } = await newLibrary();
|
||||||
|
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
|
||||||
|
const assetId = changedAssetIds[0] as string;
|
||||||
|
const v1 = await media.upsertAnalysis(spaceId, assetId, {
|
||||||
|
kind: "embeddings",
|
||||||
|
modelId: "siglip",
|
||||||
|
modelVersion: "1",
|
||||||
|
vectors: vectorBytes([1, 2, 3]),
|
||||||
|
});
|
||||||
|
const again = await media.upsertAnalysis(spaceId, assetId, {
|
||||||
|
kind: "embeddings",
|
||||||
|
modelId: "siglip",
|
||||||
|
modelVersion: "1",
|
||||||
|
vectors: vectorBytes([4, 5, 6]),
|
||||||
|
});
|
||||||
|
expect(again.id).toBe(v1.id);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
await media.upsertAnalysis(spaceId, assetId, {
|
||||||
|
kind: "embeddings",
|
||||||
|
modelId: "siglip",
|
||||||
|
modelVersion: "2",
|
||||||
|
vectors: vectorBytes([7, 8, 9]),
|
||||||
|
});
|
||||||
|
expect(await db.prisma.mediaAnalysis.count({ where: { assetId } })).toBe(2);
|
||||||
|
const latest = await media.latestAnalysis(spaceId, assetId, "embeddings");
|
||||||
|
expect(latest?.modelVersion).toBe("2");
|
||||||
|
expect([...new Float32Array(new Uint8Array(latest?.vectors as Uint8Array).buffer)]).toEqual([
|
||||||
|
7, 8, 9,
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
(await media.latestAnalysesByKind(spaceId, "embeddings")).get(assetId)?.modelVersion,
|
||||||
|
).toBe("2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces segments atomically and flags paid-only segments all or nothing", async () => {
|
||||||
|
const { spaceId, sourceId } = await newLibrary();
|
||||||
|
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
|
||||||
|
const assetId = changedAssetIds[0] as string;
|
||||||
|
await media.replaceSegments(spaceId, assetId, 1, [
|
||||||
|
{ kind: "shot", startMs: 0, endMs: 1000 },
|
||||||
|
{ kind: "shot", startMs: 1000, endMs: 2000 },
|
||||||
|
]);
|
||||||
|
await expect(
|
||||||
|
media.replaceSegments(spaceId, assetId, 2, [
|
||||||
|
{ kind: "shot", startMs: 0, endMs: 1000 },
|
||||||
|
{ kind: "shot", startMs: 1000, endMs: 1000 },
|
||||||
|
]),
|
||||||
|
).rejects.toBeInstanceOf(InvalidMediaInputError);
|
||||||
|
expect(await media.listSegments(spaceId, [assetId])).toHaveLength(2);
|
||||||
|
|
||||||
|
await media.replaceSegments(spaceId, assetId, 2, [{ kind: "scene", startMs: 0, endMs: 3000 }]);
|
||||||
|
const segments = await media.listSegments(spaceId, [assetId]);
|
||||||
|
expect(segments).toHaveLength(1);
|
||||||
|
const segmentId = segments[0]?.id as string;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
media.setSegmentsPaidOnly(spaceId, [segmentId, "missing"], true),
|
||||||
|
).rejects.toBeInstanceOf(IsolationError);
|
||||||
|
expect((await media.listSegments(spaceId, [assetId]))[0]?.paidOnly).toBe(false);
|
||||||
|
expect(await media.setSegmentsPaidOnly(spaceId, [segmentId], true)).toBe(1);
|
||||||
|
expect((await media.listSegments(spaceId, [assetId]))[0]?.paidOnly).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-clustering keeps user titles and manual collections and drops stale clusters", async () => {
|
||||||
|
const { spaceId, sourceId } = await newLibrary();
|
||||||
|
const { changedAssetIds } = await media.upsertSourceItems(
|
||||||
|
spaceId,
|
||||||
|
sourceId,
|
||||||
|
Array.from({ length: 6 }, (_, index) => sourceItem(index + 1)),
|
||||||
|
);
|
||||||
|
const [a1, a2, a3, a4, a5, a6] = changedAssetIds as string[];
|
||||||
|
const cluster = (key: string, title: string, assetIds: string[]) => ({
|
||||||
|
key,
|
||||||
|
title,
|
||||||
|
labelSource: "rule" as const,
|
||||||
|
startAt: new Date(BASE_TIME),
|
||||||
|
endAt: new Date(BASE_TIME + 1000),
|
||||||
|
centroid: { lat: 48.20849, lon: 16.37208 },
|
||||||
|
coverAssetId: assetIds[0] ?? null,
|
||||||
|
assetIds,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await media.replaceClusters(spaceId, "session", [
|
||||||
|
cluster("s1", "Morning", [a1 as string, a2 as string]),
|
||||||
|
cluster("s2", "Noon", [a3 as string]),
|
||||||
|
cluster("s3", "Evening", [a4 as string]),
|
||||||
|
]),
|
||||||
|
).toEqual({ created: 3, updated: 0, deleted: 0 });
|
||||||
|
const manual = await media.createManualCollection(spaceId, "Favourites", [
|
||||||
|
a6 as string,
|
||||||
|
a1 as string,
|
||||||
|
]);
|
||||||
|
const first = (await media.listCollections(spaceId, { kind: "session" })).items;
|
||||||
|
const morning = first.find((collection) => collection.title === "Morning");
|
||||||
|
const evening = first.find((collection) => collection.title === "Evening");
|
||||||
|
await media.renameCollection(spaceId, morning?.id as string, "Market visit");
|
||||||
|
await media.renameCollection(spaceId, evening?.id as string, "Dinner");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await media.replaceClusters(spaceId, "session", [
|
||||||
|
cluster("s1", "Morning again", [a1 as string, a3 as string, a5 as string]),
|
||||||
|
cluster("s4", "Night", [a2 as string]),
|
||||||
|
]),
|
||||||
|
).toEqual({ created: 1, updated: 1, deleted: 1 });
|
||||||
|
|
||||||
|
const after = await media.listCollections(spaceId, {});
|
||||||
|
const byTitle = new Map(after.items.map((collection) => [collection.title, collection]));
|
||||||
|
expect([...byTitle.keys()].sort()).toEqual(
|
||||||
|
["Dinner", "Favourites", "Market visit", "Night"].sort(),
|
||||||
|
);
|
||||||
|
expect(byTitle.get("Market visit")).toMatchObject({
|
||||||
|
id: morning?.id,
|
||||||
|
labelSource: "user",
|
||||||
|
assetCount: 3,
|
||||||
|
videoCount: 2,
|
||||||
|
totalDurationMs: 20_000,
|
||||||
|
});
|
||||||
|
const manualAfter = await media.getCollection(spaceId, manual.id);
|
||||||
|
expect(manualAfter.collection).toMatchObject({ kind: "manual", title: "Favourites" });
|
||||||
|
expect(manualAfter.assets.map((asset) => asset.id)).toEqual([a1, a6]);
|
||||||
|
const stored = await db.prisma.mediaCollection.findUniqueOrThrow({
|
||||||
|
where: { id: morning?.id as string },
|
||||||
|
});
|
||||||
|
expect(stored).toMatchObject({ centroidLat: 48.21, centroidLon: 16.37 });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
media.replaceClusters(spaceId, "session", [cluster("x", "Bad", ["foreign-asset"])]),
|
||||||
|
).rejects.toBeInstanceOf(IsolationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists, merges, splits and creates collections", async () => {
|
||||||
|
const { spaceId, sourceId } = await newLibrary();
|
||||||
|
const { changedAssetIds } = await media.upsertSourceItems(
|
||||||
|
spaceId,
|
||||||
|
sourceId,
|
||||||
|
Array.from({ length: 5 }, (_, index) => sourceItem(index + 1)),
|
||||||
|
);
|
||||||
|
const ids = changedAssetIds as string[];
|
||||||
|
const clusters = ids.map((assetId, index) => ({
|
||||||
|
key: `t${index}`,
|
||||||
|
title: `Theme ${index}`,
|
||||||
|
labelSource: "model" as const,
|
||||||
|
startAt: index === 4 ? null : new Date(BASE_TIME + index * 1000),
|
||||||
|
endAt: null,
|
||||||
|
centroid: null,
|
||||||
|
coverAssetId: assetId,
|
||||||
|
assetIds: [assetId],
|
||||||
|
}));
|
||||||
|
await media.replaceClusters(spaceId, "theme", clusters);
|
||||||
|
|
||||||
|
const seen: string[] = [];
|
||||||
|
let cursor: string | null = null;
|
||||||
|
do {
|
||||||
|
const page = await media.listCollections(spaceId, { kind: "theme", cursor, limit: 2 });
|
||||||
|
seen.push(...page.items.map((collection) => collection.title));
|
||||||
|
cursor = page.nextCursor;
|
||||||
|
} while (cursor);
|
||||||
|
expect(seen).toEqual(["Theme 3", "Theme 2", "Theme 1", "Theme 0", "Theme 4"]);
|
||||||
|
|
||||||
|
const all = (await media.listCollections(spaceId, { kind: "theme" })).items;
|
||||||
|
const merged = await media.mergeCollections(
|
||||||
|
spaceId,
|
||||||
|
all.slice(0, 3).map((collection) => collection.id),
|
||||||
|
);
|
||||||
|
expect(merged).toMatchObject({ id: all[0]?.id, assetCount: 3, labelSource: "user" });
|
||||||
|
expect((await media.listCollections(spaceId, { kind: "theme" })).items).toHaveLength(3);
|
||||||
|
const mergedAssets = (await media.getCollection(spaceId, merged.id)).assets.map(
|
||||||
|
(asset) => asset.id,
|
||||||
|
);
|
||||||
|
expect(mergedAssets).toEqual([ids[1], ids[2], ids[3]]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
media.splitCollection(spaceId, merged.id, ids[1] as string),
|
||||||
|
).rejects.toBeInstanceOf(InvalidMediaInputError);
|
||||||
|
const [kept, split] = await media.splitCollection(spaceId, merged.id, ids[2] as string);
|
||||||
|
expect(kept).toMatchObject({ id: merged.id, assetCount: 1 });
|
||||||
|
expect(split).toMatchObject({ assetCount: 2, kind: "theme" });
|
||||||
|
expect(
|
||||||
|
await media.collectionAssetIds(spaceId, [split?.id as string, kept?.id as string]),
|
||||||
|
).toEqual([ids[2], ids[3], ids[1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suggests, merges and assigns people and records consent only for a member", async () => {
|
||||||
|
const { spaceId, userId, sourceId } = await newLibrary();
|
||||||
|
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [
|
||||||
|
sourceItem(1),
|
||||||
|
sourceItem(2),
|
||||||
|
]);
|
||||||
|
const [assetA, assetB] = changedAssetIds as [string, string];
|
||||||
|
const tracksA = await media.replaceFaceTracks(spaceId, assetA, "faces-1", [
|
||||||
|
{
|
||||||
|
startMs: 0,
|
||||||
|
endMs: 1000,
|
||||||
|
confidenceMin: 0.9,
|
||||||
|
keyframesPath: "a1",
|
||||||
|
embedding: vectorBytes([1, 0]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
startMs: 0,
|
||||||
|
endMs: 1000,
|
||||||
|
confidenceMin: 0.7,
|
||||||
|
keyframesPath: "a2",
|
||||||
|
embedding: vectorBytes([0, 1]),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const tracksB = await media.replaceFaceTracks(spaceId, assetB, "faces-1", [
|
||||||
|
{
|
||||||
|
startMs: 0,
|
||||||
|
endMs: 1000,
|
||||||
|
confidenceMin: 0.8,
|
||||||
|
keyframesPath: "b1",
|
||||||
|
embedding: vectorBytes([1, 0]),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(await media.faceTrackEmbeddings(spaceId, { unassignedOnly: true })).toHaveLength(3);
|
||||||
|
|
||||||
|
const people = await media.createPersonSuggestions(spaceId, [
|
||||||
|
{
|
||||||
|
centroid: vectorBytes([1, 0]),
|
||||||
|
trackIds: [tracksA[0]?.id as string, tracksB[0]?.id as string],
|
||||||
|
},
|
||||||
|
{ centroid: vectorBytes([0, 1]), trackIds: [tracksA[1]?.id as string] },
|
||||||
|
]);
|
||||||
|
expect(people.map((person) => person.displayName)).toEqual(["Person 1", "Person 2"]);
|
||||||
|
expect(people[0]).toMatchObject({
|
||||||
|
faceTrackCount: 2,
|
||||||
|
confirmed: false,
|
||||||
|
coverThumbAssetId: assetA,
|
||||||
|
});
|
||||||
|
expect(await media.faceTrackEmbeddings(spaceId, { unassignedOnly: true })).toHaveLength(0);
|
||||||
|
expect(await media.personCentroids(spaceId)).toHaveLength(2);
|
||||||
|
|
||||||
|
const [first, second] = people as [(typeof people)[0], (typeof people)[0]];
|
||||||
|
await media.updatePerson(spaceId, first.id, { isSelf: true });
|
||||||
|
await media.updatePerson(spaceId, second.id, { isSelf: true, displayName: "Me" });
|
||||||
|
const listed = await media.listPeople(spaceId);
|
||||||
|
expect(listed.filter((person) => person.isSelf).map((person) => person.id)).toEqual([
|
||||||
|
second.id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const mark = await media.createMark(spaceId, {
|
||||||
|
kind: "tattoo",
|
||||||
|
label: null,
|
||||||
|
personId: second.id,
|
||||||
|
assetId: assetA,
|
||||||
|
prompt: { tMs: 0, box: { x: 0.1, y: 0.1, w: 0.2, h: 0.2 } },
|
||||||
|
treatment: "inpaint",
|
||||||
|
});
|
||||||
|
const merged = await media.mergePeople(spaceId, [first.id, second.id]);
|
||||||
|
expect(merged).toMatchObject({ id: first.id, faceTrackCount: 3, confirmed: true });
|
||||||
|
expect((await media.getMark(spaceId, mark.id)).personId).toBe(first.id);
|
||||||
|
expect(await media.listPeople(spaceId)).toHaveLength(1);
|
||||||
|
|
||||||
|
const reassigned = await media.assignTrack(spaceId, tracksB[0]?.id as string, null);
|
||||||
|
expect(reassigned.personId).toBeNull();
|
||||||
|
await expect(
|
||||||
|
media.assignTrack(spaceId, tracksB[0]?.id as string, "missing-person"),
|
||||||
|
).rejects.toBeInstanceOf(IsolationError);
|
||||||
|
|
||||||
|
const consent = {
|
||||||
|
personId: first.id,
|
||||||
|
consentArtifactId: "artifact-1",
|
||||||
|
consentRecordedAt: new Date(BASE_TIME).toISOString(),
|
||||||
|
ageVerificationMethod: "document_on_file" as const,
|
||||||
|
ageVerifiedAt: new Date(BASE_TIME).toISOString(),
|
||||||
|
};
|
||||||
|
await expect(
|
||||||
|
media.recordConsent({ spaceId, recordedByUserId: "" }, consent),
|
||||||
|
).rejects.toBeInstanceOf(InvalidMediaInputError);
|
||||||
|
await expect(
|
||||||
|
media.recordConsent({ spaceId, recordedByUserId: "not-a-member" }, consent),
|
||||||
|
).rejects.toBeInstanceOf(IsolationError);
|
||||||
|
await expect(
|
||||||
|
media.recordConsent(
|
||||||
|
{ spaceId, recordedByUserId: userId },
|
||||||
|
{ ...consent, ageVerifiedAt: null },
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(InvalidMediaInputError);
|
||||||
|
const withConsent = await media.recordConsent({ spaceId, recordedByUserId: userId }, consent);
|
||||||
|
expect(withConsent).toMatchObject({
|
||||||
|
consentArtifactId: "artifact-1",
|
||||||
|
ageVerificationMethod: "document_on_file",
|
||||||
|
consentRecordedAt: consent.consentRecordedAt,
|
||||||
|
});
|
||||||
|
const record = await db.prisma.mediaPerson.findUniqueOrThrow({ where: { id: first.id } });
|
||||||
|
expect(record.consentRecordedBy).toBe(userId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removing a person leaves no biometric bytes behind", async () => {
|
||||||
|
const { spaceId, sourceId } = await newLibrary();
|
||||||
|
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
|
||||||
|
const assetId = changedAssetIds[0] as string;
|
||||||
|
const tracks = await media.replaceFaceTracks(spaceId, assetId, "faces-1", [
|
||||||
|
{
|
||||||
|
startMs: 0,
|
||||||
|
endMs: 1000,
|
||||||
|
confidenceMin: 0.9,
|
||||||
|
keyframesPath: "k1",
|
||||||
|
embedding: vectorBytes([1, 2]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
startMs: 0,
|
||||||
|
endMs: 1000,
|
||||||
|
confidenceMin: 0.9,
|
||||||
|
keyframesPath: "k2",
|
||||||
|
embedding: vectorBytes([3, 4]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
startMs: 0,
|
||||||
|
endMs: 1000,
|
||||||
|
confidenceMin: 0.9,
|
||||||
|
keyframesPath: "k3",
|
||||||
|
embedding: vectorBytes([5, 6]),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const [person, bystander] = await media.createPersonSuggestions(spaceId, [
|
||||||
|
{
|
||||||
|
centroid: vectorBytes([2, 3]),
|
||||||
|
trackIds: [tracks[0]?.id as string, tracks[1]?.id as string],
|
||||||
|
},
|
||||||
|
{ centroid: vectorBytes([5, 6]), trackIds: [tracks[2]?.id as string] },
|
||||||
|
]);
|
||||||
|
const personId = person?.id as string;
|
||||||
|
const mark = await media.createMark(spaceId, {
|
||||||
|
kind: "birthmark",
|
||||||
|
label: null,
|
||||||
|
personId,
|
||||||
|
assetId,
|
||||||
|
prompt: { tMs: 0 },
|
||||||
|
treatment: "blur",
|
||||||
|
});
|
||||||
|
await media.recordMarkResult(spaceId, mark.id, {
|
||||||
|
qualityScore: 0.4,
|
||||||
|
suggestedFallback: "cover",
|
||||||
|
maskDir: "masks/m1",
|
||||||
|
});
|
||||||
|
const assetMark = await media.createMark(spaceId, {
|
||||||
|
kind: "license_plate",
|
||||||
|
label: null,
|
||||||
|
personId: null,
|
||||||
|
assetId,
|
||||||
|
prompt: { tMs: 0 },
|
||||||
|
treatment: "blur",
|
||||||
|
});
|
||||||
|
|
||||||
|
const removed = await media.removePersonCompletely(spaceId, personId);
|
||||||
|
expect(removed).toEqual({
|
||||||
|
deletedFaceTracks: 2,
|
||||||
|
deletedMarks: 1,
|
||||||
|
keyframesPaths: expect.arrayContaining(["k1", "k2"]),
|
||||||
|
maskDirs: ["masks/m1"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const people = await db.prisma.$queryRaw<Array<{ id: string; centroid: Uint8Array | null }>>`
|
||||||
|
SELECT id, centroid FROM media_people WHERE "spaceId" = ${spaceId}`;
|
||||||
|
expect(people).toEqual([{ id: bystander?.id, centroid: expect.anything() }]);
|
||||||
|
const personRows = await db.prisma.$queryRaw<Array<{ count: number }>>`
|
||||||
|
SELECT count(*)::int AS count FROM media_people WHERE id = ${personId}`;
|
||||||
|
expect(personRows[0]?.count).toBe(0);
|
||||||
|
const linked = await db.prisma.$queryRaw<Array<{ count: number }>>`
|
||||||
|
SELECT count(*)::int AS count FROM media_face_tracks
|
||||||
|
WHERE "personId" = ${personId} OR id IN (${tracks[0]?.id}, ${tracks[1]?.id})`;
|
||||||
|
expect(linked[0]?.count).toBe(0);
|
||||||
|
const embeddings = await db.prisma.$queryRaw<Array<{ id: string }>>`
|
||||||
|
SELECT id FROM media_face_tracks WHERE "spaceId" = ${spaceId} AND embedding IS NOT NULL`;
|
||||||
|
expect(embeddings.map((row) => row.id)).toEqual([tracks[2]?.id]);
|
||||||
|
const marks = await db.prisma.$queryRaw<Array<{ id: string }>>`
|
||||||
|
SELECT id FROM media_marks WHERE "spaceId" = ${spaceId}`;
|
||||||
|
expect(marks.map((row) => row.id)).toEqual([assetMark.id]);
|
||||||
|
await expect(media.removePersonCompletely(spaceId, personId)).rejects.toBeInstanceOf(
|
||||||
|
IsolationError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("manages marks per asset and per person", async () => {
|
||||||
|
const { spaceId, sourceId } = await newLibrary();
|
||||||
|
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [
|
||||||
|
sourceItem(1),
|
||||||
|
sourceItem(2),
|
||||||
|
]);
|
||||||
|
const [assetA, assetB] = changedAssetIds as [string, string];
|
||||||
|
const [trackB] = await media.replaceFaceTracks(spaceId, assetB, "faces-1", [
|
||||||
|
{ startMs: 0, endMs: 1000, confidenceMin: 0.9, keyframesPath: "k", embedding: null },
|
||||||
|
]);
|
||||||
|
const [person] = await media.createPersonSuggestions(spaceId, [
|
||||||
|
{ centroid: vectorBytes([1]), trackIds: [trackB?.id as string] },
|
||||||
|
]);
|
||||||
|
const personMark = await media.createMark(spaceId, {
|
||||||
|
kind: "tattoo",
|
||||||
|
label: "left forearm",
|
||||||
|
personId: person?.id as string,
|
||||||
|
assetId: assetA,
|
||||||
|
prompt: { tMs: 500, point: { x: 0.2, y: 0.3 } },
|
||||||
|
treatment: "inpaint",
|
||||||
|
});
|
||||||
|
const plate = await media.createMark(spaceId, {
|
||||||
|
kind: "license_plate",
|
||||||
|
label: null,
|
||||||
|
personId: null,
|
||||||
|
assetId: assetB,
|
||||||
|
prompt: { tMs: 0, box: { x: 0, y: 0, w: 0.1, h: 0.1 } },
|
||||||
|
treatment: "blur",
|
||||||
|
});
|
||||||
|
expect((await media.listMarks(spaceId, { personId: person?.id })).map((m) => m.id)).toEqual([
|
||||||
|
personMark.id,
|
||||||
|
]);
|
||||||
|
expect((await media.listMarks(spaceId, { assetId: assetB })).map((m) => m.id)).toEqual([
|
||||||
|
plate.id,
|
||||||
|
]);
|
||||||
|
// Person marks follow the person into every asset they appear in.
|
||||||
|
expect((await media.marksForAsset(spaceId, assetB)).map((m) => m.id).sort()).toEqual(
|
||||||
|
[personMark.id, plate.id].sort(),
|
||||||
|
);
|
||||||
|
const updated = await media.updateMark(spaceId, plate.id, { treatment: "cover", label: "car" });
|
||||||
|
expect(updated).toMatchObject({ treatment: "cover", label: "car" });
|
||||||
|
const result = await media.recordMarkResult(spaceId, personMark.id, {
|
||||||
|
qualityScore: 0.2,
|
||||||
|
suggestedFallback: "cover",
|
||||||
|
maskDir: "masks/tattoo",
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
qualityScore: 0.2,
|
||||||
|
suggestedFallback: "cover",
|
||||||
|
maskDir: "masks/tattoo",
|
||||||
|
prompt: { tMs: 500, point: { x: 0.2, y: 0.3 } },
|
||||||
|
});
|
||||||
|
expect(await media.removeMark(spaceId, personMark.id)).toEqual({ maskDir: "masks/tattoo" });
|
||||||
|
expect(await media.listMarks(spaceId)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uploads chunks idempotently, resumes, and completes only when every chunk arrived", async () => {
|
||||||
|
const { spaceId, userId, sourceId } = await newLibrary("ios_library");
|
||||||
|
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
|
||||||
|
const assetId = changedAssetIds[0] as string;
|
||||||
|
const begin = {
|
||||||
|
assetId,
|
||||||
|
userId,
|
||||||
|
sizeBytes: 10,
|
||||||
|
sha256: "c".repeat(64),
|
||||||
|
fileName: "clip.mov",
|
||||||
|
chunkBytes: 4,
|
||||||
|
};
|
||||||
|
const upload = await media.beginUpload(spaceId, begin);
|
||||||
|
expect(upload).toMatchObject({
|
||||||
|
sizeBytes: 10,
|
||||||
|
chunkBytes: 4,
|
||||||
|
receivedChunks: [],
|
||||||
|
status: "open",
|
||||||
|
});
|
||||||
|
expect((await media.beginUpload(spaceId, begin)).id).toBe(upload.id);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: 6 }, () => media.markChunkReceived(spaceId, upload.id, 1)),
|
||||||
|
);
|
||||||
|
await media.markChunkReceived(spaceId, upload.id, 0);
|
||||||
|
expect((await media.uploadStatus(spaceId, upload.id)).receivedChunks).toEqual([0, 1]);
|
||||||
|
await expect(media.markChunkReceived(spaceId, upload.id, 3)).rejects.toBeInstanceOf(
|
||||||
|
InvalidMediaInputError,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
media.completeUpload(spaceId, upload.id, { storagePath: "uploads/clip.mov" }),
|
||||||
|
).rejects.toBeInstanceOf(MediaStateConflictError);
|
||||||
|
|
||||||
|
await media.markChunkReceived(spaceId, upload.id, 2);
|
||||||
|
const done = await media.completeUpload(spaceId, upload.id, {
|
||||||
|
storagePath: "uploads/clip.mov",
|
||||||
|
});
|
||||||
|
expect(done).toMatchObject({ status: "complete", receivedChunks: [0, 1, 2] });
|
||||||
|
expect(await media.getAssetRecord(spaceId, assetId)).toMatchObject({
|
||||||
|
storage: "uploaded",
|
||||||
|
storagePath: "uploads/clip.mov",
|
||||||
|
contentHash: "c".repeat(64),
|
||||||
|
ingestState: "indexed",
|
||||||
|
});
|
||||||
|
await expect(media.markChunkReceived(spaceId, upload.id, 0)).rejects.toBeInstanceOf(
|
||||||
|
MediaStateConflictError,
|
||||||
|
);
|
||||||
|
expect((await media.beginUpload(spaceId, begin)).id).not.toBe(upload.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requests uploads only for remote assets", async () => {
|
||||||
|
const remote = await newLibrary("ios_library");
|
||||||
|
const { changedAssetIds } = await media.upsertSourceItems(remote.spaceId, remote.sourceId, [
|
||||||
|
sourceItem(1),
|
||||||
|
]);
|
||||||
|
const remoteId = changedAssetIds[0] as string;
|
||||||
|
await media.transitionIngestState(remote.spaceId, remoteId, {
|
||||||
|
from: ["awaiting_upload"],
|
||||||
|
to: "failed",
|
||||||
|
});
|
||||||
|
expect(await media.requestUpload(remote.spaceId, [remoteId])).toEqual([remoteId]);
|
||||||
|
|
||||||
|
const folder = await newLibrary();
|
||||||
|
const local = await media.upsertSourceItems(folder.spaceId, folder.sourceId, [sourceItem(1)]);
|
||||||
|
expect(await media.requestUpload(folder.spaceId, local.changedAssetIds)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates and removes a source with everything indexed from it", async () => {
|
||||||
|
const { spaceId, sourceId } = await newLibrary();
|
||||||
|
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
|
||||||
|
const synced = await media.updateSourceSync(spaceId, sourceId, {
|
||||||
|
status: "syncing",
|
||||||
|
lastSyncCursor: "page-2",
|
||||||
|
lastSyncedAt: new Date(BASE_TIME),
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
expect(synced).toMatchObject({
|
||||||
|
status: "syncing",
|
||||||
|
lastSyncCursor: "page-2",
|
||||||
|
config: { path: "/media/library" },
|
||||||
|
assetCount: 1,
|
||||||
|
});
|
||||||
|
await media.replaceSegments(spaceId, changedAssetIds[0] as string, 1, [
|
||||||
|
{ kind: "shot", startMs: 0, endMs: 1 },
|
||||||
|
]);
|
||||||
|
await media.removeSource(spaceId, sourceId);
|
||||||
|
expect(await media.listSources(spaceId)).toEqual([]);
|
||||||
|
expect(await db.prisma.mediaAsset.count({ where: { sourceId } })).toBe(0);
|
||||||
|
expect(await db.prisma.mediaSegment.count({ where: { spaceId } })).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
272
packages/db/src/media.test.ts
Normal file
272
packages/db/src/media.test.ts
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
import type { ClientMediaItem } from "@rakazo/contracts";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type {
|
||||||
|
MediaAsset as MediaAssetRow,
|
||||||
|
MediaMark as MediaMarkRow,
|
||||||
|
MediaRender as MediaRenderRow,
|
||||||
|
MediaUpload as MediaUploadRow,
|
||||||
|
} from "./client.js";
|
||||||
|
import {
|
||||||
|
clientMediaItemToSourceItem,
|
||||||
|
decodeKeysetCursor,
|
||||||
|
encodeKeysetCursor,
|
||||||
|
InvalidMediaInputError,
|
||||||
|
likeContains,
|
||||||
|
mapMediaAsset,
|
||||||
|
mapMediaMark,
|
||||||
|
mapMediaRender,
|
||||||
|
mapMediaSegment,
|
||||||
|
mapMediaUpload,
|
||||||
|
pageLimit,
|
||||||
|
toPage,
|
||||||
|
} from "./media.js";
|
||||||
|
|
||||||
|
const at = new Date("2026-01-01T08:00:00.000Z");
|
||||||
|
|
||||||
|
describe("media keyset cursors", () => {
|
||||||
|
it("round-trips dated and undated positions as opaque base64url", () => {
|
||||||
|
for (const cursor of [
|
||||||
|
{ at, id: "asset-1" },
|
||||||
|
{ at: null, id: "asset-2" },
|
||||||
|
]) {
|
||||||
|
const token = encodeKeysetCursor(cursor);
|
||||||
|
expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||||
|
expect(decodeKeysetCursor(token)).toEqual(cursor);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects tampered or malformed cursors", () => {
|
||||||
|
const bad = [
|
||||||
|
"not json",
|
||||||
|
Buffer.from("{}").toString("base64url"),
|
||||||
|
Buffer.from(JSON.stringify(["2026-01-01T00:00:00Z"])).toString("base64url"),
|
||||||
|
Buffer.from(JSON.stringify(["yesterday", "id"])).toString("base64url"),
|
||||||
|
Buffer.from(JSON.stringify([null, ""])).toString("base64url"),
|
||||||
|
Buffer.from(JSON.stringify([42, "id"])).toString("base64url"),
|
||||||
|
];
|
||||||
|
for (const token of bad) {
|
||||||
|
expect(() => decodeKeysetCursor(token), token).toThrow(InvalidMediaInputError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("slices a limit+1 result into a page with the last row as cursor", () => {
|
||||||
|
const rows = [
|
||||||
|
{ id: "c", at },
|
||||||
|
{ id: "b", at: null },
|
||||||
|
{ id: "a", at: null },
|
||||||
|
];
|
||||||
|
const page = toPage(
|
||||||
|
rows,
|
||||||
|
2,
|
||||||
|
(row) => ({ at: row.at, id: row.id }),
|
||||||
|
(row) => row.id,
|
||||||
|
);
|
||||||
|
expect(page.items).toEqual(["c", "b"]);
|
||||||
|
expect(decodeKeysetCursor(page.nextCursor as string)).toEqual({ at: null, id: "b" });
|
||||||
|
expect(
|
||||||
|
toPage(
|
||||||
|
rows,
|
||||||
|
3,
|
||||||
|
(row) => ({ at: row.at, id: row.id }),
|
||||||
|
(row) => row.id,
|
||||||
|
).nextCursor,
|
||||||
|
).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps page sizes and escapes LIKE wildcards", () => {
|
||||||
|
expect(pageLimit(undefined)).toBe(100);
|
||||||
|
expect(pageLimit(0)).toBe(1);
|
||||||
|
expect(pageLimit(10_000)).toBe(500);
|
||||||
|
expect(likeContains("50%_off\\")).toBe("%50\\%\\_off\\\\%");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("media DTO mappers", () => {
|
||||||
|
const asset: MediaAssetRow = {
|
||||||
|
id: "asset-1",
|
||||||
|
spaceId: "space-1",
|
||||||
|
sourceId: "source-1",
|
||||||
|
externalId: "a.mp4",
|
||||||
|
revision: "r1",
|
||||||
|
contentHash: "hash",
|
||||||
|
kind: "video",
|
||||||
|
storage: "referenced",
|
||||||
|
storagePath: "originals/a.mp4",
|
||||||
|
fileName: "a.mp4",
|
||||||
|
sizeBytes: 10n,
|
||||||
|
capturedAt: at,
|
||||||
|
durationMs: 1000,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
fps: 30,
|
||||||
|
codec: "h264",
|
||||||
|
orientation: 0,
|
||||||
|
gpsLat: 48.21,
|
||||||
|
gpsLon: 16.37,
|
||||||
|
deviceModel: "Camera",
|
||||||
|
albumNames: ["Trip"],
|
||||||
|
isFavorite: true,
|
||||||
|
proxyPath: "proxy.mp4",
|
||||||
|
thumbPath: null,
|
||||||
|
spritePath: null,
|
||||||
|
qualityScore: 0.5,
|
||||||
|
analysisVersion: 1,
|
||||||
|
ingestState: "proxied",
|
||||||
|
ingestError: null,
|
||||||
|
createdAt: at,
|
||||||
|
updatedAt: at,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("maps assets without leaking paths or hashes", () => {
|
||||||
|
const dto = mapMediaAsset(asset);
|
||||||
|
expect(dto).toEqual({
|
||||||
|
id: "asset-1",
|
||||||
|
sourceId: "source-1",
|
||||||
|
kind: "video",
|
||||||
|
storage: "referenced",
|
||||||
|
capturedAt: at.toISOString(),
|
||||||
|
durationMs: 1000,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
fps: 30,
|
||||||
|
gpsLat: 48.21,
|
||||||
|
gpsLon: 16.37,
|
||||||
|
deviceModel: "Camera",
|
||||||
|
albumNames: ["Trip"],
|
||||||
|
isFavorite: true,
|
||||||
|
ingestState: "proxied",
|
||||||
|
hasProxy: true,
|
||||||
|
hasThumbnail: false,
|
||||||
|
qualityScore: 0.5,
|
||||||
|
createdAt: at.toISOString(),
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(dto)).not.toContain("proxy.mp4");
|
||||||
|
expect(JSON.stringify(dto)).not.toContain("originals");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps only numeric segment scores", () => {
|
||||||
|
const segment = mapMediaSegment({
|
||||||
|
id: "seg-1",
|
||||||
|
spaceId: "space-1",
|
||||||
|
assetId: "asset-1",
|
||||||
|
kind: "shot",
|
||||||
|
startMs: 0,
|
||||||
|
endMs: 100,
|
||||||
|
scores: { motion: 0.4, faces: "many", sharpness: null },
|
||||||
|
teaserScore: null,
|
||||||
|
transcriptExcerpt: null,
|
||||||
|
labels: [],
|
||||||
|
paidOnly: true,
|
||||||
|
analysisVersion: 1,
|
||||||
|
createdAt: at,
|
||||||
|
});
|
||||||
|
expect(segment.scores).toEqual({ motion: 0.4 });
|
||||||
|
expect(segment.paidOnly).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops internal render output paths and invalid reports", () => {
|
||||||
|
const row: MediaRenderRow = {
|
||||||
|
id: "render-1",
|
||||||
|
spaceId: "space-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
planId: "plan-1",
|
||||||
|
profileId: "example-short-vertical",
|
||||||
|
quality: "draft",
|
||||||
|
status: "done",
|
||||||
|
progress: 1.5,
|
||||||
|
outputs: [
|
||||||
|
{ kind: "video", artifactId: null, fileName: "a.mp4", sizeBytes: 1, path: "/abs/a.mp4" },
|
||||||
|
{ kind: "nonsense" },
|
||||||
|
],
|
||||||
|
gateReport: { passed: "maybe" },
|
||||||
|
ffprobe: null,
|
||||||
|
autoFixRound: 0,
|
||||||
|
error: null,
|
||||||
|
createdBy: "user-1",
|
||||||
|
createdAt: at,
|
||||||
|
updatedAt: at,
|
||||||
|
};
|
||||||
|
const dto = mapMediaRender(row);
|
||||||
|
expect(dto.progress).toBe(1);
|
||||||
|
expect(dto.outputs).toEqual([
|
||||||
|
{ kind: "video", artifactId: null, fileName: "a.mp4", sizeBytes: 1 },
|
||||||
|
]);
|
||||||
|
expect(dto.gateReport).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts upload chunks and converts sizes", () => {
|
||||||
|
const row: MediaUploadRow = {
|
||||||
|
id: "upload-1",
|
||||||
|
spaceId: "space-1",
|
||||||
|
assetId: "asset-1",
|
||||||
|
userId: "user-1",
|
||||||
|
sizeBytes: 21n * 1024n * 1024n * 1024n,
|
||||||
|
chunkBytes: 8,
|
||||||
|
sha256: "a".repeat(64),
|
||||||
|
fileName: "a.mov",
|
||||||
|
receivedChunks: [3, 0, 2],
|
||||||
|
status: "open",
|
||||||
|
createdAt: at,
|
||||||
|
updatedAt: at,
|
||||||
|
};
|
||||||
|
expect(mapMediaUpload(row)).toEqual({
|
||||||
|
id: "upload-1",
|
||||||
|
assetId: "asset-1",
|
||||||
|
sizeBytes: 21 * 1024 * 1024 * 1024,
|
||||||
|
chunkBytes: 8,
|
||||||
|
receivedChunks: [0, 2, 3],
|
||||||
|
status: "open",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps mark prompts and drops malformed ones", () => {
|
||||||
|
const row: MediaMarkRow = {
|
||||||
|
id: "mark-1",
|
||||||
|
spaceId: "space-1",
|
||||||
|
kind: "tattoo",
|
||||||
|
label: null,
|
||||||
|
personId: null,
|
||||||
|
assetId: "asset-1",
|
||||||
|
prompt: { tMs: 10, point: { x: 0.5, y: 0.5 } },
|
||||||
|
treatment: "blur",
|
||||||
|
maskDir: "masks/mark-1",
|
||||||
|
qualityScore: null,
|
||||||
|
suggestedFallback: null,
|
||||||
|
createdAt: at,
|
||||||
|
updatedAt: at,
|
||||||
|
};
|
||||||
|
expect(mapMediaMark(row).prompt).toEqual({ tMs: 10, point: { x: 0.5, y: 0.5 } });
|
||||||
|
expect(mapMediaMark({ ...row, prompt: { tMs: -1 } }).prompt).toBeNull();
|
||||||
|
expect(mapMediaMark(row)).not.toHaveProperty("maskDir");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("client media items", () => {
|
||||||
|
const item: ClientMediaItem = {
|
||||||
|
externalId: "local-1",
|
||||||
|
kind: "video",
|
||||||
|
capturedAt: at.toISOString(),
|
||||||
|
durationMs: 1000,
|
||||||
|
width: 1080,
|
||||||
|
height: 1920,
|
||||||
|
fps: 30,
|
||||||
|
gpsLat: 48.2085,
|
||||||
|
gpsLon: 16.3721,
|
||||||
|
deviceModel: "Phone",
|
||||||
|
albumNames: ["Trip"],
|
||||||
|
isFavorite: false,
|
||||||
|
fileName: "IMG_0001.MOV",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("derives a revision that changes with any metadata change", () => {
|
||||||
|
const base = clientMediaItemToSourceItem(item);
|
||||||
|
expect(base).toMatchObject({ externalId: "local-1", capturedAt: at, sizeBytes: null });
|
||||||
|
expect(clientMediaItemToSourceItem({ ...item }).revision).toBe(base.revision);
|
||||||
|
expect(clientMediaItemToSourceItem({ ...item, isFavorite: true }).revision).not.toBe(
|
||||||
|
base.revision,
|
||||||
|
);
|
||||||
|
expect(clientMediaItemToSourceItem({ ...item, albumNames: [] }).revision).not.toBe(
|
||||||
|
base.revision,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
32
packages/db/src/media.ts
Normal file
32
packages/db/src/media.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import type { PrismaClient } from "./client.js";
|
||||||
|
import { createMediaCollectionRepos } from "./media-collections.js";
|
||||||
|
import { createMediaLibraryRepos } from "./media-library.js";
|
||||||
|
import { createMediaPeopleRepos } from "./media-people.js";
|
||||||
|
import { createMediaProjectRepos } from "./media-projects.js";
|
||||||
|
import { createMediaPublishingRepos } from "./media-publishing.js";
|
||||||
|
import { createMediaUploadRepos } from "./media-uploads.js";
|
||||||
|
|
||||||
|
export * from "./media-collections.js";
|
||||||
|
export * from "./media-library.js";
|
||||||
|
export * from "./media-people.js";
|
||||||
|
export * from "./media-projects.js";
|
||||||
|
export * from "./media-publishing.js";
|
||||||
|
export * from "./media-shared.js";
|
||||||
|
export * from "./media-uploads.js";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Media Studio persistence. Every function takes the space explicitly and never reads
|
||||||
|
* or writes another space's rows; a foreign or missing id is an IsolationError.
|
||||||
|
*/
|
||||||
|
export function createMediaRepos(prisma: PrismaClient) {
|
||||||
|
return {
|
||||||
|
...createMediaLibraryRepos(prisma),
|
||||||
|
...createMediaUploadRepos(prisma),
|
||||||
|
...createMediaCollectionRepos(prisma),
|
||||||
|
...createMediaPeopleRepos(prisma),
|
||||||
|
...createMediaProjectRepos(prisma),
|
||||||
|
...createMediaPublishingRepos(prisma),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MediaRepos = ReturnType<typeof createMediaRepos>;
|
||||||
Loading…
x
Reference in New Issue
Block a user