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
375 lines
12 KiB
TypeScript
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]);
|
|
}
|