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
810 lines
33 KiB
TypeScript
810 lines
33 KiB
TypeScript
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("removes device-deleted items and filters by ingest state", async () => {
|
|
const { spaceId, sourceId } = await newLibrary("ios_library");
|
|
const other = await newLibrary("ios_library");
|
|
const items = Array.from({ length: 5 }, (_, index) => sourceItem(index));
|
|
await media.upsertSourceItems(spaceId, sourceId, items);
|
|
await media.upsertSourceItems(other.spaceId, other.sourceId, items);
|
|
|
|
const awaiting = await media.searchAssets(spaceId, { ingestStates: ["awaiting_upload"] });
|
|
expect(awaiting.items).toHaveLength(5);
|
|
expect(awaiting.items.map((asset) => asset.externalId).sort()).toEqual(
|
|
items.map((item) => item.externalId).sort(),
|
|
);
|
|
expect((await media.searchAssets(spaceId, { ingestStates: ["analyzed"] })).items).toEqual([]);
|
|
|
|
const removed = await media.removeSourceItems(spaceId, sourceId, [
|
|
items[0]!.externalId,
|
|
items[1]!.externalId,
|
|
"unknown",
|
|
]);
|
|
expect(removed.deleted).toBe(2);
|
|
expect((await media.searchAssets(spaceId, {})).items).toHaveLength(3);
|
|
expect((await media.searchAssets(other.spaceId, {})).items).toHaveLength(5);
|
|
await expect(
|
|
media.removeSourceItems(other.spaceId, sourceId, [items[2]!.externalId]),
|
|
).rejects.toBeInstanceOf(IsolationError);
|
|
});
|
|
|
|
it("indexes source items once and reports only real changes on the next sync", async () => {
|
|
const { spaceId, sourceId } = await newLibrary();
|
|
const items = Array.from({ length: 1000 }, (_, index) =>
|
|
sourceItem(index, { gpsLat: 48.208_49, gpsLon: 16.372_08 }),
|
|
);
|
|
|
|
const first = await media.upsertSourceItems(spaceId, sourceId, items);
|
|
expect(first).toMatchObject({ created: 1000, updated: 0, unchanged: 0 });
|
|
expect(new Set(first.changedAssetIds).size).toBe(1000);
|
|
|
|
const second = await media.upsertSourceItems(spaceId, sourceId, items);
|
|
expect(second).toEqual({ created: 0, updated: 0, unchanged: 1000, changedAssetIds: [] });
|
|
|
|
const edited = items.map((item, index) =>
|
|
index < 3 ? { ...item, revision: "r2", isFavorite: true } : item,
|
|
);
|
|
const third = await media.upsertSourceItems(spaceId, sourceId, edited);
|
|
expect(third).toMatchObject({ created: 0, updated: 3, unchanged: 997 });
|
|
expect(third.changedAssetIds).toEqual(first.changedAssetIds.slice(0, 3));
|
|
|
|
const asset = await media.getAssetRecord(spaceId, first.changedAssetIds[0] as string);
|
|
expect(asset).toMatchObject({
|
|
gpsLat: 48.21,
|
|
gpsLon: 16.37,
|
|
isFavorite: true,
|
|
revision: "r2",
|
|
storage: "referenced",
|
|
ingestState: "indexed",
|
|
});
|
|
const sources = await media.listSources(spaceId);
|
|
expect(sources).toHaveLength(1);
|
|
expect(sources[0]).toMatchObject({ id: sourceId, assetCount: 1000, status: "idle" });
|
|
});
|
|
|
|
it("serializes concurrent syncs of one source without duplicate assets", async () => {
|
|
const { spaceId, sourceId } = await newLibrary();
|
|
const items = Array.from({ length: 50 }, (_, index) => sourceItem(index));
|
|
const results = await Promise.all([
|
|
media.upsertSourceItems(spaceId, sourceId, items),
|
|
media.upsertSourceItems(spaceId, sourceId, items),
|
|
]);
|
|
expect(results.map((result) => result.created).sort()).toEqual([0, 50]);
|
|
expect(await db.prisma.mediaAsset.count({ where: { sourceId } })).toBe(50);
|
|
});
|
|
|
|
it("marks client-pushed assets as remote and waiting for their upload", async () => {
|
|
const { spaceId, sourceId } = await newLibrary("ios_library");
|
|
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
|
|
const asset = await media.getAssetRecord(spaceId, changedAssetIds[0] as string);
|
|
expect(asset).toMatchObject({ storage: "remote", ingestState: "awaiting_upload" });
|
|
});
|
|
|
|
it("keyset pagination covers 1,200 assets exactly once while new assets arrive", async () => {
|
|
const { spaceId, sourceId } = await newLibrary();
|
|
// Repeated timestamps and undated assets exercise the (capturedAt, id) tie-break.
|
|
const items = Array.from({ length: 1200 }, (_, index) =>
|
|
sourceItem(index, {
|
|
capturedAt: index % 10 === 0 ? null : new Date(BASE_TIME + Math.floor(index / 4) * 1000),
|
|
}),
|
|
);
|
|
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, items);
|
|
const original = new Set(changedAssetIds);
|
|
|
|
const seen: string[] = [];
|
|
const order: Array<number | null> = [];
|
|
let cursor: string | null = null;
|
|
let page = 0;
|
|
do {
|
|
const result = await media.searchAssets(spaceId, { cursor, limit: 97 });
|
|
for (const asset of result.items) {
|
|
seen.push(asset.id);
|
|
order.push(asset.capturedAt ? Date.parse(asset.capturedAt) : null);
|
|
}
|
|
cursor = result.nextCursor;
|
|
page += 1;
|
|
// Newer, older and undated assets land between page reads.
|
|
await media.upsertSourceItems(spaceId, sourceId, [
|
|
sourceItem(10_000 + page * 3, { capturedAt: new Date(BASE_TIME + 10_000_000 + page) }),
|
|
sourceItem(10_001 + page * 3, { capturedAt: new Date(BASE_TIME - 10_000_000 - page) }),
|
|
sourceItem(10_002 + page * 3, { capturedAt: null }),
|
|
]);
|
|
} while (cursor);
|
|
|
|
expect(new Set(seen).size).toBe(seen.length);
|
|
for (const id of original) expect(seen).toContain(id);
|
|
// Order: capturedAt descending, undated last.
|
|
const firstNull = order.indexOf(null);
|
|
expect(order.slice(firstNull).every((value) => value === null)).toBe(true);
|
|
const dated = order.slice(0, firstNull) as number[];
|
|
for (let index = 1; index < dated.length; index++) {
|
|
expect(dated[index - 1]).toBeGreaterThanOrEqual(dated[index] as number);
|
|
}
|
|
await expect(media.searchAssets(spaceId, { cursor: "not-a-cursor" })).rejects.toBeInstanceOf(
|
|
InvalidMediaInputError,
|
|
);
|
|
});
|
|
|
|
it("filters search by kind, time, favorites, quality, place, text, person and collection", async () => {
|
|
const { spaceId, sourceId } = await newLibrary();
|
|
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [
|
|
sourceItem(1, {
|
|
kind: "video",
|
|
gpsLat: 48.2085,
|
|
gpsLon: 16.3721,
|
|
albumNames: ["Summer Trip"],
|
|
isFavorite: true,
|
|
}),
|
|
sourceItem(2, { kind: "photo", gpsLat: 48.137, gpsLon: 11.575, fileName: "beach_day.jpg" }),
|
|
sourceItem(3, { kind: "video", gpsLat: 48.25, gpsLon: 16.4 }),
|
|
sourceItem(4, { kind: "video", capturedAt: null, gpsLat: -16.5, gpsLon: 179.99 }),
|
|
sourceItem(5, { kind: "photo", gpsLat: -16.5, gpsLon: -179.99, fileName: "100%_done.jpg" }),
|
|
]);
|
|
const [vienna, munich, nearVienna, dateline, otherSide] = changedAssetIds as [
|
|
string,
|
|
string,
|
|
string,
|
|
string,
|
|
string,
|
|
];
|
|
const ids = async (query: Parameters<MediaRepos["searchAssets"]>[1]) =>
|
|
(await media.searchAssets(spaceId, query)).items.map((asset) => asset.id).sort();
|
|
|
|
expect(await ids({ kinds: ["photo"] })).toEqual([munich, otherSide].sort());
|
|
expect(await ids({ favoritesOnly: true })).toEqual([vienna]);
|
|
expect(
|
|
await ids({
|
|
from: new Date(BASE_TIME + 2 * 60_000).toISOString(),
|
|
to: new Date(BASE_TIME + 3 * 60_000).toISOString(),
|
|
}),
|
|
).toEqual([munich, nearVienna].sort());
|
|
expect(await ids({ near: { lat: 48.2082, lon: 16.3738, radiusKm: 2 } })).toEqual([vienna]);
|
|
expect(await ids({ near: { lat: 48.2082, lon: 16.3738, radiusKm: 10 } })).toEqual(
|
|
[vienna, nearVienna].sort(),
|
|
);
|
|
expect(await ids({ near: { lat: -16.5, lon: 180, radiusKm: 5 } })).toEqual(
|
|
[dateline, otherSide].sort(),
|
|
);
|
|
|
|
await media.recordAnalysisVersion(spaceId, munich, 1, 0.9);
|
|
await media.recordAnalysisVersion(spaceId, vienna, 1, 0.3);
|
|
expect(await ids({ minQuality: 0.5 })).toEqual([munich]);
|
|
|
|
expect(await ids({ text: "summer" })).toEqual([vienna]);
|
|
expect(await ids({ text: "BEACH_DAY" })).toEqual([munich]);
|
|
expect(await ids({ text: "100%" })).toEqual([otherSide]);
|
|
expect(await ids({ text: "%" })).toEqual([otherSide]);
|
|
|
|
await media.replaceSegments(spaceId, nearVienna, 1, [
|
|
{ kind: "shot", startMs: 0, endMs: 1000, labels: ["Sunset"], transcriptExcerpt: "welcome" },
|
|
]);
|
|
expect(await ids({ text: "sunset" })).toEqual([nearVienna]);
|
|
expect(await ids({ text: "WELCOME" })).toEqual([nearVienna]);
|
|
|
|
const collection = await media.createManualCollection(spaceId, "Harbour walk", [dateline]);
|
|
expect(await ids({ text: "harbour" })).toEqual([dateline]);
|
|
expect(await ids({ collectionId: collection.id })).toEqual([dateline]);
|
|
|
|
const [track] = await media.replaceFaceTracks(spaceId, munich, "faces-1", [
|
|
{ startMs: 0, endMs: 1000, confidenceMin: 0.8, keyframesPath: "k.json", embedding: null },
|
|
]);
|
|
const [person] = await media.createPersonSuggestions(spaceId, [
|
|
{ centroid: vectorBytes([1, 2]), trackIds: [track?.id as string] },
|
|
]);
|
|
expect(await ids({ personId: person?.id as string })).toEqual([munich]);
|
|
expect(await ids({ sourceId })).toHaveLength(5);
|
|
});
|
|
|
|
it("returns asset details and moves ingest state only from the expected state", async () => {
|
|
const { spaceId, sourceId } = await newLibrary();
|
|
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
|
|
const assetId = changedAssetIds[0] as string;
|
|
|
|
const claims = await Promise.all([
|
|
media.transitionIngestState(spaceId, assetId, { from: ["indexed"], to: "proxying" }),
|
|
media.transitionIngestState(spaceId, assetId, { from: ["indexed"], to: "proxying" }),
|
|
]);
|
|
expect(claims.filter(Boolean)).toHaveLength(1);
|
|
expect((await media.assetsNeedingWork(spaceId, "proxying", 10)).map((row) => row.id)).toEqual([
|
|
assetId,
|
|
]);
|
|
|
|
const proxied = await media.recordProxy(spaceId, assetId, {
|
|
contentHash: "f".repeat(64),
|
|
proxyPath: "proxy.mp4",
|
|
thumbPath: "thumb.jpg",
|
|
durationMs: 12_000,
|
|
});
|
|
expect(proxied).toMatchObject({ ingestState: "proxied", hasProxy: true, hasThumbnail: true });
|
|
expect(
|
|
await media.transitionIngestState(spaceId, assetId, {
|
|
from: ["proxied"],
|
|
to: "failed",
|
|
error: "sidecar offline",
|
|
}),
|
|
).toBe(true);
|
|
expect(await media.getAssetRecord(spaceId, assetId)).toMatchObject({
|
|
ingestState: "failed",
|
|
ingestError: "sidecar offline",
|
|
durationMs: 12_000,
|
|
});
|
|
|
|
await media.upsertAnalysis(spaceId, assetId, {
|
|
kind: "transcript",
|
|
modelId: "whisper",
|
|
modelVersion: "1",
|
|
data: { language: "en", words: [{ text: "hello" }, { text: "there" }] },
|
|
});
|
|
await media.replaceSegments(spaceId, assetId, 1, [
|
|
{ kind: "scene", startMs: 2000, endMs: 4000, scores: { motion: 0.5 } },
|
|
{ kind: "shot", startMs: 0, endMs: 2000, teaserScore: 0.7 },
|
|
]);
|
|
await media.replaceFaceTracks(spaceId, assetId, "faces-1", [
|
|
{ startMs: 0, endMs: 500, confidenceMin: 0.6, keyframesPath: "a.json", embedding: null },
|
|
]);
|
|
const detail = await media.getAsset(spaceId, assetId);
|
|
expect(detail.transcript).toBe("hello there");
|
|
expect(detail.segments.map((segment) => segment.startMs)).toEqual([0, 2000]);
|
|
expect(detail.segments[1]?.scores).toEqual({ motion: 0.5 });
|
|
expect(detail.faceTracks).toHaveLength(1);
|
|
expect(await media.recordAnalysisVersion(spaceId, assetId, 2)).toMatchObject({
|
|
ingestState: "analyzed",
|
|
});
|
|
});
|
|
|
|
it("keeps each analysis model version and returns the latest per kind", async () => {
|
|
const { spaceId, sourceId } = await newLibrary();
|
|
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
|
|
const assetId = changedAssetIds[0] as string;
|
|
const v1 = await media.upsertAnalysis(spaceId, assetId, {
|
|
kind: "embeddings",
|
|
modelId: "siglip",
|
|
modelVersion: "1",
|
|
vectors: vectorBytes([1, 2, 3]),
|
|
});
|
|
const again = await media.upsertAnalysis(spaceId, assetId, {
|
|
kind: "embeddings",
|
|
modelId: "siglip",
|
|
modelVersion: "1",
|
|
vectors: vectorBytes([4, 5, 6]),
|
|
});
|
|
expect(again.id).toBe(v1.id);
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
await media.upsertAnalysis(spaceId, assetId, {
|
|
kind: "embeddings",
|
|
modelId: "siglip",
|
|
modelVersion: "2",
|
|
vectors: vectorBytes([7, 8, 9]),
|
|
});
|
|
expect(await db.prisma.mediaAnalysis.count({ where: { assetId } })).toBe(2);
|
|
const latest = await media.latestAnalysis(spaceId, assetId, "embeddings");
|
|
expect(latest?.modelVersion).toBe("2");
|
|
expect([...new Float32Array(new Uint8Array(latest?.vectors as Uint8Array).buffer)]).toEqual([
|
|
7, 8, 9,
|
|
]);
|
|
expect(
|
|
(await media.latestAnalysesByKind(spaceId, "embeddings")).get(assetId)?.modelVersion,
|
|
).toBe("2");
|
|
});
|
|
|
|
it("replaces segments atomically and flags paid-only segments all or nothing", async () => {
|
|
const { spaceId, sourceId } = await newLibrary();
|
|
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
|
|
const assetId = changedAssetIds[0] as string;
|
|
await media.replaceSegments(spaceId, assetId, 1, [
|
|
{ kind: "shot", startMs: 0, endMs: 1000 },
|
|
{ kind: "shot", startMs: 1000, endMs: 2000 },
|
|
]);
|
|
await expect(
|
|
media.replaceSegments(spaceId, assetId, 2, [
|
|
{ kind: "shot", startMs: 0, endMs: 1000 },
|
|
{ kind: "shot", startMs: 1000, endMs: 1000 },
|
|
]),
|
|
).rejects.toBeInstanceOf(InvalidMediaInputError);
|
|
expect(await media.listSegments(spaceId, [assetId])).toHaveLength(2);
|
|
|
|
await media.replaceSegments(spaceId, assetId, 2, [{ kind: "scene", startMs: 0, endMs: 3000 }]);
|
|
const segments = await media.listSegments(spaceId, [assetId]);
|
|
expect(segments).toHaveLength(1);
|
|
const segmentId = segments[0]?.id as string;
|
|
|
|
await expect(
|
|
media.setSegmentsPaidOnly(spaceId, [segmentId, "missing"], true),
|
|
).rejects.toBeInstanceOf(IsolationError);
|
|
expect((await media.listSegments(spaceId, [assetId]))[0]?.paidOnly).toBe(false);
|
|
expect(await media.setSegmentsPaidOnly(spaceId, [segmentId], true)).toBe(1);
|
|
expect((await media.listSegments(spaceId, [assetId]))[0]?.paidOnly).toBe(true);
|
|
});
|
|
|
|
it("re-clustering keeps user titles and manual collections and drops stale clusters", async () => {
|
|
const { spaceId, sourceId } = await newLibrary();
|
|
const { changedAssetIds } = await media.upsertSourceItems(
|
|
spaceId,
|
|
sourceId,
|
|
Array.from({ length: 6 }, (_, index) => sourceItem(index + 1)),
|
|
);
|
|
const [a1, a2, a3, a4, a5, a6] = changedAssetIds as string[];
|
|
const cluster = (key: string, title: string, assetIds: string[]) => ({
|
|
key,
|
|
title,
|
|
labelSource: "rule" as const,
|
|
startAt: new Date(BASE_TIME),
|
|
endAt: new Date(BASE_TIME + 1000),
|
|
centroid: { lat: 48.20849, lon: 16.37208 },
|
|
coverAssetId: assetIds[0] ?? null,
|
|
assetIds,
|
|
});
|
|
expect(
|
|
await media.replaceClusters(spaceId, "session", [
|
|
cluster("s1", "Morning", [a1 as string, a2 as string]),
|
|
cluster("s2", "Noon", [a3 as string]),
|
|
cluster("s3", "Evening", [a4 as string]),
|
|
]),
|
|
).toEqual({ created: 3, updated: 0, deleted: 0 });
|
|
const manual = await media.createManualCollection(spaceId, "Favourites", [
|
|
a6 as string,
|
|
a1 as string,
|
|
]);
|
|
const first = (await media.listCollections(spaceId, { kind: "session" })).items;
|
|
const morning = first.find((collection) => collection.title === "Morning");
|
|
const evening = first.find((collection) => collection.title === "Evening");
|
|
await media.renameCollection(spaceId, morning?.id as string, "Market visit");
|
|
await media.renameCollection(spaceId, evening?.id as string, "Dinner");
|
|
|
|
expect(
|
|
await media.replaceClusters(spaceId, "session", [
|
|
cluster("s1", "Morning again", [a1 as string, a3 as string, a5 as string]),
|
|
cluster("s4", "Night", [a2 as string]),
|
|
]),
|
|
).toEqual({ created: 1, updated: 1, deleted: 1 });
|
|
|
|
const after = await media.listCollections(spaceId, {});
|
|
const byTitle = new Map(after.items.map((collection) => [collection.title, collection]));
|
|
expect([...byTitle.keys()].sort()).toEqual(
|
|
["Dinner", "Favourites", "Market visit", "Night"].sort(),
|
|
);
|
|
expect(byTitle.get("Market visit")).toMatchObject({
|
|
id: morning?.id,
|
|
labelSource: "user",
|
|
assetCount: 3,
|
|
videoCount: 2,
|
|
totalDurationMs: 20_000,
|
|
});
|
|
const manualAfter = await media.getCollection(spaceId, manual.id);
|
|
expect(manualAfter.collection).toMatchObject({ kind: "manual", title: "Favourites" });
|
|
expect(manualAfter.assets.map((asset) => asset.id)).toEqual([a1, a6]);
|
|
const stored = await db.prisma.mediaCollection.findUniqueOrThrow({
|
|
where: { id: morning?.id as string },
|
|
});
|
|
expect(stored).toMatchObject({ centroidLat: 48.21, centroidLon: 16.37 });
|
|
|
|
await expect(
|
|
media.replaceClusters(spaceId, "session", [cluster("x", "Bad", ["foreign-asset"])]),
|
|
).rejects.toBeInstanceOf(IsolationError);
|
|
});
|
|
|
|
it("lists, merges, splits and creates collections", async () => {
|
|
const { spaceId, sourceId } = await newLibrary();
|
|
const { changedAssetIds } = await media.upsertSourceItems(
|
|
spaceId,
|
|
sourceId,
|
|
Array.from({ length: 5 }, (_, index) => sourceItem(index + 1)),
|
|
);
|
|
const ids = changedAssetIds as string[];
|
|
const clusters = ids.map((assetId, index) => ({
|
|
key: `t${index}`,
|
|
title: `Theme ${index}`,
|
|
labelSource: "model" as const,
|
|
startAt: index === 4 ? null : new Date(BASE_TIME + index * 1000),
|
|
endAt: null,
|
|
centroid: null,
|
|
coverAssetId: assetId,
|
|
assetIds: [assetId],
|
|
}));
|
|
await media.replaceClusters(spaceId, "theme", clusters);
|
|
|
|
const seen: string[] = [];
|
|
let cursor: string | null = null;
|
|
do {
|
|
const page = await media.listCollections(spaceId, { kind: "theme", cursor, limit: 2 });
|
|
seen.push(...page.items.map((collection) => collection.title));
|
|
cursor = page.nextCursor;
|
|
} while (cursor);
|
|
expect(seen).toEqual(["Theme 3", "Theme 2", "Theme 1", "Theme 0", "Theme 4"]);
|
|
|
|
const all = (await media.listCollections(spaceId, { kind: "theme" })).items;
|
|
const merged = await media.mergeCollections(
|
|
spaceId,
|
|
all.slice(0, 3).map((collection) => collection.id),
|
|
);
|
|
expect(merged).toMatchObject({ id: all[0]?.id, assetCount: 3, labelSource: "user" });
|
|
expect((await media.listCollections(spaceId, { kind: "theme" })).items).toHaveLength(3);
|
|
const mergedAssets = (await media.getCollection(spaceId, merged.id)).assets.map(
|
|
(asset) => asset.id,
|
|
);
|
|
expect(mergedAssets).toEqual([ids[1], ids[2], ids[3]]);
|
|
|
|
await expect(
|
|
media.splitCollection(spaceId, merged.id, ids[1] as string),
|
|
).rejects.toBeInstanceOf(InvalidMediaInputError);
|
|
const [kept, split] = await media.splitCollection(spaceId, merged.id, ids[2] as string);
|
|
expect(kept).toMatchObject({ id: merged.id, assetCount: 1 });
|
|
expect(split).toMatchObject({ assetCount: 2, kind: "theme" });
|
|
expect(
|
|
await media.collectionAssetIds(spaceId, [split?.id as string, kept?.id as string]),
|
|
).toEqual([ids[2], ids[3], ids[1]]);
|
|
});
|
|
|
|
it("suggests, merges and assigns people and records consent only for a member", async () => {
|
|
const { spaceId, userId, sourceId } = await newLibrary();
|
|
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [
|
|
sourceItem(1),
|
|
sourceItem(2),
|
|
]);
|
|
const [assetA, assetB] = changedAssetIds as [string, string];
|
|
const tracksA = await media.replaceFaceTracks(spaceId, assetA, "faces-1", [
|
|
{
|
|
startMs: 0,
|
|
endMs: 1000,
|
|
confidenceMin: 0.9,
|
|
keyframesPath: "a1",
|
|
embedding: vectorBytes([1, 0]),
|
|
},
|
|
{
|
|
startMs: 0,
|
|
endMs: 1000,
|
|
confidenceMin: 0.7,
|
|
keyframesPath: "a2",
|
|
embedding: vectorBytes([0, 1]),
|
|
},
|
|
]);
|
|
const tracksB = await media.replaceFaceTracks(spaceId, assetB, "faces-1", [
|
|
{
|
|
startMs: 0,
|
|
endMs: 1000,
|
|
confidenceMin: 0.8,
|
|
keyframesPath: "b1",
|
|
embedding: vectorBytes([1, 0]),
|
|
},
|
|
]);
|
|
expect(await media.faceTrackEmbeddings(spaceId, { unassignedOnly: true })).toHaveLength(3);
|
|
|
|
const people = await media.createPersonSuggestions(spaceId, [
|
|
{
|
|
centroid: vectorBytes([1, 0]),
|
|
trackIds: [tracksA[0]?.id as string, tracksB[0]?.id as string],
|
|
},
|
|
{ centroid: vectorBytes([0, 1]), trackIds: [tracksA[1]?.id as string] },
|
|
]);
|
|
expect(people.map((person) => person.displayName)).toEqual(["Person 1", "Person 2"]);
|
|
expect(people[0]).toMatchObject({
|
|
faceTrackCount: 2,
|
|
confirmed: false,
|
|
coverThumbAssetId: assetA,
|
|
});
|
|
expect(await media.faceTrackEmbeddings(spaceId, { unassignedOnly: true })).toHaveLength(0);
|
|
expect(await media.personCentroids(spaceId)).toHaveLength(2);
|
|
|
|
const [first, second] = people as [(typeof people)[0], (typeof people)[0]];
|
|
await media.updatePerson(spaceId, first.id, { isSelf: true });
|
|
await media.updatePerson(spaceId, second.id, { isSelf: true, displayName: "Me" });
|
|
const listed = await media.listPeople(spaceId);
|
|
expect(listed.filter((person) => person.isSelf).map((person) => person.id)).toEqual([
|
|
second.id,
|
|
]);
|
|
|
|
const mark = await media.createMark(spaceId, {
|
|
kind: "tattoo",
|
|
label: null,
|
|
personId: second.id,
|
|
assetId: assetA,
|
|
prompt: { tMs: 0, box: { x: 0.1, y: 0.1, w: 0.2, h: 0.2 } },
|
|
treatment: "inpaint",
|
|
});
|
|
const merged = await media.mergePeople(spaceId, [first.id, second.id]);
|
|
expect(merged).toMatchObject({ id: first.id, faceTrackCount: 3, confirmed: true });
|
|
expect((await media.getMark(spaceId, mark.id)).personId).toBe(first.id);
|
|
expect(await media.listPeople(spaceId)).toHaveLength(1);
|
|
|
|
const reassigned = await media.assignTrack(spaceId, tracksB[0]?.id as string, null);
|
|
expect(reassigned.personId).toBeNull();
|
|
await expect(
|
|
media.assignTrack(spaceId, tracksB[0]?.id as string, "missing-person"),
|
|
).rejects.toBeInstanceOf(IsolationError);
|
|
|
|
const consent = {
|
|
personId: first.id,
|
|
consentArtifactId: "artifact-1",
|
|
consentRecordedAt: new Date(BASE_TIME).toISOString(),
|
|
ageVerificationMethod: "document_on_file" as const,
|
|
ageVerifiedAt: new Date(BASE_TIME).toISOString(),
|
|
};
|
|
await expect(
|
|
media.recordConsent({ spaceId, recordedByUserId: "" }, consent),
|
|
).rejects.toBeInstanceOf(InvalidMediaInputError);
|
|
await expect(
|
|
media.recordConsent({ spaceId, recordedByUserId: "not-a-member" }, consent),
|
|
).rejects.toBeInstanceOf(IsolationError);
|
|
await expect(
|
|
media.recordConsent(
|
|
{ spaceId, recordedByUserId: userId },
|
|
{ ...consent, ageVerifiedAt: null },
|
|
),
|
|
).rejects.toBeInstanceOf(InvalidMediaInputError);
|
|
const withConsent = await media.recordConsent({ spaceId, recordedByUserId: userId }, consent);
|
|
expect(withConsent).toMatchObject({
|
|
consentArtifactId: "artifact-1",
|
|
ageVerificationMethod: "document_on_file",
|
|
consentRecordedAt: consent.consentRecordedAt,
|
|
});
|
|
const record = await db.prisma.mediaPerson.findUniqueOrThrow({ where: { id: first.id } });
|
|
expect(record.consentRecordedBy).toBe(userId);
|
|
});
|
|
|
|
it("removing a person leaves no biometric bytes behind", async () => {
|
|
const { spaceId, sourceId } = await newLibrary();
|
|
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
|
|
const assetId = changedAssetIds[0] as string;
|
|
const tracks = await media.replaceFaceTracks(spaceId, assetId, "faces-1", [
|
|
{
|
|
startMs: 0,
|
|
endMs: 1000,
|
|
confidenceMin: 0.9,
|
|
keyframesPath: "k1",
|
|
embedding: vectorBytes([1, 2]),
|
|
},
|
|
{
|
|
startMs: 0,
|
|
endMs: 1000,
|
|
confidenceMin: 0.9,
|
|
keyframesPath: "k2",
|
|
embedding: vectorBytes([3, 4]),
|
|
},
|
|
{
|
|
startMs: 0,
|
|
endMs: 1000,
|
|
confidenceMin: 0.9,
|
|
keyframesPath: "k3",
|
|
embedding: vectorBytes([5, 6]),
|
|
},
|
|
]);
|
|
const [person, bystander] = await media.createPersonSuggestions(spaceId, [
|
|
{
|
|
centroid: vectorBytes([2, 3]),
|
|
trackIds: [tracks[0]?.id as string, tracks[1]?.id as string],
|
|
},
|
|
{ centroid: vectorBytes([5, 6]), trackIds: [tracks[2]?.id as string] },
|
|
]);
|
|
const personId = person?.id as string;
|
|
const mark = await media.createMark(spaceId, {
|
|
kind: "birthmark",
|
|
label: null,
|
|
personId,
|
|
assetId,
|
|
prompt: { tMs: 0 },
|
|
treatment: "blur",
|
|
});
|
|
await media.recordMarkResult(spaceId, mark.id, {
|
|
qualityScore: 0.4,
|
|
suggestedFallback: "cover",
|
|
maskDir: "masks/m1",
|
|
});
|
|
const assetMark = await media.createMark(spaceId, {
|
|
kind: "license_plate",
|
|
label: null,
|
|
personId: null,
|
|
assetId,
|
|
prompt: { tMs: 0 },
|
|
treatment: "blur",
|
|
});
|
|
|
|
const removed = await media.removePersonCompletely(spaceId, personId);
|
|
expect(removed).toEqual({
|
|
deletedFaceTracks: 2,
|
|
deletedMarks: 1,
|
|
keyframesPaths: expect.arrayContaining(["k1", "k2"]),
|
|
maskDirs: ["masks/m1"],
|
|
});
|
|
|
|
const people = await db.prisma.$queryRaw<Array<{ id: string; centroid: Uint8Array | null }>>`
|
|
SELECT id, centroid FROM media_people WHERE "spaceId" = ${spaceId}`;
|
|
expect(people).toEqual([{ id: bystander?.id, centroid: expect.anything() }]);
|
|
const personRows = await db.prisma.$queryRaw<Array<{ count: number }>>`
|
|
SELECT count(*)::int AS count FROM media_people WHERE id = ${personId}`;
|
|
expect(personRows[0]?.count).toBe(0);
|
|
const linked = await db.prisma.$queryRaw<Array<{ count: number }>>`
|
|
SELECT count(*)::int AS count FROM media_face_tracks
|
|
WHERE "personId" = ${personId} OR id IN (${tracks[0]?.id}, ${tracks[1]?.id})`;
|
|
expect(linked[0]?.count).toBe(0);
|
|
const embeddings = await db.prisma.$queryRaw<Array<{ id: string }>>`
|
|
SELECT id FROM media_face_tracks WHERE "spaceId" = ${spaceId} AND embedding IS NOT NULL`;
|
|
expect(embeddings.map((row) => row.id)).toEqual([tracks[2]?.id]);
|
|
const marks = await db.prisma.$queryRaw<Array<{ id: string }>>`
|
|
SELECT id FROM media_marks WHERE "spaceId" = ${spaceId}`;
|
|
expect(marks.map((row) => row.id)).toEqual([assetMark.id]);
|
|
await expect(media.removePersonCompletely(spaceId, personId)).rejects.toBeInstanceOf(
|
|
IsolationError,
|
|
);
|
|
});
|
|
|
|
it("manages marks per asset and per person", async () => {
|
|
const { spaceId, sourceId } = await newLibrary();
|
|
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [
|
|
sourceItem(1),
|
|
sourceItem(2),
|
|
]);
|
|
const [assetA, assetB] = changedAssetIds as [string, string];
|
|
const [trackB] = await media.replaceFaceTracks(spaceId, assetB, "faces-1", [
|
|
{ startMs: 0, endMs: 1000, confidenceMin: 0.9, keyframesPath: "k", embedding: null },
|
|
]);
|
|
const [person] = await media.createPersonSuggestions(spaceId, [
|
|
{ centroid: vectorBytes([1]), trackIds: [trackB?.id as string] },
|
|
]);
|
|
const personMark = await media.createMark(spaceId, {
|
|
kind: "tattoo",
|
|
label: "left forearm",
|
|
personId: person?.id as string,
|
|
assetId: assetA,
|
|
prompt: { tMs: 500, point: { x: 0.2, y: 0.3 } },
|
|
treatment: "inpaint",
|
|
});
|
|
const plate = await media.createMark(spaceId, {
|
|
kind: "license_plate",
|
|
label: null,
|
|
personId: null,
|
|
assetId: assetB,
|
|
prompt: { tMs: 0, box: { x: 0, y: 0, w: 0.1, h: 0.1 } },
|
|
treatment: "blur",
|
|
});
|
|
expect((await media.listMarks(spaceId, { personId: person?.id })).map((m) => m.id)).toEqual([
|
|
personMark.id,
|
|
]);
|
|
expect((await media.listMarks(spaceId, { assetId: assetB })).map((m) => m.id)).toEqual([
|
|
plate.id,
|
|
]);
|
|
// Person marks follow the person into every asset they appear in.
|
|
expect((await media.marksForAsset(spaceId, assetB)).map((m) => m.id).sort()).toEqual(
|
|
[personMark.id, plate.id].sort(),
|
|
);
|
|
const updated = await media.updateMark(spaceId, plate.id, { treatment: "cover", label: "car" });
|
|
expect(updated).toMatchObject({ treatment: "cover", label: "car" });
|
|
const result = await media.recordMarkResult(spaceId, personMark.id, {
|
|
qualityScore: 0.2,
|
|
suggestedFallback: "cover",
|
|
maskDir: "masks/tattoo",
|
|
});
|
|
expect(result).toMatchObject({
|
|
qualityScore: 0.2,
|
|
suggestedFallback: "cover",
|
|
maskDir: "masks/tattoo",
|
|
prompt: { tMs: 500, point: { x: 0.2, y: 0.3 } },
|
|
});
|
|
expect(await media.removeMark(spaceId, personMark.id)).toEqual({ maskDir: "masks/tattoo" });
|
|
expect(await media.listMarks(spaceId)).toHaveLength(1);
|
|
});
|
|
|
|
it("uploads chunks idempotently, resumes, and completes only when every chunk arrived", async () => {
|
|
const { spaceId, userId, sourceId } = await newLibrary("ios_library");
|
|
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
|
|
const assetId = changedAssetIds[0] as string;
|
|
const begin = {
|
|
assetId,
|
|
userId,
|
|
sizeBytes: 10,
|
|
sha256: "c".repeat(64),
|
|
fileName: "clip.mov",
|
|
chunkBytes: 4,
|
|
};
|
|
const upload = await media.beginUpload(spaceId, begin);
|
|
expect(upload).toMatchObject({
|
|
sizeBytes: 10,
|
|
chunkBytes: 4,
|
|
receivedChunks: [],
|
|
status: "open",
|
|
});
|
|
expect((await media.beginUpload(spaceId, begin)).id).toBe(upload.id);
|
|
|
|
await Promise.all(
|
|
Array.from({ length: 6 }, () => media.markChunkReceived(spaceId, upload.id, 1)),
|
|
);
|
|
await media.markChunkReceived(spaceId, upload.id, 0);
|
|
expect((await media.uploadStatus(spaceId, upload.id)).receivedChunks).toEqual([0, 1]);
|
|
await expect(media.markChunkReceived(spaceId, upload.id, 3)).rejects.toBeInstanceOf(
|
|
InvalidMediaInputError,
|
|
);
|
|
await expect(
|
|
media.completeUpload(spaceId, upload.id, { storagePath: "uploads/clip.mov" }),
|
|
).rejects.toBeInstanceOf(MediaStateConflictError);
|
|
|
|
await media.markChunkReceived(spaceId, upload.id, 2);
|
|
const done = await media.completeUpload(spaceId, upload.id, {
|
|
storagePath: "uploads/clip.mov",
|
|
});
|
|
expect(done).toMatchObject({ status: "complete", receivedChunks: [0, 1, 2] });
|
|
expect(await media.getAssetRecord(spaceId, assetId)).toMatchObject({
|
|
storage: "uploaded",
|
|
storagePath: "uploads/clip.mov",
|
|
contentHash: "c".repeat(64),
|
|
ingestState: "indexed",
|
|
});
|
|
await expect(media.markChunkReceived(spaceId, upload.id, 0)).rejects.toBeInstanceOf(
|
|
MediaStateConflictError,
|
|
);
|
|
expect((await media.beginUpload(spaceId, begin)).id).not.toBe(upload.id);
|
|
});
|
|
|
|
it("requests uploads only for remote assets", async () => {
|
|
const remote = await newLibrary("ios_library");
|
|
const { changedAssetIds } = await media.upsertSourceItems(remote.spaceId, remote.sourceId, [
|
|
sourceItem(1),
|
|
]);
|
|
const remoteId = changedAssetIds[0] as string;
|
|
await media.transitionIngestState(remote.spaceId, remoteId, {
|
|
from: ["awaiting_upload"],
|
|
to: "failed",
|
|
});
|
|
expect(await media.requestUpload(remote.spaceId, [remoteId])).toEqual([remoteId]);
|
|
|
|
const folder = await newLibrary();
|
|
const local = await media.upsertSourceItems(folder.spaceId, folder.sourceId, [sourceItem(1)]);
|
|
expect(await media.requestUpload(folder.spaceId, local.changedAssetIds)).toEqual([]);
|
|
});
|
|
|
|
it("updates and removes a source with everything indexed from it", async () => {
|
|
const { spaceId, sourceId } = await newLibrary();
|
|
const { changedAssetIds } = await media.upsertSourceItems(spaceId, sourceId, [sourceItem(1)]);
|
|
const synced = await media.updateSourceSync(spaceId, sourceId, {
|
|
status: "syncing",
|
|
lastSyncCursor: "page-2",
|
|
lastSyncedAt: new Date(BASE_TIME),
|
|
error: null,
|
|
});
|
|
expect(synced).toMatchObject({
|
|
status: "syncing",
|
|
lastSyncCursor: "page-2",
|
|
config: { path: "/media/library" },
|
|
assetCount: 1,
|
|
});
|
|
await media.replaceSegments(spaceId, changedAssetIds[0] as string, 1, [
|
|
{ kind: "shot", startMs: 0, endMs: 1 },
|
|
]);
|
|
await media.removeSource(spaceId, sourceId);
|
|
expect(await media.listSources(spaceId)).toEqual([]);
|
|
expect(await db.prisma.mediaAsset.count({ where: { sourceId } })).toBe(0);
|
|
expect(await db.prisma.mediaSegment.count({ where: { spaceId } })).toBe(0);
|
|
});
|
|
});
|