/** * flow-media-url.ts — recovery for useapi.net Google Flow v1 **missing media * download links** (API change 2026-07-27). * * Google issues Flow's signed media URLs from an endpoint that rate-limits by * NETWORK ADDRESS, so it periodically refuses useapi's outbound calls and * returns an "unusual traffic" block page instead of a link. useapi now OMITS * `videoUrl` / `thumbnailUrl` / `fifeUrl` / `servingBaseUri` / `previewUrl` / * `audioUrl` rather than handing back a URL that does not work. * * The load-bearing consequence: **a missing URL is not a failed generation.** * The clip exists and was charged; only the link could not be minted at that * moment. Code that treats absence as failure turns a recoverable hiccup into a * lost, paid-for render. Per useapi's logs this ran twice in seven days (~4 h * and ~40 min) — rare, but while it lasts it hits a large share of requests. * * Three documented recovery routes, in preference order: * 1. Keep polling `GET /jobs/{jobId}` — completed video jobs re-resolve missing * URLs automatically and the recovered link is saved. Jobs < 24 h old only, * and **at most one re-resolve per minute** ({@link FLOW_JOB_REPOLL_MIN_MS}); * a tight loop lengthens the block rather than shortening it. * 2. `GET /assets/{mediaGenerationId}` — link route for when the job is gone. * `503` + `Retry-After` = coming, wait that long; `404` = permanent. * 3. `GET /assets/{mediaGenerationId}?raw=true` — streams the bytes through * useapi over a different Google route the block does not touch. **Video * only** (an image id returns 400). The whole file transits useapi, so it * is the way OUT of a stuck download, not the normal download path. * * Images never need any of this: when `fifeUrl` is absent the picture arrives * inline as base64 in `encodedImage` — see {@link pickFlowImageBytes}. * * Spec: https://useapi.net/docs/api-google-flow-v1 (LLM text form: * https://useapi.net/assets/aibot/api-google-flow-v1.txt). * * Everything here takes an injectable fetch/sleep so it is unit-testable offline. */ export const FLOW_MEDIA_BASE = 'https://api.useapi.net/v1/google-flow'; /** * Minimum gap between polls of a COMPLETED job whose URL is still missing. A job * re-resolves at most once a minute, and the Google block only clears once * traffic stops — so polling faster actively hurts. This floor applies ONLY * after completion; in-progress polling stays at its normal cadence. */ export const FLOW_JOB_REPOLL_MIN_MS = 60_000; /** Default ceiling on URL-recovery waiting, separate from any generation budget. */ export const FLOW_URL_RECOVERY_MAX_WAIT_MS = 3 * 60_000; export interface FlowMediaFetchResponse { ok: boolean; status: number; text(): Promise; arrayBuffer(): Promise; headers?: { get(name: string): string | null }; } export type FlowMediaFetchLike = ( input: string, init?: { method?: string; headers?: Record }, ) => Promise; // ─── pure helpers ─────────────────────────────────────────────────────────── /** `GET /assets/{id}` — the link route. The id MUST be URL-encoded (it contains `:`). */ export function flowAssetUrl(mediaGenerationId: string, baseUrl: string = FLOW_MEDIA_BASE): string { return `${baseUrl.replace(/\/$/, '')}/assets/${encodeURIComponent(mediaGenerationId)}`; } /** `GET /assets/{id}?raw=true` — the byte-streaming route. Video ids only. */ export function flowRawAssetUrl(mediaGenerationId: string, baseUrl: string = FLOW_MEDIA_BASE): string { return `${flowAssetUrl(mediaGenerationId, baseUrl)}?raw=true`; } /** * True when a media item's own status says generation SUCCEEDED. This is the * discriminator that separates "the link isn't minted yet" (retry) from "this * generation failed" (never retry — a failed item never carries a URL). * * Items that carry no `mediaStatus` at all are treated as successful: the older * `operations[]` shape omits it, and the caller only reaches here on a job the * API already reported as `completed`. */ export function isFlowMediaSuccessful(mediaItem: unknown): boolean { const status = (mediaItem as { mediaMetadata?: { mediaStatus?: { mediaGenerationStatus?: unknown } }; })?.mediaMetadata?.mediaStatus?.mediaGenerationStatus; if (typeof status !== 'string') return true; return status === 'MEDIA_GENERATION_STATUS_SUCCESSFUL'; } /** * Resolve a generated image to its bytes-source. `fifeUrl` and `encodedImage` * are mutually exclusive — the API sends the base64 bytes precisely when it * could not mint a URL — so read the URL first and decode the inline bytes when * it is absent. Returns null only when the item carries neither. */ export function pickFlowImageBytes( generatedImage: { fifeUrl?: string | undefined; encodedImage?: string | undefined } | undefined, ): { kind: 'url'; url: string } | { kind: 'inline'; bytes: Buffer } | null { const url = generatedImage?.fifeUrl; if (typeof url === 'string' && url) return { kind: 'url', url }; const encoded = generatedImage?.encodedImage; if (typeof encoded === 'string' && encoded) { return { kind: 'inline', bytes: Buffer.from(encoded, 'base64') }; } return null; } /** * Server-stated wait in ms from a `Retry-After` header (seconds or an HTTP/ISO * date), or null. `GET /assets/{id}` always sets it on a 503, so honoring it * waits the window the API actually reports instead of a hard-coded guess. */ export function flowAssetRetryAfterMs(res: { headers?: { get(name: string): string | null } }): number | null { const header = res.headers?.get?.('retry-after'); if (!header) return null; const secs = Number(header); if (Number.isFinite(secs) && secs >= 0) return secs * 1000; const at = Date.parse(header); if (!Number.isNaN(at)) { const delta = at - Date.now(); if (delta > 0) return delta; } return null; } /** A media id Google has no record of — permanent, never worth retrying. */ export class FlowMediaNotFoundError extends Error {} // ─── I/O ──────────────────────────────────────────────────────────────────── export interface FlowMediaRecoveryOptions { mediaGenerationId: string; apiToken: string; baseUrl?: string; fetchImpl?: FlowMediaFetchLike; sleepImpl?: (ms: number) => Promise; /** Ceiling on total 503 waiting (default {@link FLOW_URL_RECOVERY_MAX_WAIT_MS}). */ maxWaitMs?: number; } const defaultSleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); function resolvedFetch(fetchImpl?: FlowMediaFetchLike): FlowMediaFetchLike { return fetchImpl ?? (fetch as unknown as FlowMediaFetchLike); } /** * Route 2 — ask `GET /assets/{mediaGenerationId}` for a signed URL, honoring the * `Retry-After` on a 503 until `maxWaitMs` is spent. Returns the URL, or null * when the budget runs out with the link still unavailable (the caller should * then fall back to {@link downloadFlowVideoRaw}). * * Throws {@link FlowMediaNotFoundError} on 404 — that one never resolves. */ export async function resolveFlowMediaUrl(options: FlowMediaRecoveryOptions): Promise { const fetchImpl = resolvedFetch(options.fetchImpl); const sleep = options.sleepImpl ?? defaultSleep; const maxWaitMs = options.maxWaitMs ?? FLOW_URL_RECOVERY_MAX_WAIT_MS; const url = flowAssetUrl(options.mediaGenerationId, options.baseUrl ?? FLOW_MEDIA_BASE); const headers = { Authorization: `Bearer ${options.apiToken}` }; let waited = 0; for (;;) { const res = await fetchImpl(url, { method: 'GET', headers }); const text = await res.text(); if (res.ok) { let parsed: { url?: unknown } = {}; try { parsed = JSON.parse(text) as { url?: unknown }; } catch { /* fall through */ } return typeof parsed.url === 'string' && parsed.url ? parsed.url : null; } if (res.status === 404) { throw new FlowMediaNotFoundError( `Google holds no media under mediaGenerationId ${options.mediaGenerationId} — retrying will not help.`, ); } if (res.status !== 503) return null; // 503 = "not ready yet" (a few seconds) or "URL temporarily unavailable" // (the rate-limit block). Both state their own window. Floor it: a legal // `Retry-After: 0` would otherwise spin this loop forever without ever // advancing the budget — a hang in an unattended render. const wait = Math.max(flowAssetRetryAfterMs(res) ?? 10_000, 1_000); if (waited + wait > maxWaitMs) return null; await sleep(wait); waited += wait; } } /** * Route 3 — stream the video bytes through useapi with `?raw=true`. Works while * signed URLs are blocked, because it reads over a different Google route. * **Video ids only**; an image id is rejected with 400 (use `encodedImage`). */ export async function downloadFlowVideoRaw(options: FlowMediaRecoveryOptions): Promise { const fetchImpl = resolvedFetch(options.fetchImpl); const res = await fetchImpl(flowRawAssetUrl(options.mediaGenerationId, options.baseUrl ?? FLOW_MEDIA_BASE), { method: 'GET', headers: { Authorization: `Bearer ${options.apiToken}` }, }); if (!res.ok) { const detail = await res.text().catch(() => ''); if (res.status === 404) { throw new FlowMediaNotFoundError( `Google holds no media under mediaGenerationId ${options.mediaGenerationId} — retrying will not help.`, ); } throw new Error( `Flow raw download failed (HTTP ${res.status}) for ${options.mediaGenerationId}: ${detail.slice(0, 200)}`, ); } return Buffer.from(await res.arrayBuffer()); } export interface FlowVideoBytesResult { bytes: Buffer; /** Which route produced them — worth logging, since `raw` means Google is blocking links. */ via: 'signed-url' | 'raw'; /** The signed URL, when that is how the bytes were fetched. */ url?: string; } /** * Recover a Flow video's BYTES when the job/generation response carried no * `videoUrl`: try the link route first (preferred — the bytes then come * straight from Google), fall back to streaming through useapi. * * The terminal error names the `mediaGenerationId` so an operator can recover * the clip by hand once the block clears, instead of re-rendering and paying twice. */ export async function recoverFlowVideoBytes(options: FlowMediaRecoveryOptions): Promise { const fetchImpl = resolvedFetch(options.fetchImpl); const signedUrl = await resolveFlowMediaUrl(options); if (signedUrl) { const res = await fetchImpl(signedUrl, { method: 'GET' }); if (res.ok) return { bytes: Buffer.from(await res.arrayBuffer()), via: 'signed-url', url: signedUrl }; // A resolved-but-dead link is exactly what raw exists for; don't give up here. } return { bytes: await downloadFlowVideoRaw(options), via: 'raw' }; }