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[] = [ {