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): 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(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; /** Location of one asset, or null when unknown or not permitted. */ locate(id: string): Promise; push(input: PushInput): Promise; /** How many assets the server holds for this source. */ serverAssetCount(): Promise; /** Every externalId the server holds for this source. */ serverExternalIds(): Promise; }; 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 { 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(); 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(); 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 }; }