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.
This commit is contained in:
parent
893f74f038
commit
3db3fd19ce
5 changed files with 157 additions and 69 deletions
|
|
@ -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<string, unknown>;
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
});
|
||||
}
|
||||
|
||||
function getUpstreamStatus(error: unknown): number | undefined {
|
||||
if (typeof error !== 'object' || error === null) return undefined;
|
||||
const rec = error as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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;
|
||||
|
|
|
|||
53
src/lib/server/tts/upstream-response.ts
Normal file
53
src/lib/server/tts/upstream-response.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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);
|
||||
}
|
||||
|
|
@ -124,8 +124,55 @@ const DEEPINFRA_DEFAULT_VOICES_BY_MODEL: Record<string, readonly string[]> = {
|
|||
'Zyphra/Zonos-v0.1-hybrid': ['random'],
|
||||
'Zyphra/Zonos-v0.1-transformer': ['random'],
|
||||
};
|
||||
const replicateVoiceInputKeyCache = new Map<string, ReplicateVoiceInputKey>();
|
||||
const replicateOpenApiSchemaPromiseCache = new Map<string, Promise<unknown | null>>();
|
||||
|
||||
class LRUMap<K, V> {
|
||||
private readonly maxEntries: number;
|
||||
private readonly store = new Map<K, V>();
|
||||
|
||||
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<string, ReplicateVoiceInputKey>(
|
||||
REPLICATE_VOICE_INPUT_KEY_CACHE_MAX_ENTRIES,
|
||||
);
|
||||
const replicateOpenApiSchemaPromiseCache = new LRUMap<string, Promise<unknown | null>>(
|
||||
REPLICATE_OPENAPI_SCHEMA_PROMISE_CACHE_MAX_ENTRIES,
|
||||
);
|
||||
|
||||
export const TTS_PROVIDER_DEFINITIONS: TtsProviderDefinition[] = [
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue