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

375 lines
12 KiB
TypeScript

import type {
MediaAsset,
MediaCapabilities,
MediaRender,
MediaSource,
MediaUpload,
} from "@rakazo/contracts";
import { fetch as expoFetch } from "expo/fetch";
import * as Application from "expo-application";
import { File, Paths } from "expo-file-system";
import {
AssetField,
getPermissionsAsync,
Asset as LibraryAsset,
MediaType,
presentPermissionsPicker,
Query,
requestPermissionsAsync,
} from "expo-media-library";
import * as Notifications from "expo-notifications";
import { useRouter } from "expo-router";
import * as SecureStore from "expo-secure-store";
import * as Sharing from "expo-sharing";
import { useEffect, useState } from "react";
import { Platform } from "react-native";
import { authHeaders, currentApiBase, rpc, selectedSpaceId } from "./api";
import { t } from "./i18n";
import type { DeviceAsset, PushInput, PushResult } from "./media-index";
import { syncDeviceIndex } from "./media-index";
import type { LibraryAccess } from "./media-project";
import {
libraryAccess,
mediaNotificationRoute,
renderFileUrl,
shareFileName,
uploadsFromDevice,
videoOutput,
} from "./media-project";
import type { ChunkBytes, UploadTransport } from "./media-upload";
import {
combinedProgress,
hashOriginal,
MediaUploadHttpError,
uploadOriginal,
} from "./media-upload";
/*
* Native glue for Media Studio on mobile: RPC calls, the media library, the
* chunk route, sharing, and push routing. Decisions live in the pure
* media-index, media-upload, and media-project modules.
*/
export function mediaRpc<T>(
proc: string,
body: unknown = {},
options?: { timeoutMs?: number | null; signal?: AbortSignal },
): Promise<T> {
return rpc<T>(`media/${proc}`, body, options);
}
/** Media appears only when the server reports Media Studio configured. */
export function useMediaAvailable(): boolean {
const [available, setAvailable] = useState(false);
useEffect(() => {
let active = true;
void mediaRpc<MediaCapabilities>("capabilities")
.then((capabilities) => {
if (active) setAvailable(capabilities.configured);
})
.catch(() => {
if (active) setAvailable(false);
});
return () => {
active = false;
};
}, []);
return available;
}
// ---------------------------------------------------------------------------
// Library permission
// ---------------------------------------------------------------------------
const LIBRARY_PERMISSIONS = ["photo", "video"] as const;
export async function readLibraryAccess(): Promise<LibraryAccess> {
return libraryAccess(await getPermissionsAsync(false, [...LIBRARY_PERMISSIONS]));
}
export async function requestLibraryAccess(): Promise<LibraryAccess> {
return libraryAccess(await requestPermissionsAsync(false, [...LIBRARY_PERMISSIONS]));
}
/** With limited access, let the user change which items the app may see. */
export async function changeLimitedSelection(): Promise<void> {
await presentPermissionsPicker(["photo", "video"]);
}
// ---------------------------------------------------------------------------
// Device index
// ---------------------------------------------------------------------------
async function deviceId(): Promise<string> {
if (Platform.OS === "ios") {
const id = await Application.getIosIdForVendorAsync();
if (id) return `ios:${id}`;
} else if (Platform.OS === "android") {
return `android:${Application.getAndroidId()}`;
}
throw new Error(t("Failed"));
}
function storeKey(sourceId: string): string {
const scope = `${selectedSpaceId() ?? "default"}.${sourceId}`.replace(/[^A-Za-z0-9._-]/g, "_");
return `rakazo.media.sync.${scope}`;
}
async function readSyncToken(sourceId: string): Promise<string | null> {
try {
return (await SecureStore.getItemAsync(storeKey(sourceId))) || null;
} catch {
return null;
}
}
async function writeSyncToken(sourceId: string, token: string | null): Promise<void> {
try {
if (token) await SecureStore.setItemAsync(storeKey(sourceId), token);
} catch {
// The next sync then pushes everything again; the server reports it unchanged.
}
}
export async function registerDeviceSource(): Promise<MediaSource> {
return mediaRpc<MediaSource>("sources/registerClient", {
kind: "ios_library",
label: Platform.OS === "ios" ? "iPhone" : "Android",
deviceId: await deviceId(),
});
}
async function listDevice(offset: number, limit: number): Promise<DeviceAsset[]> {
const rows = await new Query()
.within(AssetField.MEDIA_TYPE, [MediaType.IMAGE, MediaType.VIDEO])
.orderBy({ key: AssetField.CREATION_TIME, ascending: true })
.offset(offset)
.limit(limit)
.exeForMetadata();
return rows.map((row) => ({
id: row.id,
filename: row.filename,
mediaType: row.mediaType,
width: row.width,
height: row.height,
duration: row.duration,
creationTime: row.creationTime,
modificationTime: row.modificationTime,
isFavorite: row.isFavorite,
}));
}
async function serverExternalIds(sourceId: string): Promise<string[]> {
const ids: string[] = [];
let cursor: string | null = null;
do {
const page: { items: MediaAsset[]; nextCursor: string | null } = await mediaRpc(
"assets/search",
{ sourceId, cursor, limit: 500 },
);
for (const asset of page.items) ids.push(asset.externalId);
cursor = page.nextCursor;
} while (cursor);
return ids;
}
let syncing: Promise<MediaSource> | null = null;
/** Index the device library into its source. One sync runs at a time. */
export function syncDeviceLibrary(): Promise<MediaSource> {
syncing ??= (async () => {
const source = await registerDeviceSource();
const result = await syncDeviceIndex({
sourceId: source.id,
syncToken: await readSyncToken(source.id),
io: {
listDevice,
locate: (id) => new LibraryAsset(id).getLocation(),
push: (input: PushInput) =>
mediaRpc<PushResult>("sources/pushItems", input, { timeoutMs: 60_000 }),
serverAssetCount: async () => {
const sources = await mediaRpc<MediaSource[]>("sources/list");
return sources.find((entry) => entry.id === source.id)?.assetCount ?? 0;
},
serverExternalIds: () => serverExternalIds(source.id),
},
});
await writeSyncToken(source.id, result.syncToken);
return source;
})().finally(() => {
syncing = null;
});
return syncing;
}
// ---------------------------------------------------------------------------
// Uploads
// ---------------------------------------------------------------------------
async function rpcUploadCall<T>(proc: string, body: unknown, timeoutMs?: number): Promise<T> {
try {
return await mediaRpc<T>(proc, body, timeoutMs ? { timeoutMs } : undefined);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (/expired/i.test(message)) throw new MediaUploadHttpError(409, message);
throw error;
}
}
async function putChunk(
uploadId: string,
index: number,
bytes: ChunkBytes,
signal?: AbortSignal,
): Promise<MediaUpload> {
const url = `${currentApiBase()}/api/media/uploads/${encodeURIComponent(uploadId)}/chunks/${index}`;
const response = await expoFetch(url, {
method: "PUT",
headers: {
...(await authHeaders()),
origin: "rakazo://",
"content-type": "application/octet-stream",
},
body: bytes,
signal,
});
const body = (await response.json().catch(() => null)) as
| (MediaUpload & { error?: undefined })
| { error?: string }
| null;
if (!response.ok) {
const message = (body && "error" in body && body.error) || `Upload failed (${response.status})`;
throw new MediaUploadHttpError(response.status, message);
}
return body as MediaUpload;
}
const transport: UploadTransport = {
begin: (input) => rpcUploadCall<MediaUpload>("uploads/begin", input),
status: (uploadId) => rpcUploadCall<MediaUpload>("uploads/status", { uploadId }),
putChunk,
complete: (uploadId) => rpcUploadCall<MediaUpload>("uploads/complete", { uploadId }, 10 * 60_000),
};
/** Hashes are slow on large videos; keep them for retries within this session. */
const hashes = new Map<string, string>();
async function uploadOne(
asset: MediaAsset,
onProgress: (fraction: number) => void,
signal?: AbortSignal,
): Promise<void> {
const original = new LibraryAsset(asset.externalId);
const file = new File(await original.getUri());
const sizeBytes = file.size;
if (!sizeBytes) throw new Error("Original not available");
const fileName = (await original.getFilename()) || asset.externalId;
const handle = file.open();
try {
const read = (start: number, length: number) => {
handle.offset = start;
return handle.readBytes(length);
};
const key = `${asset.id}:${sizeBytes}`;
let sha256 = hashes.get(key);
if (!sha256) {
sha256 = await hashOriginal(sizeBytes, read, {
signal,
onProgress: (fraction) => onProgress(fraction * 0.1),
});
hashes.set(key, sha256);
}
await uploadOriginal({
assetId: asset.id,
fileName,
sizeBytes,
sha256,
read,
transport,
signal,
onProgress: (fraction) => onProgress(0.1 + fraction * 0.9),
});
} finally {
handle.close();
}
}
/** Upload every listed item that still waits for its original. Returns the ids that failed. */
export async function uploadAssets(
assets: MediaAsset[],
deviceSourceId: string,
onProgress: (fraction: number) => void,
signal?: AbortSignal,
): Promise<string[]> {
const pending = uploadsFromDevice(assets, deviceSourceId);
const progress = pending.map(() => ({ sizeBytes: 1, fraction: 0 }));
const failed: string[] = [];
for (const [index, asset] of pending.entries()) {
signal?.throwIfAborted();
try {
await uploadOne(
asset,
(fraction) => {
const entry = progress[index];
if (entry) entry.fraction = fraction;
onProgress(combinedProgress(progress));
},
signal,
);
} catch (error) {
if (signal?.aborted) throw error;
failed.push(asset.id);
}
const entry = progress[index];
if (entry) entry.fraction = 1;
onProgress(combinedProgress(progress));
}
return failed;
}
// ---------------------------------------------------------------------------
// Playback and sharing
// ---------------------------------------------------------------------------
/** Authenticated source for expo-video. */
export async function renderVideoSource(
renderId: string,
fileName: string,
): Promise<{ uri: string; headers: Record<string, string> }> {
return {
uri: renderFileUrl(currentApiBase(), renderId, fileName),
headers: { ...(await authHeaders()), origin: "rakazo://" },
};
}
/** Download a final render and open the native share sheet. */
export async function shareRender(render: MediaRender): Promise<void> {
const video = videoOutput(render);
if (!video || render.status !== "done") return;
const target = new File(Paths.cache, shareFileName(render.id, video.fileName));
const file = await File.downloadFileAsync(
renderFileUrl(currentApiBase(), render.id, video.fileName, true),
target,
{ headers: { ...(await authHeaders()), origin: "rakazo://" }, idempotent: true },
);
await Sharing.shareAsync(file.uri, { mimeType: "video/mp4" });
}
// ---------------------------------------------------------------------------
// Push
// ---------------------------------------------------------------------------
/** Open the project when the user taps a render notification. Mount once in the root layout. */
export function useMediaNotificationRouting(): void {
const router = useRouter();
useEffect(() => {
const open = (response: Notifications.NotificationResponse | null) => {
const route = mediaNotificationRoute(response?.notification.request.content.data);
if (route) router.push(route);
};
open(Notifications.getLastNotificationResponse());
const subscription = Notifications.addNotificationResponseReceivedListener(open);
return () => subscription.remove();
}, [router]);
}