From 3db3fd19cec94682ed7a779c8b85d8bc70f5b5be Mon Sep 17 00:00:00 2001 From: Richard R Date: Thu, 16 Apr 2026 13:07:31 -0600 Subject: [PATCH] refactor(tts): centralize upstream response helpers and improve settings validation Move getUpstreamStatus and getUpstreamRetryAfterSeconds to a shared utility module for consistent upstream error handling across TTS endpoints. Strengthen audiobook chapter API by introducing runtime validation for incoming settings payloads, ensuring type safety and error reporting for malformed requests. Replace in-memory Map caches with LRUMap for Replicate voice and schema lookups, improving memory management and eviction logic. --- src/app/api/audiobook/chapter/route.ts | 66 ++++++++++++++++++++----- src/app/api/tts/route.ts | 28 +---------- src/lib/server/tts/generate.ts | 28 +---------- src/lib/server/tts/upstream-response.ts | 53 ++++++++++++++++++++ src/lib/shared/tts-provider-catalog.ts | 51 ++++++++++++++++++- 5 files changed, 157 insertions(+), 69 deletions(-) create mode 100644 src/lib/server/tts/upstream-response.ts diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index 24e157a..426b0ff 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -41,7 +41,7 @@ interface ConversionRequest { bookId?: string; format?: TTSAudiobookFormat; chapterIndex?: number; - settings?: AudiobookGenerationSettings; + settings?: unknown; } type ChapterObject = { @@ -99,6 +99,29 @@ function normalizeNativeSpeedForSettings(settings: AudiobookGenerationSettings): : { ...settings, nativeSpeed: 1 }; } +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function isAudiobookFormat(value: unknown): value is TTSAudiobookFormat { + return value === 'mp3' || value === 'm4b'; +} + +function isAudiobookGenerationSettings(value: unknown): value is AudiobookGenerationSettings { + if (typeof value !== 'object' || value === null) { + return false; + } + + const record = value as Record; + return typeof record.ttsProvider === 'string' + && typeof record.ttsModel === 'string' + && typeof record.voice === 'string' + && isFiniteNumber(record.nativeSpeed) + && isFiniteNumber(record.postSpeed) + && isAudiobookFormat(record.format) + && (record.ttsInstructions === undefined || typeof record.ttsInstructions === 'string'); +} + function chapterFileMimeType(format: TTSAudiobookFormat): string { return format === 'mp3' ? 'audio/mpeg' : 'audio/mp4'; } @@ -297,24 +320,41 @@ export async function POST(request: NextRequest) { const existingChapters = listChapterObjects(objectNames); const hasChapters = existingChapters.length > 0; - let existingSettings: AudiobookGenerationSettings | null = null; + let normalizedExistingSettings: AudiobookGenerationSettings | undefined; try { - existingSettings = JSON.parse( + const parsedSettings = JSON.parse( (await getAudiobookObjectBuffer(bookId, storageUserId, 'audiobook.meta.json', testNamespace)).toString('utf8'), - ) as AudiobookGenerationSettings; + ) as unknown; + if (!isAudiobookGenerationSettings(parsedSettings)) { + console.error('Invalid audiobook.meta.json settings payload', { bookId, storageUserId }); + return NextResponse.json({ error: 'Invalid audiobook metadata settings' }, { status: 500 }); + } + normalizedExistingSettings = normalizeNativeSpeedForSettings(parsedSettings); } catch (error) { if (!isMissingBlobError(error)) throw error; - existingSettings = null; + normalizedExistingSettings = undefined; } - const normalizedExistingSettings = existingSettings ? normalizeNativeSpeedForSettings(existingSettings) : undefined; - const incomingSettings = data.settings ? normalizeNativeSpeedForSettings(data.settings) : undefined; - const mergedSettings = (normalizedExistingSettings || incomingSettings) + const incomingSettings = (() => { + if (data.settings === undefined) { + return undefined; + } + if (!isAudiobookGenerationSettings(data.settings)) { + return null; + } + return normalizeNativeSpeedForSettings(data.settings); + })(); + + if (incomingSettings === null) { + return NextResponse.json({ error: 'Invalid audiobook settings payload' }, { status: 400 }); + } + + const mergedSettings = normalizedExistingSettings && incomingSettings ? normalizeNativeSpeedForSettings({ - ...(normalizedExistingSettings || {}), - ...(incomingSettings || {}), - } as AudiobookGenerationSettings) - : undefined; + ...normalizedExistingSettings, + ...incomingSettings, + }) + : normalizedExistingSettings ?? incomingSettings; if (normalizedExistingSettings && hasChapters && incomingSettings) { const mismatch = @@ -511,7 +551,7 @@ export async function POST(request: NextRequest) { await deleteAudiobookObject(bookId, storageUserId, 'complete.mp3.manifest.json', testNamespace).catch(() => {}); await deleteAudiobookObject(bookId, storageUserId, 'complete.m4b.manifest.json', testNamespace).catch(() => {}); - if (!existingSettings && incomingSettings) { + if (!normalizedExistingSettings && incomingSettings) { await putAudiobookObject( bookId, storageUserId, diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts index 0843e1e..d41b7c0 100644 --- a/src/app/api/tts/route.ts +++ b/src/app/api/tts/route.ts @@ -14,6 +14,7 @@ import { getCachedTTSBuffer, getTTSContentType, } from '@/lib/server/tts/generate'; +import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response'; export const runtime = 'nodejs'; export const maxDuration = 60; @@ -49,33 +50,6 @@ function formatLimitForHint(limit: number): string { return String(limit); } -function getUpstreamStatus(error: unknown): number | undefined { - if (typeof error !== 'object' || error === null) return undefined; - const rec = error as Record; - if (typeof rec.status === 'number') return rec.status; - if (typeof rec.statusCode === 'number') return rec.statusCode; - const response = rec.response as { status?: unknown } | undefined; - if (response && typeof response.status === 'number') return response.status; - return undefined; -} - -function getUpstreamRetryAfterSeconds(error: unknown): number | undefined { - if (typeof error !== 'object' || error === null) return undefined; - const rec = error as Record; - const response = rec.response as { headers?: { get?: (name: string) => string | null } } | undefined; - const retryAfterHeader = response?.headers?.get?.('retry-after'); - if (!retryAfterHeader) return undefined; - const parsed = Number(retryAfterHeader); - if (Number.isFinite(parsed) && parsed > 0) { - return Math.ceil(parsed); - } - const parsedDateMs = Date.parse(retryAfterHeader); - if (!Number.isFinite(parsedDateMs)) return undefined; - const seconds = (parsedDateMs - Date.now()) / 1000; - if (seconds <= 0) return undefined; - return Math.ceil(seconds); -} - export async function POST(req: NextRequest) { let providerForError: string | null = null; let didCreateDeviceIdCookie = false; diff --git a/src/lib/server/tts/generate.ts b/src/lib/server/tts/generate.ts index e907cd8..f1fef08 100644 --- a/src/lib/server/tts/generate.ts +++ b/src/lib/server/tts/generate.ts @@ -8,6 +8,7 @@ import { supportsNativeModelSpeed, supportsTtsInstructions, } from '@/lib/shared/tts-provider-catalog'; +import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response'; import { LRUCache } from 'lru-cache'; import { createHash } from 'crypto'; import { access, readFile } from 'fs/promises'; @@ -93,33 +94,6 @@ function sleepWithSignal(ms: number, signal: AbortSignal): Promise { }); } -function getUpstreamStatus(error: unknown): number | undefined { - if (typeof error !== 'object' || error === null) return undefined; - const rec = error as Record; - if (typeof rec.status === 'number') return rec.status; - if (typeof rec.statusCode === 'number') return rec.statusCode; - const response = rec.response as { status?: unknown } | undefined; - if (response && typeof response.status === 'number') return response.status; - return undefined; -} - -function getUpstreamRetryAfterSeconds(error: unknown): number | undefined { - if (typeof error !== 'object' || error === null) return undefined; - const rec = error as Record; - const response = rec.response as { headers?: { get?: (name: string) => string | null } } | undefined; - const retryAfterHeader = response?.headers?.get?.('retry-after'); - if (!retryAfterHeader) return undefined; - const parsed = Number(retryAfterHeader); - if (Number.isFinite(parsed) && parsed > 0) { - return parsed; - } - const parsedDateMs = Date.parse(retryAfterHeader); - if (!Number.isFinite(parsedDateMs)) return undefined; - const seconds = (parsedDateMs - Date.now()) / 1000; - if (seconds <= 0) return undefined; - return Math.ceil(seconds); -} - function applyReplicateCooldown(cooldownMs: number) { if (!Number.isFinite(cooldownMs) || cooldownMs <= 0) return; const next = Date.now() + cooldownMs; diff --git a/src/lib/server/tts/upstream-response.ts b/src/lib/server/tts/upstream-response.ts new file mode 100644 index 0000000..e27f41b --- /dev/null +++ b/src/lib/server/tts/upstream-response.ts @@ -0,0 +1,53 @@ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +export function getUpstreamStatus(error: unknown): number | undefined { + if (!isRecord(error)) return undefined; + if (typeof error.status === 'number') return error.status; + if (typeof error.statusCode === 'number') return error.statusCode; + const response = isRecord(error.response) ? error.response : undefined; + if (response && typeof response.status === 'number') return response.status; + return undefined; +} + +function readRetryAfterHeader(error: unknown): string | undefined { + if (!isRecord(error)) return undefined; + const response = isRecord(error.response) ? error.response : undefined; + if (!response) return undefined; + + const headers = response.headers; + if (isRecord(headers) && typeof headers.get === 'function') { + const value = headers.get('retry-after'); + return typeof value === 'string' && value.length > 0 ? value : undefined; + } + + if (isRecord(headers)) { + const lowerCaseValue = headers['retry-after']; + if (typeof lowerCaseValue === 'string' && lowerCaseValue.length > 0) { + return lowerCaseValue; + } + const canonicalValue = headers['Retry-After']; + if (typeof canonicalValue === 'string' && canonicalValue.length > 0) { + return canonicalValue; + } + } + + return undefined; +} + +export function getUpstreamRetryAfterSeconds(error: unknown): number | undefined { + const retryAfterHeader = readRetryAfterHeader(error); + if (!retryAfterHeader) return undefined; + + const parsed = Number(retryAfterHeader); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + + const parsedDateMs = Date.parse(retryAfterHeader); + if (!Number.isFinite(parsedDateMs)) return undefined; + const seconds = (parsedDateMs - Date.now()) / 1000; + if (seconds <= 0) return undefined; + return Math.ceil(seconds); +} diff --git a/src/lib/shared/tts-provider-catalog.ts b/src/lib/shared/tts-provider-catalog.ts index 5d25137..08a4d99 100644 --- a/src/lib/shared/tts-provider-catalog.ts +++ b/src/lib/shared/tts-provider-catalog.ts @@ -124,8 +124,55 @@ const DEEPINFRA_DEFAULT_VOICES_BY_MODEL: Record = { 'Zyphra/Zonos-v0.1-hybrid': ['random'], 'Zyphra/Zonos-v0.1-transformer': ['random'], }; -const replicateVoiceInputKeyCache = new Map(); -const replicateOpenApiSchemaPromiseCache = new Map>(); + +class LRUMap { + private readonly maxEntries: number; + private readonly store = new Map(); + + constructor(maxEntries: number) { + this.maxEntries = Math.max(1, maxEntries); + } + + get(key: K): V | undefined { + const value = this.store.get(key); + if (value === undefined) { + return undefined; + } + this.store.delete(key); + this.store.set(key, value); + return value; + } + + set(key: K, value: V): this { + if (this.store.has(key)) { + this.store.delete(key); + } + this.store.set(key, value); + + if (this.store.size > this.maxEntries) { + const oldestKey = this.store.keys().next().value as K | undefined; + if (oldestKey !== undefined) { + this.store.delete(oldestKey); + } + } + + return this; + } + + delete(key: K): boolean { + return this.store.delete(key); + } +} + +const REPLICATE_VOICE_INPUT_KEY_CACHE_MAX_ENTRIES = 128; +const REPLICATE_OPENAPI_SCHEMA_PROMISE_CACHE_MAX_ENTRIES = 128; + +const replicateVoiceInputKeyCache = new LRUMap( + REPLICATE_VOICE_INPUT_KEY_CACHE_MAX_ENTRIES, +); +const replicateOpenApiSchemaPromiseCache = new LRUMap>( + REPLICATE_OPENAPI_SCHEMA_PROMISE_CACHE_MAX_ENTRIES, +); export const TTS_PROVIDER_DEFINITIONS: TtsProviderDefinition[] = [ {