diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 3a251fa..5e45283 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -7,6 +7,7 @@ export * from "./events.js"; export * from "./external-conversations.js"; export * from "./groups.js"; export * from "./main-agent.js"; +export * from "./media.js"; export * from "./memory-config.js"; export * from "./messages.js"; export * from "./messaging.js"; diff --git a/packages/db/src/media-collections.ts b/packages/db/src/media-collections.ts new file mode 100644 index 0000000..c679019 --- /dev/null +++ b/packages/db/src/media-collections.ts @@ -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; + 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> { + 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 { + 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 { + 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, + clusters: MediaClusterInput[], + ): Promise { + const keys = new Set(); + 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> { + const limit = pageLimit(input.limit); + const cursor = input.cursor ? decodeKeysetCursor(input.cursor) : null; + const keys = await prisma.$queryRaw>(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 { + 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 { + 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 { + 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 { + 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 { + 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, + }; +} diff --git a/packages/db/src/media-fixtures.ts b/packages/db/src/media-fixtures.ts new file mode 100644 index 0000000..89b3c1e --- /dev/null +++ b/packages/db/src/media-fixtures.ts @@ -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; +} + +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 { + 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>; diff --git a/packages/db/src/media-isolation.postgres.test.ts b/packages/db/src/media-isolation.postgres.test.ts new file mode 100644 index 0000000..a0b76a8 --- /dev/null +++ b/packages/db/src/media-isolation.postgres.test.ts @@ -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]> = [ + ["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 = {}; + for (const table of tables) { + const rows = await db.prisma.$queryRawUnsafe>( + `SELECT count(*)::int AS count FROM ${table} WHERE "spaceId" = $1`, + spaceId, + ); + out[table] = rows[0]?.count ?? -1; + } + const items = await db.prisma.$queryRawUnsafe>( + `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); + }); +}); diff --git a/packages/db/src/media-library.ts b/packages/db/src/media-library.ts new file mode 100644 index 0000000..3bee8a5 --- /dev/null +++ b/packages/db/src/media-library.ts @@ -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; +} + +/** Source row for the sync worker, including its config and cursor. */ +export interface MediaSourceRecord extends MediaSource { + spaceId: string; + ownerUserId: string; + config: Record; + 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 & 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>; + 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) + : {}; + 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): 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const unique = new Map(); + 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(); + 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> { + 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>(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 { + 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 { + return requireAsset(prisma, spaceId, assetId); + } + + async function listAssetRecords(spaceId: string, assetIds: string[]): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + const rows = await prisma.mediaAnalysis.findMany({ + where: { spaceId, kind }, + orderBy: [{ assetId: "asc" }, { createdAt: "desc" }, { id: "desc" }], + }); + const latest = new Map(); + 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 { + 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 { + 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 { + 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, + }; +} diff --git a/packages/db/src/media-people.ts b/packages/db/src/media-people.ts new file mode 100644 index 0000000..05261b4 --- /dev/null +++ b/packages/db/src/media-people.ts @@ -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 { + 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 { + 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 { + 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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return markRecord(await requireMark(spaceId, markId)); + } + + async function updateMark( + spaceId: string, + markId: string, + patch: MediaMarkPatch, + ): Promise { + 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 { + 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, + }; +} diff --git a/packages/db/src/media-production.postgres.test.ts b/packages/db/src/media-production.postgres.test.ts new file mode 100644 index 0000000..5f326f8 --- /dev/null +++ b/packages/db/src/media-production.postgres.test.ts @@ -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(); + }); +}); diff --git a/packages/db/src/media-projects.ts b/packages/db/src/media-projects.ts new file mode 100644 index 0000000..9b113ee --- /dev/null +++ b/packages/db/src/media-projects.ts @@ -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>; + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return mapMediaRender(await requireRender(prisma, spaceId, renderId)); + } + + /** Full row, including outputs with internal paths. */ + async function getRenderRecord(spaceId: string, renderId: string): Promise { + return requireRender(prisma, spaceId, renderId); + } + + async function listRenders(spaceId: string, projectId: string): Promise { + 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 { + 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 { + 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 { + 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, + }; +} diff --git a/packages/db/src/media-publishing.ts b/packages/db/src/media-publishing.ts new file mode 100644 index 0000000..2da941f --- /dev/null +++ b/packages/db/src/media-publishing.ts @@ -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; + +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 { + 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 { + 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 { + return mapMediaPublication(await requirePublication(spaceId, publicationId)); + } + + async function getPublicationRecord( + spaceId: string, + publicationId: string, + ): Promise { + return requirePublication(spaceId, publicationId); + } + + async function listPublications( + spaceId: string, + input: MediaPageInput & { projectId?: string } = {}, + ): Promise> { + 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 { + 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 { + return transitionPublication(spaceId, publicationId, ["draft", "scheduled", "failed"], { + status: "scheduled", + scheduledAt, + }); + } + + async function cancelPublication( + spaceId: string, + publicationId: string, + ): Promise { + return transitionPublication( + spaceId, + publicationId, + ["draft", "scheduled", "bundled", "failed"], + { status: "canceled" }, + ); + } + + async function markPublicationBundled( + spaceId: string, + publicationId: string, + bundleArtifactId: string | null, + ): Promise { + 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 { + return transitionPublication(spaceId, publicationId, ["draft", "scheduled", "bundled"], { + status: "published", + externalUrl, + publishedAt, + }); + } + + async function markPublicationFailed( + spaceId: string, + publicationId: string, + ): Promise { + 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> { + 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(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return prisma.mediaPublication.count({ where: { spaceId, metrics: { some: {} } } }); + } + + // ------------------------------------------------------------------------- + // Teaser model + // ------------------------------------------------------------------------- + + async function getTeaserModel(spaceId: string): Promise { + 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 { + 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 { + 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, + }; +} diff --git a/packages/db/src/media-shared.ts b/packages/db/src/media-shared.ts new file mode 100644 index 0000000..589160e --- /dev/null +++ b/packages/db/src/media-shared.ts @@ -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 { + 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 { + 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( + rows: Row[], + limit: number, + keyOf: (row: Row) => KeysetCursor, + map: (row: Row) => Item, +): MediaPage { + 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 { + 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 { + 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> { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const out: Partial> = {}; + 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 }; +} diff --git a/packages/db/src/media-uploads.ts b/packages/db/src/media-uploads.ts new file mode 100644 index 0000000..d58eb74 --- /dev/null +++ b/packages/db/src/media-uploads.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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, + }; +} diff --git a/packages/db/src/media.postgres.test.ts b/packages/db/src/media.postgres.test.ts new file mode 100644 index 0000000..c1e233e --- /dev/null +++ b/packages/db/src/media.postgres.test.ts @@ -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 = []; + 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[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>` + SELECT id, centroid FROM media_people WHERE "spaceId" = ${spaceId}`; + expect(people).toEqual([{ id: bystander?.id, centroid: expect.anything() }]); + const personRows = await db.prisma.$queryRaw>` + SELECT count(*)::int AS count FROM media_people WHERE id = ${personId}`; + expect(personRows[0]?.count).toBe(0); + const linked = await db.prisma.$queryRaw>` + 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>` + 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>` + 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); + }); +}); diff --git a/packages/db/src/media.test.ts b/packages/db/src/media.test.ts new file mode 100644 index 0000000..c859369 --- /dev/null +++ b/packages/db/src/media.test.ts @@ -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, + ); + }); +}); diff --git a/packages/db/src/media.ts b/packages/db/src/media.ts new file mode 100644 index 0000000..bfc3fd2 --- /dev/null +++ b/packages/db/src/media.ts @@ -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;