Index photos and videos from the device library into a client-pushed ios_library source, upload originals in resumable chunks, and review, approve, and share project variants with expo-video and the native share sheet. Pure logic for chunking, resume, indexing, and variants is unit tested. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
210 lines
7.0 KiB
TypeScript
210 lines
7.0 KiB
TypeScript
import type { ClientMediaItem } from "@rakazo/contracts";
|
|
|
|
/*
|
|
* Index of the device media library (photos and videos) pushed to the
|
|
* `ios_library` source through `sources.pushItems`. Only metadata leaves the
|
|
* device here; originals upload later, per session, when the user asks.
|
|
*/
|
|
|
|
/** Lightweight library metadata as expo-media-library reports it. */
|
|
export type DeviceAsset = {
|
|
id: string;
|
|
filename: string | null;
|
|
mediaType: string;
|
|
width: number | null;
|
|
height: number | null;
|
|
/** Milliseconds. */
|
|
duration: number | null;
|
|
/** Unix milliseconds. */
|
|
creationTime: number | null;
|
|
modificationTime: number | null;
|
|
isFavorite: boolean;
|
|
};
|
|
|
|
export type DeviceLocation = { latitude: number; longitude: number };
|
|
|
|
export type PushInput = {
|
|
sourceId: string;
|
|
items: ClientMediaItem[];
|
|
deletedExternalIds: string[];
|
|
syncToken: string | null;
|
|
complete: boolean;
|
|
};
|
|
|
|
export type PushResult = { created: number; updated: number; unchanged: number; deleted: number };
|
|
|
|
export const PUSH_ITEMS_MAX = 500;
|
|
export const PUSH_DELETIONS_MAX = 5000;
|
|
|
|
/** Coordinates leave the device rounded to two decimals (about 1 km). */
|
|
export function roundCoordinate(value: number): number {
|
|
return Math.round(value * 100) / 100;
|
|
}
|
|
|
|
function validCoordinate(value: number, limit: number): boolean {
|
|
return Number.isFinite(value) && Math.abs(value) <= limit;
|
|
}
|
|
|
|
function positiveInt(value: number | null): number | null {
|
|
return value !== null && Number.isFinite(value) && value > 0 ? Math.round(value) : null;
|
|
}
|
|
|
|
function isoOrNull(ms: number | null): string | null {
|
|
if (ms === null || !Number.isFinite(ms) || ms <= 0) return null;
|
|
const date = new Date(ms);
|
|
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
}
|
|
|
|
/** Photos and videos only; audio and unknown entries are not media for this index. */
|
|
export function isIndexable(asset: Pick<DeviceAsset, "mediaType">): boolean {
|
|
return asset.mediaType === "image" || asset.mediaType === "video";
|
|
}
|
|
|
|
export function toClientMediaItem(
|
|
asset: DeviceAsset,
|
|
location: DeviceLocation | null,
|
|
): ClientMediaItem | null {
|
|
if (!isIndexable(asset) || !asset.id || asset.id.length > 200) return null;
|
|
const hasLocation =
|
|
location !== null &&
|
|
validCoordinate(location.latitude, 90) &&
|
|
validCoordinate(location.longitude, 180) &&
|
|
!(location.latitude === 0 && location.longitude === 0);
|
|
const video = asset.mediaType === "video";
|
|
return {
|
|
externalId: asset.id,
|
|
kind: video ? "video" : "photo",
|
|
capturedAt: isoOrNull(asset.creationTime),
|
|
durationMs: video ? positiveInt(asset.duration) : null,
|
|
width: positiveInt(asset.width),
|
|
height: positiveInt(asset.height),
|
|
fps: null,
|
|
gpsLat: hasLocation ? roundCoordinate(location.latitude) : null,
|
|
gpsLon: hasLocation ? roundCoordinate(location.longitude) : null,
|
|
deviceModel: null,
|
|
albumNames: [],
|
|
isFavorite: asset.isFavorite,
|
|
fileName: asset.filename ? asset.filename.slice(0, 255) : null,
|
|
};
|
|
}
|
|
|
|
/** The sync token is the newest change time seen; later syncs push only newer changes. */
|
|
export function changeTime(asset: DeviceAsset): number {
|
|
return Math.max(asset.modificationTime ?? 0, asset.creationTime ?? 0);
|
|
}
|
|
|
|
export function parseSyncToken(token: string | null): number | null {
|
|
if (!token || !/^[0-9]{1,16}$/.test(token)) return null;
|
|
return Number(token);
|
|
}
|
|
|
|
export function chunked<T>(items: T[], size: number): T[][] {
|
|
const out: T[][] = [];
|
|
for (let index = 0; index < items.length; index += size)
|
|
out.push(items.slice(index, index + size));
|
|
return out;
|
|
}
|
|
|
|
export type DeviceIndexIo = {
|
|
/** One page of library metadata in a stable order; fewer than `limit` ends the scan. */
|
|
listDevice(offset: number, limit: number): Promise<DeviceAsset[]>;
|
|
/** Location of one asset, or null when unknown or not permitted. */
|
|
locate(id: string): Promise<DeviceLocation | null>;
|
|
push(input: PushInput): Promise<PushResult>;
|
|
/** How many assets the server holds for this source. */
|
|
serverAssetCount(): Promise<number>;
|
|
/** Every externalId the server holds for this source. */
|
|
serverExternalIds(): Promise<string[]>;
|
|
};
|
|
|
|
export type DeviceIndexResult = {
|
|
scanned: number;
|
|
pushed: number;
|
|
deleted: number;
|
|
syncToken: string | null;
|
|
};
|
|
|
|
/**
|
|
* Bring the server index in line with the device library. Pushes items changed
|
|
* since `syncToken`; when the server count then differs from the device count
|
|
* (deletions, or items newly shared through limited access), reconciles by id.
|
|
*/
|
|
export async function syncDeviceIndex(input: {
|
|
sourceId: string;
|
|
syncToken: string | null;
|
|
io: DeviceIndexIo;
|
|
pageSize?: number;
|
|
signal?: AbortSignal;
|
|
onProgress?: (scanned: number) => void;
|
|
}): Promise<DeviceIndexResult> {
|
|
const { io, signal } = input;
|
|
const pageSize = Math.min(input.pageSize ?? PUSH_ITEMS_MAX, PUSH_ITEMS_MAX);
|
|
const since = parseSyncToken(input.syncToken);
|
|
|
|
const device = new Map<string, DeviceAsset>();
|
|
for (let offset = 0; ; offset += pageSize) {
|
|
signal?.throwIfAborted();
|
|
const page = await io.listDevice(offset, pageSize);
|
|
for (const asset of page) if (isIndexable(asset)) device.set(asset.id, asset);
|
|
input.onProgress?.(device.size);
|
|
if (page.length < pageSize) break;
|
|
}
|
|
|
|
let newest = since ?? 0;
|
|
for (const asset of device.values()) newest = Math.max(newest, changeTime(asset));
|
|
const syncToken = newest > 0 ? String(newest) : null;
|
|
|
|
let pushed = 0;
|
|
const sent = new Set<string>();
|
|
const pushAssets = async (assets: DeviceAsset[]) => {
|
|
for (const batch of chunked(assets, pageSize)) {
|
|
signal?.throwIfAborted();
|
|
const items: ClientMediaItem[] = [];
|
|
for (const asset of batch) {
|
|
const location = await io.locate(asset.id).catch(() => null);
|
|
const item = toClientMediaItem(asset, location);
|
|
if (item) items.push(item);
|
|
sent.add(asset.id);
|
|
}
|
|
if (items.length === 0) continue;
|
|
await io.push({
|
|
sourceId: input.sourceId,
|
|
items,
|
|
deletedExternalIds: [],
|
|
syncToken: null,
|
|
complete: false,
|
|
});
|
|
pushed += items.length;
|
|
}
|
|
};
|
|
|
|
const changed = [...device.values()].filter(
|
|
(asset) => since === null || changeTime(asset) > since,
|
|
);
|
|
await pushAssets(changed);
|
|
|
|
let deletions: string[] = [];
|
|
if ((await io.serverAssetCount()) !== device.size) {
|
|
const server = new Set(await io.serverExternalIds());
|
|
await pushAssets(
|
|
[...device.values()].filter((asset) => !server.has(asset.id) && !sent.has(asset.id)),
|
|
);
|
|
deletions = [...server].filter((id) => !device.has(id));
|
|
}
|
|
|
|
const deletionBatches = chunked(deletions, PUSH_DELETIONS_MAX);
|
|
if (deletionBatches.length === 0) deletionBatches.push([]);
|
|
for (const [index, batch] of deletionBatches.entries()) {
|
|
signal?.throwIfAborted();
|
|
const last = index === deletionBatches.length - 1;
|
|
await io.push({
|
|
sourceId: input.sourceId,
|
|
items: [],
|
|
deletedExternalIds: batch,
|
|
syncToken: last ? syncToken : null,
|
|
complete: last,
|
|
});
|
|
}
|
|
return { scanned: device.size, pushed, deleted: deletions.length, syncToken };
|
|
}
|