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
527 lines
15 KiB
TypeScript
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 };
|
|
}
|