evOo-local/apps/mobile/lib/media-upload.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

250 lines
8.4 KiB
TypeScript

import type { MediaUpload } from "@rakazo/contracts";
import { Sha256 } from "./media-sha256";
/*
* Resumable chunked upload of one device original (spec 2.7). The server owns
* the protocol: `uploads.begin` opens or resumes an upload for the same asset
* and content, chunks go to `PUT /api/media/uploads/:id/chunks/:index` with
* their exact length, `uploads.status.receivedChunks` says what already
* arrived, and an upload idle for too long answers 409 "Upload expired" and
* must begin again. This module is the driver; the native glue injects I/O.
*/
/** A failed byte route or RPC with the HTTP status the server answered. */
export class MediaUploadHttpError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message);
this.name = "MediaUploadHttpError";
}
}
/** The local original no longer matches what the upload was opened for. */
export class MediaFileChangedError extends Error {
constructor() {
super("The file changed while reading it");
this.name = "MediaFileChangedError";
}
}
export function chunkCount(sizeBytes: number, chunkBytes: number): number {
if (!Number.isSafeInteger(sizeBytes) || sizeBytes <= 0) throw new Error("Invalid upload size");
if (!Number.isSafeInteger(chunkBytes) || chunkBytes <= 0) throw new Error("Invalid chunk size");
return Math.ceil(sizeBytes / chunkBytes);
}
/** Byte range of chunk `index`: every chunk is full except the last. */
export function chunkRange(
index: number,
sizeBytes: number,
chunkBytes: number,
): { start: number; length: number } {
const count = chunkCount(sizeBytes, chunkBytes);
if (!Number.isInteger(index) || index < 0 || index >= count) {
throw new Error("Chunk index out of range");
}
const start = index * chunkBytes;
return { start, length: Math.min(chunkBytes, sizeBytes - start) };
}
/** Chunk indexes the server has not confirmed yet, in upload order. */
export function missingChunks(
upload: Pick<MediaUpload, "sizeBytes" | "chunkBytes" | "receivedChunks">,
) {
const received = new Set(upload.receivedChunks);
const missing: number[] = [];
for (let index = 0; index < chunkCount(upload.sizeBytes, upload.chunkBytes); index++) {
if (!received.has(index)) missing.push(index);
}
return missing;
}
export function isUploadExpired(error: unknown): boolean {
return (
error instanceof MediaUploadHttpError && error.status === 409 && /expired/i.test(error.message)
);
}
/** Network failures and server hiccups are worth another try; client errors are not. */
export function isRetryableUploadError(error: unknown): boolean {
if (error instanceof MediaUploadHttpError) {
return error.status === 408 || error.status === 429 || error.status >= 500;
}
if (error instanceof MediaFileChangedError) return false;
return !(error instanceof Error && error.name === "AbortError");
}
/** Bytes of one chunk, backed by a plain ArrayBuffer so fetch can send them. */
export type ChunkBytes = Uint8Array<ArrayBuffer>;
/** Reads `length` bytes at `start` from the original. */
export type ReadChunk = (start: number, length: number) => Promise<ChunkBytes> | ChunkBytes;
export async function hashOriginal(
sizeBytes: number,
read: ReadChunk,
options: {
readBytes?: number;
signal?: AbortSignal;
onProgress?: (fraction: number) => void;
} = {},
): Promise<string> {
const step = options.readBytes ?? 4 * 1024 * 1024;
const hash = new Sha256();
for (let start = 0; start < sizeBytes; start += step) {
options.signal?.throwIfAborted();
const length = Math.min(step, sizeBytes - start);
const bytes = await read(start, length);
if (bytes.byteLength !== length) throw new MediaFileChangedError();
hash.update(bytes);
options.onProgress?.(Math.min(1, (start + length) / sizeBytes));
}
return hash.hex();
}
export type UploadTransport = {
begin(input: {
assetId: string;
sizeBytes: number;
sha256: string;
fileName: string;
}): Promise<MediaUpload>;
status(uploadId: string): Promise<MediaUpload>;
putChunk(
uploadId: string,
index: number,
bytes: ChunkBytes,
signal?: AbortSignal,
): Promise<MediaUpload>;
complete(uploadId: string): Promise<MediaUpload>;
};
export type UploadOriginalInput = {
assetId: string;
fileName: string;
sizeBytes: number;
sha256: string;
read: ReadChunk;
transport: UploadTransport;
signal?: AbortSignal;
/** Fraction of bytes the server confirmed, 0..1. */
onProgress?: (fraction: number) => void;
/** Consecutive failed attempts before giving up. */
maxAttempts?: number;
/** How often an expired upload may start over. */
maxRestarts?: number;
wait?: (ms: number, signal?: AbortSignal) => Promise<void>;
};
const defaultWait = (ms: number, signal?: AbortSignal) =>
new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(signal.reason);
},
{ once: true },
);
});
export function retryDelayMs(attempt: number): number {
return Math.min(30_000, 1_000 * 2 ** Math.max(0, attempt - 1));
}
function confirmedFraction(upload: MediaUpload): number {
const count = chunkCount(upload.sizeBytes, upload.chunkBytes);
let bytes = 0;
for (const index of new Set(upload.receivedChunks)) {
if (index >= 0 && index < count) {
bytes += chunkRange(index, upload.sizeBytes, upload.chunkBytes).length;
}
}
return bytes / upload.sizeBytes;
}
/**
* Upload one original and complete it. Resumes from the server's
* `receivedChunks` after any failure, begins again when the upload expired,
* and backs off between retries. Returns the completed upload.
*/
export async function uploadOriginal(input: UploadOriginalInput): Promise<MediaUpload> {
const { transport, signal } = input;
const maxAttempts = input.maxAttempts ?? 5;
const maxRestarts = input.maxRestarts ?? 2;
const wait = input.wait ?? defaultWait;
const begin = () =>
transport.begin({
assetId: input.assetId,
sizeBytes: input.sizeBytes,
sha256: input.sha256,
fileName: input.fileName,
});
let restarts = 0;
let failures = 0;
let upload = await begin();
for (;;) {
signal?.throwIfAborted();
try {
if (upload.status === "complete") {
input.onProgress?.(1);
return upload;
}
if (upload.status === "failed") {
throw new MediaUploadHttpError(409, "Upload expired");
}
if (upload.sizeBytes !== input.sizeBytes) throw new MediaFileChangedError();
input.onProgress?.(confirmedFraction(upload));
for (const index of missingChunks(upload)) {
signal?.throwIfAborted();
const range = chunkRange(index, upload.sizeBytes, upload.chunkBytes);
const bytes = await input.read(range.start, range.length);
if (bytes.byteLength !== range.length) throw new MediaFileChangedError();
upload = await transport.putChunk(upload.id, index, bytes, signal);
failures = 0;
input.onProgress?.(confirmedFraction(upload));
}
upload = await transport.complete(upload.id);
if (upload.status !== "complete") throw new Error("Upload did not complete");
input.onProgress?.(1);
return upload;
} catch (error) {
if (signal?.aborted) throw signal.reason ?? error;
if (isUploadExpired(error)) {
if (restarts >= maxRestarts) throw error;
restarts += 1;
failures = 0;
upload = await begin();
continue;
}
if (!isRetryableUploadError(error)) throw error;
failures += 1;
if (failures >= maxAttempts) throw error;
await wait(retryDelayMs(failures), signal);
// Ask the server what arrived; a chunk may have landed before the error.
try {
upload = await transport.status(upload.id);
} catch (statusError) {
if (isUploadExpired(statusError)) {
if (restarts >= maxRestarts) throw statusError;
restarts += 1;
upload = await begin();
} else if (!isRetryableUploadError(statusError)) {
throw statusError;
}
}
}
}
}
/** Overall progress of several uploads, weighted by size. */
export function combinedProgress(items: Array<{ sizeBytes: number; fraction: number }>): number {
const total = items.reduce((sum, item) => sum + item.sizeBytes, 0);
if (total === 0) return 0;
return items.reduce((sum, item) => sum + item.sizeBytes * item.fraction, 0) / total;
}