evOo-local/apps/mobile/lib/media-index.ts
Claude 4c30af9ea3
feat(media): mobile library index, resumable upload, and project variants
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
2026-10-03 19:20:42 +00:00

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 };
}