diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index 7f75b8c..83c89a5 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -33,10 +33,16 @@ import { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials'; import { resolveEffectiveTtsInstructions } from '@/lib/server/admin/tts-instructions'; import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response'; import { defaultVoiceForProviderType, resolveTtsModelForProvider, resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy'; -import { isBuiltInTtsProviderId, isTtsProviderType } from '@/lib/shared/tts-provider-catalog'; +import { isBuiltInTtsProviderId } from '@/lib/shared/tts-provider-catalog'; import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config'; +import { listAdminProviders } from '@/lib/server/admin/providers'; import type { AudiobookGenerationSettings } from '@/types/client'; import type { TTSAudiobookFormat } from '@/types/tts'; +import { + canonicalizeAudiobookSettingsForRuntime, + coerceAudiobookGenerationSettings, + type SharedProviderPolicyEntry, +} from '@/lib/server/audiobooks/settings'; export const dynamic = 'force-dynamic'; @@ -109,30 +115,6 @@ 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.providerRef === 'string' - && isTtsProviderType(record.providerType) - && 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'; } @@ -298,6 +280,7 @@ export async function POST(request: NextRequest) { if (ctxOrRes instanceof Response) return ctxOrRes; const { userId, authEnabled, user } = ctxOrRes; + const runtimeConfig = await getResolvedRuntimeConfig(); const testNamespace = getOpenReaderTestNamespace(request.headers); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId); @@ -333,15 +316,20 @@ export async function POST(request: NextRequest) { const hasChapters = existingChapters.length > 0; let normalizedExistingSettings: AudiobookGenerationSettings | undefined; + let existingSettingsNeedsMigration = false; try { const parsedSettings = JSON.parse( (await getAudiobookObjectBuffer(bookId, storageUserId, 'audiobook.meta.json', testNamespace)).toString('utf8'), ) as unknown; - if (!isAudiobookGenerationSettings(parsedSettings)) { + const existingResult = coerceAudiobookGenerationSettings(parsedSettings, { + fallbackProviderRef: runtimeConfig.defaultTtsProvider, + }); + if (!existingResult.settings) { console.error('Invalid audiobook.meta.json settings payload', { bookId, storageUserId }); return NextResponse.json({ error: 'Invalid audiobook metadata settings' }, { status: 500 }); } - normalizedExistingSettings = normalizeNativeSpeedForSettings(parsedSettings); + normalizedExistingSettings = normalizeNativeSpeedForSettings(existingResult.settings); + existingSettingsNeedsMigration = existingResult.migrated; } catch (error) { if (!isMissingBlobError(error)) throw error; normalizedExistingSettings = undefined; @@ -351,33 +339,91 @@ export async function POST(request: NextRequest) { if (data.settings === undefined) { return undefined; } - if (!isAudiobookGenerationSettings(data.settings)) { + const incomingResult = coerceAudiobookGenerationSettings(data.settings, { + fallbackProviderRef: runtimeConfig.defaultTtsProvider, + }); + if (!incomingResult.settings) { return null; } - return normalizeNativeSpeedForSettings(data.settings); + return normalizeNativeSpeedForSettings(incomingResult.settings); })(); if (incomingSettings === null) { return NextResponse.json({ error: 'Invalid audiobook settings payload' }, { status: 400 }); } - const mergedSettings = normalizedExistingSettings && incomingSettings + const sharedProviders: SharedProviderPolicyEntry[] = runtimeConfig.restrictUserApiKeys + ? (await listAdminProviders()) + .filter((entry) => entry.enabled) + .map((entry) => ({ + slug: entry.slug, + providerType: entry.providerType, + defaultModel: entry.defaultModel, + defaultInstructions: entry.defaultInstructions, + })) + : []; + + if (runtimeConfig.restrictUserApiKeys && normalizedExistingSettings) { + const next = canonicalizeAudiobookSettingsForRuntime({ + settings: normalizedExistingSettings, + restrictUserApiKeys: runtimeConfig.restrictUserApiKeys, + fallbackProviderRef: runtimeConfig.defaultTtsProvider, + showAllProviderModels: runtimeConfig.showAllProviderModels, + sharedProviders, + }); + if (JSON.stringify(next) !== JSON.stringify(normalizedExistingSettings)) { + existingSettingsNeedsMigration = true; + } + normalizedExistingSettings = next; + } + + let normalizedIncomingSettings = incomingSettings; + if (runtimeConfig.restrictUserApiKeys && normalizedIncomingSettings) { + normalizedIncomingSettings = canonicalizeAudiobookSettingsForRuntime({ + settings: normalizedIncomingSettings, + restrictUserApiKeys: runtimeConfig.restrictUserApiKeys, + fallbackProviderRef: runtimeConfig.defaultTtsProvider, + showAllProviderModels: runtimeConfig.showAllProviderModels, + sharedProviders, + }); + } + + if (normalizedExistingSettings && existingSettingsNeedsMigration) { + try { + await putAudiobookObject( + bookId, + storageUserId, + 'audiobook.meta.json', + Buffer.from(JSON.stringify(normalizedExistingSettings, null, 2), 'utf8'), + 'application/json; charset=utf-8', + testNamespace, + ); + } catch (error) { + console.warn('Failed to persist migrated audiobook metadata settings', { + bookId, + storageUserId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + const mergedSettings = normalizedExistingSettings && normalizedIncomingSettings ? normalizeNativeSpeedForSettings({ ...normalizedExistingSettings, - ...incomingSettings, + ...normalizedIncomingSettings, }) - : normalizedExistingSettings ?? incomingSettings; + : normalizedExistingSettings ?? normalizedIncomingSettings; - if (normalizedExistingSettings && hasChapters && incomingSettings) { + if (normalizedExistingSettings && hasChapters && normalizedIncomingSettings) { const mismatch = - normalizedExistingSettings.providerRef !== incomingSettings.providerRef || - normalizedExistingSettings.providerType !== incomingSettings.providerType || - normalizedExistingSettings.ttsModel !== incomingSettings.ttsModel || - normalizedExistingSettings.voice !== incomingSettings.voice || - normalizedExistingSettings.nativeSpeed !== incomingSettings.nativeSpeed || - normalizedExistingSettings.postSpeed !== incomingSettings.postSpeed || - normalizedExistingSettings.format !== incomingSettings.format || - (normalizedExistingSettings.ttsInstructions || '') !== (incomingSettings.ttsInstructions || ''); + normalizedExistingSettings.providerRef !== normalizedIncomingSettings.providerRef || + normalizedExistingSettings.providerType !== normalizedIncomingSettings.providerType || + normalizedExistingSettings.ttsModel !== normalizedIncomingSettings.ttsModel || + normalizedExistingSettings.voice !== normalizedIncomingSettings.voice || + normalizedExistingSettings.nativeSpeed !== normalizedIncomingSettings.nativeSpeed || + normalizedExistingSettings.postSpeed !== normalizedIncomingSettings.postSpeed || + normalizedExistingSettings.format !== normalizedIncomingSettings.format || + (normalizedExistingSettings.ttsInstructions || '') !== (normalizedIncomingSettings.ttsInstructions || ''); if (mismatch) { return NextResponse.json({ error: 'Audiobook settings mismatch', settings: normalizedExistingSettings }, { status: 409 }); } @@ -419,7 +465,6 @@ export async function POST(request: NextRequest) { || mergedSettings?.providerRef || 'openai'; providerForError = requestedProvider; - const runtimeConfig = await getResolvedRuntimeConfig(); const credResolved = await resolveTtsCredentials({ providerHeader: requestedProvider, apiKeyHeader: request.headers.get('x-openai-key'), diff --git a/src/lib/server/audiobooks/settings.ts b/src/lib/server/audiobooks/settings.ts new file mode 100644 index 0000000..1e61ef7 --- /dev/null +++ b/src/lib/server/audiobooks/settings.ts @@ -0,0 +1,152 @@ +import { + resolveTtsModelForProvider, + resolveTtsProviderModelPolicy, + resolveProviderDefaults, + type ProviderDefaultResolverEntry, +} from '@/lib/shared/tts-provider-policy'; +import { isBuiltInTtsProviderId, isTtsProviderType, type TtsProviderId } from '@/lib/shared/tts-provider-catalog'; +import type { AudiobookGenerationSettings } from '@/types/client'; +import type { TTSAudiobookFormat } from '@/types/tts'; +import { resolveEffectiveTtsInstructions } from '@/lib/server/admin/tts-instructions'; + +function isAudiobookFormat(value: unknown): value is TTSAudiobookFormat { + return value === 'mp3' || value === 'm4b'; +} + +function toFiniteNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +export function coerceAudiobookGenerationSettings( + value: unknown, + options?: { + fallbackProviderRef?: string | null | undefined; + sharedProviders?: readonly ProviderDefaultResolverEntry[]; + }, +): { settings: AudiobookGenerationSettings | null; migrated: boolean } { + if (typeof value !== 'object' || value === null) { + return { settings: null, migrated: false }; + } + + const record = value as Record; + const hasLegacyProvider = typeof record.ttsProvider === 'string'; + const rawProviderRef = typeof record.providerRef === 'string' + ? record.providerRef + : hasLegacyProvider + ? (record.ttsProvider as string) + : ''; + + const defaults = resolveProviderDefaults({ + providerRef: rawProviderRef, + providerType: isTtsProviderType(record.providerType) ? record.providerType : undefined, + sharedProviders: options?.sharedProviders, + fallbackProviderRef: options?.fallbackProviderRef, + }); + + const ttsModel = typeof record.ttsModel === 'string' ? record.ttsModel.trim() : ''; + const voice = typeof record.voice === 'string' ? record.voice.trim() : ''; + const nativeSpeed = toFiniteNumber(record.nativeSpeed); + const postSpeed = toFiniteNumber(record.postSpeed); + const format = record.format; + + if (!defaults.providerRef || !ttsModel || !voice || nativeSpeed === null || postSpeed === null || !isAudiobookFormat(format)) { + return { settings: null, migrated: false }; + } + + const settings: AudiobookGenerationSettings = { + providerRef: defaults.providerRef, + providerType: defaults.providerType, + ttsModel, + voice, + nativeSpeed, + postSpeed, + format, + ...(typeof record.ttsInstructions === 'string' ? { ttsInstructions: record.ttsInstructions } : {}), + }; + + const migrated = + hasLegacyProvider + || typeof record.providerRef !== 'string' + || !isTtsProviderType(record.providerType) + || record.providerRef !== defaults.providerRef + || record.providerType !== defaults.providerType; + + return { settings, migrated }; +} + +export type SharedProviderPolicyEntry = { + slug: string; + providerType: TtsProviderId; + defaultModel: string | null; + defaultInstructions: string | null; +}; + +function normalizeNativeSpeedForSettings(settings: AudiobookGenerationSettings): AudiobookGenerationSettings { + return resolveTtsProviderModelPolicy({ + providerRef: settings.providerRef, + providerType: settings.providerType, + model: settings.ttsModel, + }).supportsNativeModelSpeed + ? settings + : { ...settings, nativeSpeed: 1 }; +} + +function resolveRestrictedProviderRef( + providerRef: string, + fallbackProviderRef: string, + sharedProviders: SharedProviderPolicyEntry[], +): string { + const requestedIsBuiltIn = isBuiltInTtsProviderId(providerRef); + const fallbackIsBuiltIn = isBuiltInTtsProviderId(fallbackProviderRef); + const requestedSharedSlug = requestedIsBuiltIn ? '' : providerRef; + const fallbackSharedSlug = fallbackIsBuiltIn ? '' : fallbackProviderRef; + const preferredSharedSlug = requestedSharedSlug || fallbackSharedSlug; + if (preferredSharedSlug) return preferredSharedSlug; + return sharedProviders[0]?.slug || providerRef; +} + +export function canonicalizeAudiobookSettingsForRuntime(input: { + settings: AudiobookGenerationSettings; + restrictUserApiKeys: boolean; + fallbackProviderRef: string; + showAllProviderModels: boolean; + sharedProviders: SharedProviderPolicyEntry[]; +}): AudiobookGenerationSettings { + if (!input.restrictUserApiKeys) { + return normalizeNativeSpeedForSettings(input.settings); + } + + const restrictedProviderRef = resolveRestrictedProviderRef( + input.settings.providerRef, + input.fallbackProviderRef, + input.sharedProviders, + ); + const sharedProvider = input.sharedProviders.find((entry) => entry.slug === restrictedProviderRef); + const providerType = sharedProvider?.providerType || input.settings.providerType; + const ttsModel = resolveTtsModelForProvider({ + providerRef: restrictedProviderRef, + providerType, + model: input.settings.ttsModel, + sharedProviders: sharedProvider ? [sharedProvider] : [], + fallbackProviderRef: input.fallbackProviderRef, + showAllProviderModels: input.showAllProviderModels, + }); + const ttsInstructions = resolveEffectiveTtsInstructions({ + model: ttsModel, + requestInstructions: input.settings.ttsInstructions, + sharedDefaultInstructions: sharedProvider?.defaultInstructions, + }) ?? ''; + + return normalizeNativeSpeedForSettings({ + ...input.settings, + providerRef: restrictedProviderRef, + providerType, + ttsModel, + ttsInstructions, + }); +} diff --git a/tests/unit/audiobook-settings.spec.ts b/tests/unit/audiobook-settings.spec.ts new file mode 100644 index 0000000..af3c788 --- /dev/null +++ b/tests/unit/audiobook-settings.spec.ts @@ -0,0 +1,115 @@ +import { test, expect } from '@playwright/test'; +import { + canonicalizeAudiobookSettingsForRuntime, + coerceAudiobookGenerationSettings, +} from '../../src/lib/server/audiobooks/settings'; + +test.describe('coerceAudiobookGenerationSettings', () => { + test('accepts current metadata shape without migration', () => { + const result = coerceAudiobookGenerationSettings({ + providerRef: 'openai', + providerType: 'openai', + ttsModel: 'tts-1', + voice: 'alloy', + nativeSpeed: 1, + postSpeed: 1, + format: 'mp3', + ttsInstructions: 'keep calm', + }); + + expect(result.migrated).toBe(false); + expect(result.settings).toEqual({ + providerRef: 'openai', + providerType: 'openai', + ttsModel: 'tts-1', + voice: 'alloy', + nativeSpeed: 1, + postSpeed: 1, + format: 'mp3', + ttsInstructions: 'keep calm', + }); + }); + + test('coerces legacy ttsProvider metadata and marks as migrated', () => { + const result = coerceAudiobookGenerationSettings({ + ttsProvider: 'custom-openai', + ttsModel: 'kokoro', + voice: 'af_sarah', + nativeSpeed: 1, + postSpeed: 1, + format: 'm4b', + }); + + expect(result.migrated).toBe(true); + expect(result.settings).toEqual({ + providerRef: 'custom-openai', + providerType: 'custom-openai', + ttsModel: 'kokoro', + voice: 'af_sarah', + nativeSpeed: 1, + postSpeed: 1, + format: 'm4b', + }); + }); + + test('normalizes default-openai legacy ref using fallback provider', () => { + const result = coerceAudiobookGenerationSettings({ + ttsProvider: 'default-openai', + ttsModel: 'tts-1', + voice: 'alloy', + nativeSpeed: 1, + postSpeed: 1, + format: 'mp3', + }, { + fallbackProviderRef: 'openai', + }); + + expect(result.migrated).toBe(true); + expect(result.settings?.providerRef).toBe('openai'); + expect(result.settings?.providerType).toBe('openai'); + }); + + test('rejects invalid payloads', () => { + const result = coerceAudiobookGenerationSettings({ + providerRef: 'openai', + providerType: 'openai', + ttsModel: 'tts-1', + nativeSpeed: 1, + postSpeed: 1, + format: 'mp3', + }); + + expect(result.settings).toBeNull(); + }); + + test('canonicalizes built-in provider settings to shared provider in restricted mode', () => { + const settings = canonicalizeAudiobookSettingsForRuntime({ + settings: { + providerRef: 'custom-openai', + providerType: 'custom-openai', + ttsModel: 'kokoro', + voice: 'af_sarah', + nativeSpeed: 1, + postSpeed: 1, + format: 'mp3', + ttsInstructions: '', + }, + restrictUserApiKeys: true, + fallbackProviderRef: 'shared-openai', + showAllProviderModels: false, + sharedProviders: [ + { + slug: 'shared-openai', + providerType: 'openai', + defaultModel: 'gpt-4o-mini-tts', + defaultInstructions: 'Speak with warmth.', + }, + ], + }); + + expect(settings.providerRef).toBe('shared-openai'); + expect(settings.providerType).toBe('openai'); + expect(settings.ttsModel).toBe('gpt-4o-mini-tts'); + expect(settings.ttsInstructions).toBe('Speak with warmth.'); + }); +});