evOo-local/packages/db/src/media-shared.ts
Claude 1aab5ab3a8
feat(media): expose asset externalId, filter by ingest state, push device deletions
The desktop Photos sync needs to map awaiting assets back to Photos items,
query only awaiting uploads, and report deletions without a full relisting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:00:02 +00:00

527 lines
15 KiB
TypeScript

import { createHash } from "node:crypto";
import type { MediaSourceItem } from "@rakazo/adapter-kit";
import type {
AgeVerificationMethod,
AnonymizeMode,
BrandKitRecord,
ClientMediaItem,
MarkTreatment,
MediaAsset,
MediaAssetKind,
MediaAssetStorage,
MediaCollection,
MediaCollectionKind,
MediaEditPlan,
MediaFaceTrack,
MediaIngestState,
MediaLabelSource,
MediaMark,
MediaMarkKind,
MediaMetric,
MediaMetricSource,
MediaPerson,
MediaProject,
MediaProjectGoal,
MediaProjectStatus,
MediaPublication,
MediaPublicationStatus,
MediaRender,
MediaRenderOutput,
MediaRenderQuality,
MediaRenderStatus,
MediaSegment,
MediaSegmentKind,
MediaSource,
MediaSourceKind,
MediaSourceStatus,
MediaUpload,
MetricCsvMapping,
TeaserFeature,
} from "@rakazo/contracts";
import {
BrandKitSchema,
EdlSchema,
FfprobeReportSchema,
GateReportSchema,
MediaMarkPromptSchema,
MediaRenderOutputSchema,
MetricCsvMappingSchema,
UtmSchema,
} from "@rakazo/contracts";
import type {
MediaAsset as MediaAssetRow,
MediaBrandKit as MediaBrandKitRow,
MediaCollection as MediaCollectionRow,
MediaEditPlan as MediaEditPlanRow,
MediaFaceTrack as MediaFaceTrackRow,
MediaMark as MediaMarkRow,
MediaMetricMapping as MediaMetricMappingRow,
MediaMetric as MediaMetricRow,
MediaPerson as MediaPersonRow,
MediaProject as MediaProjectRow,
MediaPublication as MediaPublicationRow,
MediaRender as MediaRenderRow,
MediaSegment as MediaSegmentRow,
MediaSource as MediaSourceRow,
MediaUpload as MediaUploadRow,
PrismaClient,
} from "./client.js";
import { Prisma } from "./client.js";
import { IsolationError } from "./scope.js";
/*
* Shared pieces of the Media Studio repositories: errors, keyset cursors, and
* the row → DTO mappers. Not-found and foreign-space access both surface as
* IsolationError (from scope.ts), like every other repository.
*/
/** Input the repository refuses: bad cursor, invalid EDL or brand kit, foreign ids. */
export class InvalidMediaInputError extends Error {
constructor(message: string) {
super(message);
this.name = "InvalidMediaInputError";
}
}
/** The record exists but its state does not allow the requested change. */
export class MediaStateConflictError extends Error {
constructor(message: string) {
super(message);
this.name = "MediaStateConflictError";
}
}
export type MediaDb = Prisma.TransactionClient | PrismaClient;
/** Throws IsolationError unless every id is an asset of the space. */
export async function assertAssetsInSpace(
db: MediaDb,
spaceId: string,
assetIds: string[],
): Promise<void> {
const ids = [...new Set(assetIds)];
if (ids.length === 0) return;
const owned = await db.mediaAsset.count({ where: { spaceId, id: { in: ids } } });
if (owned !== ids.length) throw new IsolationError();
}
export const MEDIA_PAGE_MAX = 500;
export const MEDIA_PAGE_DEFAULT = 100;
export interface MediaPage<T> {
items: T[];
nextCursor: string | null;
}
export interface MediaPageInput {
cursor?: string | null;
limit?: number;
}
export function pageLimit(limit: number | undefined): number {
if (limit === undefined || !Number.isFinite(limit)) return MEDIA_PAGE_DEFAULT;
return Math.min(MEDIA_PAGE_MAX, Math.max(1, Math.trunc(limit)));
}
/**
* Position after the last row of a page ordered by `(at DESC NULLS LAST, id DESC)`.
* `at` is null for rows without a timestamp, which sort after every dated row.
*/
export interface KeysetCursor {
at: Date | null;
id: string;
}
/** Opaque base64url token for a keyset position. */
export function encodeKeysetCursor(cursor: KeysetCursor): string {
return Buffer.from(JSON.stringify([cursor.at?.toISOString() ?? null, cursor.id])).toString(
"base64url",
);
}
export function decodeKeysetCursor(token: string): KeysetCursor {
let parsed: unknown;
try {
parsed = JSON.parse(Buffer.from(token, "base64url").toString("utf8"));
} catch {
throw new InvalidMediaInputError("Invalid cursor");
}
if (!Array.isArray(parsed) || parsed.length !== 2) {
throw new InvalidMediaInputError("Invalid cursor");
}
const [at, id] = parsed as unknown[];
if (typeof id !== "string" || id.length === 0 || id.length > 100) {
throw new InvalidMediaInputError("Invalid cursor");
}
if (at === null) return { at: null, id };
if (typeof at !== "string") throw new InvalidMediaInputError("Invalid cursor");
const date = new Date(at);
if (Number.isNaN(date.getTime())) throw new InvalidMediaInputError("Invalid cursor");
return { at: date, id };
}
/**
* SQL predicate for "after the cursor" under `ORDER BY at DESC NULLS LAST, id DESC`.
* `atColumn` and `idColumn` are trusted column references.
*/
export function keysetAfterSql(
cursor: KeysetCursor | null,
atColumn: Prisma.Sql,
idColumn: Prisma.Sql,
): Prisma.Sql {
if (!cursor) return Prisma.sql`TRUE`;
if (cursor.at === null) {
return Prisma.sql`(${atColumn} IS NULL AND ${idColumn} < ${cursor.id})`;
}
return Prisma.sql`(${atColumn} < ${cursor.at} OR (${atColumn} = ${cursor.at} AND ${idColumn} < ${cursor.id}) OR ${atColumn} IS NULL)`;
}
/** Slice a `limit + 1` result into a page with its next cursor. */
export function toPage<Row, Item>(
rows: Row[],
limit: number,
keyOf: (row: Row) => KeysetCursor,
map: (row: Row) => Item,
): MediaPage<Item> {
const hasMore = rows.length > limit;
const pageRows = hasMore ? rows.slice(0, limit) : rows;
const last = pageRows.at(-1);
return {
items: pageRows.map(map),
nextCursor: hasMore && last ? encodeKeysetCursor(keyOf(last)) : null,
};
}
/** Escape LIKE wildcards so user text matches literally. */
export function likeContains(text: string): string {
return `%${text.replace(/[\\%_]/g, (char) => `\\${char}`)}%`;
}
export function isUniqueViolation(error: unknown): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === "P2002");
}
/** Advisory lock held until the surrounding transaction ends. */
export async function lockMediaKey(tx: Prisma.TransactionClient, key: string): Promise<void> {
await tx.$queryRaw(Prisma.sql`
SELECT pg_advisory_xact_lock(hashtextextended(${`media:${key}`}, 0))::text AS "lock"
`);
}
/** Prisma Bytes columns want an ArrayBuffer-backed view; copy when the input is a slice. */
export function toBytes(value: Uint8Array): Uint8Array<ArrayBuffer> {
return new Uint8Array(value);
}
/**
* Change detector for a client-pushed item, which has no revision of its own:
* any metadata change produces a different value.
*/
export function clientMediaItemToSourceItem(item: ClientMediaItem): MediaSourceItem {
const canonical = JSON.stringify([
item.kind,
item.capturedAt,
item.durationMs,
item.width,
item.height,
item.fps,
item.gpsLat,
item.gpsLon,
item.deviceModel,
item.albumNames,
item.isFavorite,
item.fileName,
]);
return {
externalId: item.externalId,
kind: item.kind,
capturedAt: item.capturedAt ? new Date(item.capturedAt) : null,
durationMs: item.durationMs,
width: item.width,
height: item.height,
fps: item.fps,
gpsLat: item.gpsLat,
gpsLon: item.gpsLon,
deviceModel: item.deviceModel,
albumNames: item.albumNames,
isFavorite: item.isFavorite,
fileName: item.fileName,
sizeBytes: null,
revision: createHash("sha256").update(canonical).digest("hex").slice(0, 32),
};
}
// ---------------------------------------------------------------------------
// Mappers
// ---------------------------------------------------------------------------
function iso(value: Date | null): string | null {
return value ? value.toISOString() : null;
}
export function mapMediaSource(row: MediaSourceRow, assetCount: number): MediaSource {
return {
id: row.id,
kind: row.kind as MediaSourceKind,
label: row.label,
status: row.status as MediaSourceStatus,
assetCount,
lastSyncedAt: iso(row.lastSyncedAt),
error: row.error,
createdAt: row.createdAt.toISOString(),
};
}
export function mapMediaAsset(row: MediaAssetRow): MediaAsset {
return {
id: row.id,
sourceId: row.sourceId,
externalId: row.externalId,
kind: row.kind as MediaAssetKind,
storage: row.storage as MediaAssetStorage,
capturedAt: iso(row.capturedAt),
durationMs: row.durationMs,
width: row.width,
height: row.height,
fps: row.fps,
gpsLat: row.gpsLat,
gpsLon: row.gpsLon,
deviceModel: row.deviceModel,
albumNames: row.albumNames,
isFavorite: row.isFavorite,
ingestState: row.ingestState as MediaIngestState,
hasProxy: row.proxyPath !== null,
hasThumbnail: row.thumbPath !== null,
qualityScore: row.qualityScore,
createdAt: row.createdAt.toISOString(),
};
}
function numberRecord(value: Prisma.JsonValue): Partial<Record<TeaserFeature, number>> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const out: Partial<Record<TeaserFeature, number>> = {};
for (const [key, score] of Object.entries(value)) {
if (typeof score === "number" && Number.isFinite(score)) {
out[key as TeaserFeature] = score;
}
}
return out;
}
export function mapMediaSegment(row: MediaSegmentRow): MediaSegment {
return {
id: row.id,
assetId: row.assetId,
kind: row.kind as MediaSegmentKind,
startMs: row.startMs,
endMs: row.endMs,
scores: numberRecord(row.scores),
teaserScore: row.teaserScore,
transcriptExcerpt: row.transcriptExcerpt,
labels: row.labels,
paidOnly: row.paidOnly,
};
}
export interface CollectionStats {
assetCount: number;
videoCount: number;
totalDurationMs: number;
}
export const EMPTY_COLLECTION_STATS: CollectionStats = {
assetCount: 0,
videoCount: 0,
totalDurationMs: 0,
};
export function mapMediaCollection(
row: MediaCollectionRow,
stats: CollectionStats,
): MediaCollection {
return {
id: row.id,
kind: row.kind as MediaCollectionKind,
title: row.title,
labelSource: row.labelSource as MediaLabelSource,
startAt: iso(row.startAt),
endAt: iso(row.endAt),
coverAssetId: row.coverAssetId,
assetCount: stats.assetCount,
videoCount: stats.videoCount,
totalDurationMs: stats.totalDurationMs,
};
}
export function mapMediaPerson(
row: MediaPersonRow,
faceTrackCount: number,
coverThumbAssetId: string | null,
): MediaPerson {
return {
id: row.id,
displayName: row.displayName,
isSelf: row.isSelf,
confirmed: row.confirmed,
anonymizeByDefault: row.anonymizeByDefault as AnonymizeMode,
consentRecordedAt: iso(row.consentRecordedAt),
consentArtifactId: row.consentArtifactId,
ageVerifiedAt: iso(row.ageVerifiedAt),
ageVerificationMethod: row.ageVerificationMethod as AgeVerificationMethod,
faceTrackCount,
coverThumbAssetId,
};
}
export function mapMediaFaceTrack(
row: Pick<
MediaFaceTrackRow,
"id" | "assetId" | "personId" | "startMs" | "endMs" | "confidenceMin"
>,
): MediaFaceTrack {
return {
id: row.id,
assetId: row.assetId,
personId: row.personId,
startMs: row.startMs,
endMs: row.endMs,
confidenceMin: row.confidenceMin,
};
}
export function mapMediaMark(row: MediaMarkRow): MediaMark {
const prompt = row.prompt === null ? null : MediaMarkPromptSchema.safeParse(row.prompt);
return {
id: row.id,
kind: row.kind as MediaMarkKind,
label: row.label,
personId: row.personId,
assetId: row.assetId,
prompt: prompt?.success ? prompt.data : null,
treatment: row.treatment as MarkTreatment,
qualityScore: row.qualityScore,
suggestedFallback: row.suggestedFallback as MarkTreatment | null,
createdAt: row.createdAt.toISOString(),
};
}
export function mapBrandKit(row: MediaBrandKitRow): BrandKitRecord {
return {
id: row.id,
kit: BrandKitSchema.parse(row.kit),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
export function mapMediaProject(row: MediaProjectRow): MediaProject {
return {
id: row.id,
title: row.title,
goal: row.goal as MediaProjectGoal,
status: row.status as MediaProjectStatus,
brandKitId: row.brandKitId,
collectionIds: row.collectionIds,
profileIds: row.profileIds,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
export function mapMediaEditPlan(row: MediaEditPlanRow): MediaEditPlan {
return {
id: row.id,
projectId: row.projectId,
profileId: row.profileId,
version: row.version,
edl: EdlSchema.parse(row.edl),
createdBy: row.createdBy === "agent" ? "agent" : "user",
rationale: row.rationale,
createdAt: row.createdAt.toISOString(),
};
}
/** Public render outputs; internal paths stored next to them are dropped. */
export function renderOutputsOf(value: Prisma.JsonValue): MediaRenderOutput[] {
if (!Array.isArray(value)) return [];
const outputs: MediaRenderOutput[] = [];
for (const entry of value) {
const parsed = MediaRenderOutputSchema.safeParse(entry);
if (parsed.success) outputs.push(parsed.data);
}
return outputs;
}
export function mapMediaRender(row: MediaRenderRow): MediaRender {
const gate = row.gateReport === null ? null : GateReportSchema.safeParse(row.gateReport);
const probe = row.ffprobe === null ? null : FfprobeReportSchema.safeParse(row.ffprobe);
return {
id: row.id,
planId: row.planId,
projectId: row.projectId,
profileId: row.profileId,
quality: row.quality as MediaRenderQuality,
status: row.status as MediaRenderStatus,
progress: Math.min(1, Math.max(0, row.progress)),
outputs: renderOutputsOf(row.outputs),
gateReport: gate?.success ? gate.data : null,
ffprobe: probe?.success ? probe.data : null,
error: row.error,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
export function mapMediaUpload(row: MediaUploadRow): MediaUpload {
return {
id: row.id,
assetId: row.assetId,
sizeBytes: Number(row.sizeBytes),
chunkBytes: row.chunkBytes,
receivedChunks: [...row.receivedChunks].sort((a, b) => a - b),
status: row.status as MediaUpload["status"],
};
}
export function mapMediaPublication(row: MediaPublicationRow): MediaPublication {
const utm = row.utm === null ? null : UtmSchema.safeParse(row.utm);
return {
id: row.id,
renderId: row.renderId,
profileId: row.profileId,
destination: row.destination,
status: row.status as MediaPublicationStatus,
scheduledAt: iso(row.scheduledAt),
publishedAt: iso(row.publishedAt),
externalUrl: row.externalUrl,
targetUrl: row.targetUrl,
trackingUrl: row.trackingUrl,
utm: utm?.success ? utm.data : null,
title: row.title,
caption: row.caption,
tags: row.tags,
thumbnailVariant: row.thumbnailVariant,
bundleArtifactId: row.bundleArtifactId,
createdAt: row.createdAt.toISOString(),
};
}
export function mapMediaMetric(row: MediaMetricRow): MediaMetric {
return {
id: row.id,
publicationId: row.publicationId,
observedAt: row.observedAt.toISOString(),
views: row.views,
clicks: row.clicks,
conversions: row.conversions,
revenueCents: row.revenueCents,
currency: row.currency,
source: row.source as MediaMetricSource,
};
}
export function mapMetricMapping(row: MediaMetricMappingRow): MetricCsvMapping {
const mapping = MetricCsvMappingSchema.parse(row.mapping);
return { ...mapping, id: row.id, name: row.name };
}