diff --git a/docs-site/docs/configure/admin-panel.md b/docs-site/docs/configure/admin-panel.md index 41fd0c2..4def5a3 100644 --- a/docs-site/docs/configure/admin-panel.md +++ b/docs-site/docs/configure/admin-panel.md @@ -54,7 +54,7 @@ On first boot, if `admin_providers` is empty and the legacy `API_KEY` env var is - slug `default-openai`, displayName `Default (from env)`, providerType `custom-openai` - baseUrl from `API_BASE`, apiKey from `API_KEY` (encrypted) -- defaultModel from `NEXT_PUBLIC_DEFAULT_TTS_MODEL` if set +- defaultModel set to `kokoro` (you can edit it in Admin → Shared providers) After this seed runs, the legacy `API_KEY` / `API_BASE` env vars are no longer read by the TTS routes — the DB row is authoritative. You can rename, edit, disable, or delete this row like any other from the admin UI, and remove the env vars from your `.env` when convenient. @@ -65,10 +65,10 @@ Runtime-editable settings, one row per key: | Key | What it controls | | --- | --- | | `defaultTtsProvider` | Default provider id new users start with (built-in id or shared slug). | -| `defaultTtsModel` | Default model id new users start with. | | `restrictUserApiKeys` | Restrict user-supplied API keys/base URLs; when `true`, only admin shared providers are allowed. | | `enableTtsProvidersTab` | Whether the user-facing TTS Provider tab in Settings is shown. | | `showAllDeepInfraModels` | Show the full DeepInfra catalog vs. the Kokoro-only subset. | +| `showAllProviderModels` | When `false`, users are restricted to each provider's default model (shared provider `defaultModel` or built-in provider default). | | `enableWordHighlight` | Enable whisper.cpp word-by-word highlighting during TTS playback. | | `enableAudiobookExport` | Show the audiobook export entry points on PDF/EPUB pages. | | `enableDocxConversion` | Accept .docx uploads (converted to PDF server-side). | diff --git a/docs-site/docs/configure/tts-provider-guides/replicate.md b/docs-site/docs/configure/tts-provider-guides/replicate.md index 01cfc31..ff485cd 100644 --- a/docs-site/docs/configure/tts-provider-guides/replicate.md +++ b/docs-site/docs/configure/tts-provider-guides/replicate.md @@ -17,9 +17,10 @@ Use Replicate's hosted TTS models as your provider. ```env API_KEY=r8_... -NEXT_PUBLIC_DEFAULT_TTS_MODEL=alphanumericuser/kokoro-82m:89b6fa84e4fa2dd6bd3a96be3e1f12827a3516c9fda8fddbac7a0be131c9a6f5 ``` +Then update the shared provider's **Default model** in **Settings → Admin → Shared providers**. + **Per-user Settings → TTS Provider (only when `restrictUserApiKeys=false`):** 1. Set provider to `Replicate`. diff --git a/docs-site/docs/deploy/vercel-deployment.md b/docs-site/docs/deploy/vercel-deployment.md index 92e1f14..43fdce5 100644 --- a/docs-site/docs/deploy/vercel-deployment.md +++ b/docs-site/docs/deploy/vercel-deployment.md @@ -55,7 +55,7 @@ After the first successful deploy and admin login, open **Settings → Admin** a - `enableTtsProvidersTab=false` if you want shared-provider-only UX. - `restrictUserApiKeys=true` to block user BYOK through the hosted server. - `defaultTtsProvider=replicate` (or your preferred shared slug). - - `defaultTtsModel=alphanumericuser/kokoro-82m:89b6fa84e4fa2dd6bd3a96be3e1f12827a3516c9fda8fddbac7a0be131c9a6f5`. + - `showAllProviderModels=false` if you want users locked to each provider's default model. - `showAllDeepInfraModels=false` if you want a narrower default catalog. - `enableAudiobookExport=true`. - `enableWordHighlight=false` unless your timestamp stack is configured. diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index 43b1f36..8fb4db7 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -403,13 +403,7 @@ Sets the default TTS provider for new users. - Example values: `replicate`, `deepinfra`, `openai`, `custom-openai`, or an admin-defined shared provider slug (e.g. `kokoro-prod`) - Runtime key: `defaultTtsProvider` -### NEXT_PUBLIC_DEFAULT_TTS_MODEL - -Sets the default TTS model for new users. - -- Default: `kokoro` -- Example values: `hexgrad/Kokoro-82M`, `tts-1` -- Runtime key: `defaultTtsModel` +`showAllProviderModels` is a runtime-only admin setting (no env seed). Configure it in **Settings → Admin → Site features**. ### NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index 3a26ec4..7f75b8c 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -32,7 +32,8 @@ import { generateTTSBuffer } from '@/lib/server/tts/generate'; 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 { supportsNativeModelSpeed, supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog'; +import { defaultVoiceForProviderType, resolveTtsModelForProvider, resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy'; +import { isBuiltInTtsProviderId, isTtsProviderType } from '@/lib/shared/tts-provider-catalog'; import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config'; import type { AudiobookGenerationSettings } from '@/types/client'; import type { TTSAudiobookFormat } from '@/types/tts'; @@ -99,7 +100,11 @@ function s3NotConfiguredResponse(): NextResponse { } function normalizeNativeSpeedForSettings(settings: AudiobookGenerationSettings): AudiobookGenerationSettings { - return supportsNativeModelSpeed(settings.ttsProvider, settings.ttsModel) + return resolveTtsProviderModelPolicy({ + providerRef: settings.providerRef, + providerType: settings.providerType, + model: settings.ttsModel, + }).supportsNativeModelSpeed ? settings : { ...settings, nativeSpeed: 1 }; } @@ -118,7 +123,8 @@ function isAudiobookGenerationSettings(value: unknown): value is AudiobookGenera } const record = value as Record; - return typeof record.ttsProvider === 'string' + return typeof record.providerRef === 'string' + && isTtsProviderType(record.providerType) && typeof record.ttsModel === 'string' && typeof record.voice === 'string' && isFiniteNumber(record.nativeSpeed) @@ -364,7 +370,8 @@ export async function POST(request: NextRequest) { if (normalizedExistingSettings && hasChapters && incomingSettings) { const mismatch = - normalizedExistingSettings.ttsProvider !== incomingSettings.ttsProvider || + normalizedExistingSettings.providerRef !== incomingSettings.providerRef || + normalizedExistingSettings.providerType !== incomingSettings.providerType || normalizedExistingSettings.ttsModel !== incomingSettings.ttsModel || normalizedExistingSettings.voice !== incomingSettings.voice || normalizedExistingSettings.nativeSpeed !== incomingSettings.nativeSpeed || @@ -409,7 +416,7 @@ export async function POST(request: NextRequest) { } const requestedProvider = request.headers.get('x-tts-provider') - || mergedSettings?.ttsProvider + || mergedSettings?.providerRef || 'openai'; providerForError = requestedProvider; const runtimeConfig = await getResolvedRuntimeConfig(); @@ -433,15 +440,21 @@ export async function POST(request: NextRequest) { ); } const provider = credResolved.provider; + if (!isBuiltInTtsProviderId(provider)) { + return NextResponse.json({ error: `Unsupported TTS provider type: ${provider}` }, { status: 500 }); + } const openApiKey = credResolved.apiKey || 'none'; const openApiBaseUrl = credResolved.baseUrl; - const model = mergedSettings?.ttsModel; - const voice = mergedSettings?.voice - || (provider === 'openai' - ? 'alloy' - : provider === 'deepinfra' - ? 'af_bella' - : 'af_sarah'); + const effectiveProviderRef = credResolved.adminRecord?.slug || requestedProvider; + const model = resolveTtsModelForProvider({ + providerRef: effectiveProviderRef, + providerType: provider, + model: mergedSettings?.ttsModel, + sharedProviders: credResolved.adminRecord ? [credResolved.adminRecord] : [], + fallbackProviderRef: runtimeConfig.defaultTtsProvider, + showAllProviderModels: runtimeConfig.showAllProviderModels, + }); + const voice = mergedSettings?.voice || defaultVoiceForProviderType(provider); const rawNativeSpeed = mergedSettings?.nativeSpeed ?? 1; const nativeSpeed = Number.isFinite(Number(rawNativeSpeed)) ? Number(rawNativeSpeed) : 1; const instructions = resolveEffectiveTtsInstructions({ @@ -586,7 +599,14 @@ export async function POST(request: NextRequest) { if (!normalizedExistingSettings && incomingSettings) { const settingsToPersist: AudiobookGenerationSettings = { ...incomingSettings, - ...(supportsTtsInstructions(incomingSettings.ttsModel) + providerRef: effectiveProviderRef, + providerType: provider, + ttsModel: model, + ...(resolveTtsProviderModelPolicy({ + providerRef: effectiveProviderRef, + providerType: provider, + model, + }).supportsInstructions ? { ttsInstructions: instructions ?? '' } : { ttsInstructions: '' }), }; diff --git a/src/app/api/tts/segments/ensure/route.ts b/src/app/api/tts/segments/ensure/route.ts index 437de78..cc23d9a 100644 --- a/src/app/api/tts/segments/ensure/route.ts +++ b/src/app/api/tts/segments/ensure/route.ts @@ -21,6 +21,7 @@ import { projectSegmentLocator, probeAudioDurationMsFromBuffer, } from '@/lib/server/tts/segments'; +import { isBuiltInTtsProviderId, isTtsProviderType } from '@/lib/shared/tts-provider-catalog'; import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth'; import { rateLimiter, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter'; import { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials'; @@ -31,6 +32,7 @@ import { buildDailyQuotaExceededResponse } from '@/lib/server/rate-limit/problem import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response'; import { alignAudioWithText } from '@/lib/server/whisper/alignment'; import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config'; +import { resolveTtsModelForProvider } from '@/lib/shared/tts-provider-policy'; import type { TTSSegmentInput, TTSSegmentManifestItem, @@ -50,14 +52,16 @@ function attachDeviceIdCookie(response: NextResponse, deviceId: string | null, d function parseSettings(value: unknown): TTSSegmentSettings | null { if (!value || typeof value !== 'object') return null; const rec = value as Record; - if (typeof rec.ttsProvider !== 'string') return null; + if (typeof rec.providerRef !== 'string') return null; + if (!isTtsProviderType(rec.providerType)) return null; if (typeof rec.ttsModel !== 'string') return null; if (typeof rec.voice !== 'string') return null; if (!Number.isFinite(Number(rec.nativeSpeed))) return null; if (rec.ttsInstructions !== undefined && typeof rec.ttsInstructions !== 'string') return null; return { - ttsProvider: rec.ttsProvider, + providerRef: rec.providerRef, + providerType: rec.providerType, ttsModel: rec.ttsModel, voice: rec.voice, nativeSpeed: Number(rec.nativeSpeed), @@ -169,7 +173,7 @@ export async function POST(request: NextRequest) { if (scope instanceof Response) return scope; const runtimeConfig = await getResolvedRuntimeConfig(); const requestCreds = await resolveTtsCredentials({ - providerHeader: parsed.settings.ttsProvider, + providerHeader: parsed.settings.providerRef, apiKeyHeader: request.headers.get('x-openai-key'), baseUrlHeader: request.headers.get('x-openai-base-url'), fallbackProvider: runtimeConfig.defaultTtsProvider, @@ -189,13 +193,28 @@ export async function POST(request: NextRequest) { // Normalize request settings to the effective generation settings so cache // keys and persisted metadata match what we actually synthesize. - const effectiveInstructions = resolveEffectiveTtsInstructions({ + const effectiveProviderRef = requestCreds.adminRecord?.slug || parsed.settings.providerRef; + const effectiveModel = resolveTtsModelForProvider({ + providerRef: effectiveProviderRef, + providerType: isBuiltInTtsProviderId(requestCreds.provider) ? requestCreds.provider : 'unknown', model: parsed.settings.ttsModel, + sharedProviders: requestCreds.adminRecord ? [requestCreds.adminRecord] : [], + fallbackProviderRef: runtimeConfig.defaultTtsProvider, + showAllProviderModels: runtimeConfig.showAllProviderModels, + }); + const effectiveInstructions = resolveEffectiveTtsInstructions({ + model: effectiveModel, requestInstructions: parsed.settings.ttsInstructions, sharedDefaultInstructions: requestCreds.adminRecord?.defaultInstructions, }) ?? ''; + const resolvedProviderType = isBuiltInTtsProviderId(requestCreds.provider) + ? requestCreds.provider + : 'unknown'; const effectiveSettings: TTSSegmentSettings = { ...parsed.settings, + providerRef: effectiveProviderRef, + providerType: resolvedProviderType, + ttsModel: effectiveModel, ttsInstructions: effectiveInstructions, }; diff --git a/src/app/api/tts/segments/manifest/route.ts b/src/app/api/tts/segments/manifest/route.ts index 78e4a3e..6ff9e3d 100644 --- a/src/app/api/tts/segments/manifest/route.ts +++ b/src/app/api/tts/segments/manifest/route.ts @@ -17,6 +17,8 @@ import type { TTSSegmentVariant, TTSSegmentsManifestResponse, } from '@/types/client'; +import { isTtsProviderType } from '@/lib/shared/tts-provider-catalog'; +import { resolveEffectiveProviderType } from '@/lib/shared/tts-provider-policy'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -44,20 +46,31 @@ function parseSettingsValue(value: unknown): TTSSegmentSettings | null { if (!raw || typeof raw !== 'object') return null; const rec = raw as Record; - const ttsProvider = typeof rec.ttsProvider === 'string' - ? rec.ttsProvider - : typeof rec.provider === 'string' ? rec.provider : null; + // Accept both current settings shape and canonicalized legacy keys from + // persisted rows (e.g., SQLite stringified settings with `model/speed`). + const providerRef = typeof rec.providerRef === 'string' + ? rec.providerRef + : typeof rec.ttsProvider === 'string' + ? rec.ttsProvider + : typeof rec.provider === 'string' + ? rec.provider + : null; + const providerType = isTtsProviderType(rec.providerType) + ? rec.providerType + : resolveEffectiveProviderType({ providerRef }); const ttsModel = typeof rec.ttsModel === 'string' ? rec.ttsModel - : typeof rec.model === 'string' ? rec.model : null; + : typeof rec.model === 'string' + ? rec.model + : null; const voice = typeof rec.voice === 'string' ? rec.voice : null; const speedSource = rec.nativeSpeed ?? rec.speed; const nativeSpeed = Number.isFinite(Number(speedSource)) ? Number(speedSource) : 1; const instructionsSource = rec.ttsInstructions ?? rec.instructions; const ttsInstructions = typeof instructionsSource === 'string' ? instructionsSource : ''; - if (!ttsProvider || !ttsModel || !voice) return null; - return { ttsProvider, ttsModel, voice, nativeSpeed, ttsInstructions }; + if (!providerRef || !providerType || !ttsModel || !voice) return null; + return { providerRef, providerType, ttsModel, voice, nativeSpeed, ttsInstructions }; } function locatorFromProjection(row: ManifestGroupRow): TTSSegmentLocator | null { diff --git a/src/app/api/tts/voices/route.ts b/src/app/api/tts/voices/route.ts index 00aa315..e0cee34 100644 --- a/src/app/api/tts/voices/route.ts +++ b/src/app/api/tts/voices/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { auth } from '@/lib/server/auth/auth'; -import { getDefaultVoices } from '@/lib/shared/tts-provider-catalog'; +import { isBuiltInTtsProviderId } from '@/lib/shared/tts-provider-catalog'; +import { defaultModelForProviderType, resolveTtsModelForProvider, resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy'; import { resolveVoices } from '@/lib/server/tts/voice-resolution'; import { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials'; import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config'; @@ -38,9 +39,20 @@ export async function GET(req: NextRequest) { ); } - const requestedModel = req.headers.get('x-tts-model') - || resolved.adminRecord?.defaultModel - || 'tts-1'; + if (!isBuiltInTtsProviderId(resolved.provider)) { + return NextResponse.json({ error: `Unsupported provider type: ${resolved.provider}` }, { status: 500 }); + } + const effectiveProviderRef = resolved.adminRecord?.slug + ?? req.headers.get('x-tts-provider') + ?? runtimeConfig.defaultTtsProvider; + const requestedModel = resolveTtsModelForProvider({ + providerRef: effectiveProviderRef, + providerType: resolved.provider, + model: req.headers.get('x-tts-model'), + sharedProviders: resolved.adminRecord ? [resolved.adminRecord] : [], + fallbackProviderRef: runtimeConfig.defaultTtsProvider, + showAllProviderModels: runtimeConfig.showAllProviderModels, + }) || defaultModelForProviderType(resolved.provider); const voices = await resolveVoices({ provider: resolved.provider, model: requestedModel, @@ -50,10 +62,18 @@ export async function GET(req: NextRequest) { return NextResponse.json({ voices }); } catch (error) { console.error('Error in voices endpoint:', error); - const provider = req.headers.get('x-tts-provider') || 'openai'; + const providerRef = req.headers.get('x-tts-provider') || 'openai'; const model = req.headers.get('x-tts-model') || 'tts-1'; + const provider = isBuiltInTtsProviderId(providerRef) ? providerRef : 'openai'; return NextResponse.json( - { error: 'Failed to resolve voices', fallbackVoices: getDefaultVoices(provider, model) }, + { + error: 'Failed to resolve voices', + fallbackVoices: resolveTtsProviderModelPolicy({ + providerRef, + providerType: provider, + model, + }).defaultVoices, + }, { status: 500 }, ); } diff --git a/src/app/api/user/state/preferences/route.ts b/src/app/api/user/state/preferences/route.ts index 5e23286..e60b5fb 100644 --- a/src/app/api/user/state/preferences/route.ts +++ b/src/app/api/user/state/preferences/route.ts @@ -5,6 +5,10 @@ import { userPreferences } from '@/db/schema'; import { SYNCED_PREFERENCE_KEYS, type SyncedPreferencesPatch } from '@/types/user-state'; import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope'; import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps'; +import { isTtsProviderType, type TtsProviderId } from '@/lib/shared/tts-provider-catalog'; +import { listAdminProviders } from '@/lib/server/admin/providers'; +import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config'; +import { normalizeLegacyProviderRef, resolveProviderDefaults } from '@/lib/shared/tts-provider-policy'; export const dynamic = 'force-dynamic'; @@ -17,17 +21,54 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } -function parseStoredPreferences(value: unknown): SyncedPreferencesPatch { - if (!value) return {}; +interface PreferenceNormalizationContext { + defaultProviderRef: string; + showAllProviderModels: boolean; + sharedProviders: Array<{ + slug: string; + providerType: TtsProviderId; + defaultModel: string | null; + defaultInstructions: string | null; + }>; +} + +async function loadPreferenceNormalizationContext(): Promise { + const [runtimeConfig, providers] = await Promise.all([ + getResolvedRuntimeConfig(), + listAdminProviders(), + ]); + return { + defaultProviderRef: runtimeConfig.defaultTtsProvider, + showAllProviderModels: runtimeConfig.showAllProviderModels, + sharedProviders: providers + .filter((entry) => entry.enabled) + .map((entry) => ({ + slug: entry.slug, + providerType: entry.providerType, + defaultModel: entry.defaultModel, + defaultInstructions: entry.defaultInstructions, + })), + }; +} + +function parseStoredPreferences( + value: unknown, + context: PreferenceNormalizationContext, +): { patch: SyncedPreferencesPatch; migrated: boolean } { + if (!value) return { patch: {}, migrated: false }; if (typeof value === 'string') { try { const parsed = JSON.parse(value); - return isRecord(parsed) ? sanitizePreferencesPatch(parsed) : {}; + return isRecord(parsed) + ? sanitizePreferencesPatch(parsed, context, { fillMissingProvider: true }) + : { patch: {}, migrated: false }; } catch { - return {}; + return { patch: {}, migrated: false }; } } - return isRecord(value) ? sanitizePreferencesPatch(value) : {}; + return isRecord(value) + ? sanitizePreferencesPatch(value, context, { fillMissingProvider: true }) + : { patch: {}, migrated: false }; } function sanitizeSavedVoices(value: unknown): Record { @@ -41,25 +82,52 @@ function sanitizeSavedVoices(value: unknown): Record { return out; } -function sanitizePreferencesPatch(input: unknown): SyncedPreferencesPatch { - if (!isRecord(input)) return {}; +function sanitizePreferencesPatch( + input: unknown, + context: PreferenceNormalizationContext, + options: { fillMissingProvider: boolean }, +): { patch: SyncedPreferencesPatch; migrated: boolean } { + if (!isRecord(input)) return { patch: {}, migrated: false }; + const rec = input as Record; const out: SyncedPreferencesPatch = {}; + let migrated = false; + + const legacyProviderRef = typeof rec.ttsProvider === 'string' + ? rec.ttsProvider + : typeof rec.provider === 'string' + ? rec.provider + : ''; + const rawProviderRef = typeof rec.providerRef === 'string' ? rec.providerRef : legacyProviderRef; + const normalizedProviderRef = normalizeLegacyProviderRef(rawProviderRef, context.defaultProviderRef); + const providerDefaults = resolveProviderDefaults({ + providerRef: normalizedProviderRef || context.defaultProviderRef, + providerType: isTtsProviderType(rec.providerType) ? rec.providerType : 'unknown', + sharedProviders: context.sharedProviders, + fallbackProviderRef: context.defaultProviderRef, + }); + const hasLegacyProviderKey = typeof rec.ttsProvider === 'string' || typeof rec.provider === 'string'; + if (hasLegacyProviderKey || rawProviderRef !== providerDefaults.providerRef) migrated = true; for (const key of SYNCED_PREFERENCE_KEYS) { - if (!(key in input)) continue; - const value = input[key]; + if (!(key in rec)) continue; + const value = rec[key]; switch (key) { case 'viewType': if (value === 'single' || value === 'dual' || value === 'scroll') out[key] = value; break; case 'voice': - case 'ttsProvider': case 'ttsModel': case 'ttsInstructions': if (typeof value === 'string') out[key] = value; break; + case 'providerRef': + out[key] = providerDefaults.providerRef; + break; + case 'providerType': + out[key] = providerDefaults.providerType; + break; case 'voiceSpeed': case 'audioPlayerSpeed': case 'segmentPreloadDepthPages': @@ -88,7 +156,40 @@ function sanitizePreferencesPatch(input: unknown): SyncedPreferencesPatch { } } - return out; + if ('providerRef' in out && !('providerType' in out)) { + out.providerType = providerDefaults.providerType; + migrated = true; + } + + if (options.fillMissingProvider && !('providerRef' in out)) { + out.providerRef = providerDefaults.providerRef; + migrated = true; + } + if (options.fillMissingProvider && !('providerType' in out)) { + out.providerType = providerDefaults.providerType; + migrated = true; + } + + const rawModel = typeof rec.ttsModel === 'string' ? rec.ttsModel.trim() : ''; + const shouldNormalizeSharedDefaultModel = + !!providerDefaults.defaultModel + && context.sharedProviders.some((entry) => entry.slug === providerDefaults.providerRef) + && (rawModel.length === 0 || rawModel === 'kokoro') + && (hasLegacyProviderKey || rawProviderRef === 'default-openai'); + + if (options.fillMissingProvider && !('ttsModel' in out) && providerDefaults.defaultModel) { + out.ttsModel = providerDefaults.defaultModel; + migrated = true; + } else if (shouldNormalizeSharedDefaultModel && out.ttsModel !== providerDefaults.defaultModel) { + out.ttsModel = providerDefaults.defaultModel; + migrated = true; + } + if (!context.showAllProviderModels && providerDefaults.defaultModel && out.ttsModel !== providerDefaults.defaultModel) { + out.ttsModel = providerDefaults.defaultModel; + migrated = true; + } + + return { patch: out, migrated }; } function normalizeClientUpdatedAtMs(value: unknown): number { @@ -99,6 +200,7 @@ function normalizeClientUpdatedAtMs(value: unknown): number { export async function GET(req: NextRequest) { try { + const normalizationContext = await loadPreferenceNormalizationContext(); const scope = await resolveUserStateScope(req); if (scope instanceof Response) return scope; @@ -112,9 +214,29 @@ export async function GET(req: NextRequest) { .limit(1); const row = rows[0]; - const storedPatch = parseStoredPreferences(row?.dataJson); + const stored = parseStoredPreferences(row?.dataJson, normalizationContext); + const storedPatch = stored.patch; const clientUpdatedAtMs = Number(row?.clientUpdatedAtMs ?? 0); + if (row && stored.migrated) { + const updatedAt = nowTimestampMs(); + await db + .insert(userPreferences) + .values({ + userId: scope.ownerUserId, + dataJson: serializePreferencesForDb(storedPatch), + clientUpdatedAtMs: clientUpdatedAtMs > 0 ? clientUpdatedAtMs : updatedAt, + updatedAt, + }) + .onConflictDoUpdate({ + target: [userPreferences.userId], + set: { + dataJson: serializePreferencesForDb(storedPatch), + updatedAt, + }, + }); + } + return NextResponse.json({ preferences: storedPatch, clientUpdatedAtMs, @@ -128,13 +250,18 @@ export async function GET(req: NextRequest) { export async function PUT(req: NextRequest) { try { + const normalizationContext = await loadPreferenceNormalizationContext(); const scope = await resolveUserStateScope(req); if (scope instanceof Response) return scope; const body = (await req.json().catch(() => null)) as | { patch?: unknown; clientUpdatedAtMs?: unknown } | null; - const patch = sanitizePreferencesPatch(body?.patch); + const patch = sanitizePreferencesPatch( + body?.patch, + normalizationContext, + { fillMissingProvider: false }, + ).patch; const clientUpdatedAtMs = normalizeClientUpdatedAtMs(body?.clientUpdatedAtMs); if (Object.keys(patch).length === 0) { @@ -151,7 +278,7 @@ export async function PUT(req: NextRequest) { .limit(1); const existing = existingRows[0]; const existingUpdated = Number(existing?.clientUpdatedAtMs ?? 0); - const existingPatch = parseStoredPreferences(existing?.dataJson); + const existingPatch = parseStoredPreferences(existing?.dataJson, normalizationContext).patch; if (existing && clientUpdatedAtMs < existingUpdated) { return NextResponse.json({ diff --git a/src/components/AudiobookExportModal.tsx b/src/components/AudiobookExportModal.tsx index ab874fe..70095db 100644 --- a/src/components/AudiobookExportModal.tsx +++ b/src/components/AudiobookExportModal.tsx @@ -12,7 +12,7 @@ import { useConfig } from '@/contexts/ConfigContext'; import { useTTS } from '@/contexts/TTSContext'; import { VoicesControlBase } from '@/components/player/VoicesControlBase'; import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell'; -import { supportsNativeModelSpeed, supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog'; +import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy'; import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts'; import { getAudiobookStatus, @@ -49,7 +49,7 @@ export function AudiobookExportModal({ onGenerateAudiobook, onRegenerateChapter }: AudiobookExportModalProps) { - const { isLoading, isDBReady, ttsProvider, ttsModel, ttsInstructions, voice: configVoice, voiceSpeed, audioPlayerSpeed } = useConfig(); + const { isLoading, isDBReady, providerRef, providerType, ttsModel, ttsInstructions, voice: configVoice, voiceSpeed, audioPlayerSpeed } = useConfig(); const { availableVoices } = useTTS(); const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation(); const [isGenerating, setIsGenerating] = useState(false); @@ -74,7 +74,11 @@ export function AudiobookExportModal({ const formatSpeed = useCallback((speed: number) => { return Number.isInteger(speed) ? speed.toString() : speed.toFixed(1); }, []); - const nativeSpeedSupported = useMemo(() => supportsNativeModelSpeed(ttsProvider, ttsModel), [ttsProvider, ttsModel]); + const providerModelPolicy = useMemo( + () => resolveTtsProviderModelPolicy({ providerRef, providerType, model: ttsModel }), + [providerRef, providerType, ttsModel], + ); + const nativeSpeedSupported = providerModelPolicy.supportsNativeModelSpeed; const effectiveNativeSpeed = nativeSpeedSupported ? nativeSpeed : 1; const hasExistingAudiobook = Boolean(bookId) || chapters.length > 0; @@ -113,15 +117,16 @@ export function AudiobookExportModal({ const nextVoice = audiobookVoice || configVoice || availableVoices[0] || ''; if (!nextVoice) return null; return { - ttsProvider, + providerRef, + providerType, ttsModel, voice: nextVoice, nativeSpeed: effectiveNativeSpeed, postSpeed, format, - ttsInstructions: supportsTtsInstructions(ttsModel) ? ttsInstructions : undefined, + ttsInstructions: providerModelPolicy.supportsInstructions ? ttsInstructions : undefined, }; - }, [savedSettings, audiobookVoice, configVoice, availableVoices, ttsProvider, ttsModel, ttsInstructions, effectiveNativeSpeed, postSpeed, format]); + }, [savedSettings, audiobookVoice, configVoice, availableVoices, providerRef, providerType, ttsModel, ttsInstructions, effectiveNativeSpeed, postSpeed, format, providerModelPolicy.supportsInstructions]); const fetchExistingChapters = useCallback(async (soft: boolean = false) => { if (soft) { @@ -514,7 +519,11 @@ export function AudiobookExportModal({
Native speed
- {supportsNativeModelSpeed(savedSettings.ttsProvider, savedSettings.ttsModel) + {resolveTtsProviderModelPolicy({ + providerRef: savedSettings.providerRef, + providerType: savedSettings.providerType, + model: savedSettings.ttsModel, + }).supportsNativeModelSpeed ? `${formatSpeed(savedSettings.nativeSpeed)}x` : 'Not supported'}
@@ -538,7 +547,7 @@ export function AudiobookExportModal({ availableVoices={availableVoices} voice={audiobookVoice} onChangeVoice={setAudiobookVoice} - ttsProvider={ttsProvider} + providerType={providerType} ttsModel={ttsModel} dropdownDirection="down" variant="field" diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 5465941..7ea3e59 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -36,7 +36,17 @@ import { deleteDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/a import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/cache/documents'; import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews'; import { resolveTtsSettingsViewModel } from '@/lib/client/settings/tts-settings'; -import { REPLICATE_KOKORO_82M_VERSIONED_MODEL, supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog'; +import { + isBuiltInTtsProviderId, + type TtsProviderType, +} from '@/lib/shared/tts-provider-catalog'; +import { + defaultBaseUrlForProviderType, + defaultModelForProviderType, + resolveProviderDefaults, + resolveEffectiveProviderType, + resolveTtsProviderModelPolicy, +} from '@/lib/shared/tts-provider-policy'; import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext'; import { AdminProvidersPanel } from '@/components/admin/AdminProvidersPanel'; import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel'; @@ -105,6 +115,7 @@ export function SettingsModal({ className = '' }: { className?: string }) { const runtimeConfig = useRuntimeConfig(); const enableDestructiveDelete = runtimeConfig.enableDestructiveDeleteActions; const showAllDeepInfra = runtimeConfig.showAllDeepInfraModels; + const showAllProviderModels = runtimeConfig.showAllProviderModels; const enableTTSProvidersTab = runtimeConfig.enableTtsProvidersTab; const restrictUserApiKeys = runtimeConfig.restrictUserApiKeys; const [isOpen, setIsOpen] = useState(false); @@ -113,11 +124,12 @@ export function SettingsModal({ className = '' }: { className?: string }) { const { theme, setTheme, applyCustomColors } = useTheme(); const [customColors, setCustomColors] = useState(getCustomThemeColors); const [isCustomExpanded, setIsCustomExpanded] = useState(false); - const { apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, updateConfig, updateConfigKey } = useConfig(); + const { apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions, updateConfig, updateConfigKey } = useConfig(); const { refreshDocuments } = useDocuments(); const [localApiKey, setLocalApiKey] = useState(apiKey); const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl); - const [localTTSProvider, setLocalTTSProvider] = useState(ttsProvider); + const [localProviderRef, setLocalProviderRef] = useState(providerRef); + const [localProviderType, setLocalProviderType] = useState(providerType); const [modelValue, setModelValue] = useState(ttsModel); const [customModelInput, setCustomModelInput] = useState(''); const [localTTSInstructions, setLocalTTSInstructions] = useState(ttsInstructions); @@ -154,17 +166,21 @@ export function SettingsModal({ className = '' }: { className?: string }) { selectedModelId, canSubmit, selectedSharedProvider, + selectedProviderRef, + selectedProviderType, } = useMemo(() => resolveTtsSettingsViewModel({ - provider: localTTSProvider, + providerRef: localProviderRef, + providerType: localProviderType, apiKey: localApiKey, modelValue, customModelInput, showAllDeepInfra, + showAllProviderModels, sharedProviders, allowBuiltInProviders: !restrictUserApiKeys, - }), [localTTSProvider, localApiKey, modelValue, customModelInput, showAllDeepInfra, sharedProviders, restrictUserApiKeys]); + }), [localProviderRef, localProviderType, localApiKey, modelValue, customModelInput, showAllDeepInfra, showAllProviderModels, sharedProviders, restrictUserApiKeys]); const isSharedSelected = Boolean(selectedSharedProvider); - const selectedProviderOption = ttsProviders.find((p) => p.id === localTTSProvider) ?? ttsProviders[0]; + const selectedProviderOption = ttsProviders.find((p) => p.id === localProviderRef) ?? ttsProviders[0]; const checkFirstVist = useCallback(async () => { const appConfig = await getAppConfig(); @@ -187,10 +203,11 @@ export function SettingsModal({ className = '' }: { className?: string }) { useEffect(() => { setLocalApiKey(apiKey); setLocalBaseUrl(baseUrl); - setLocalTTSProvider(ttsProvider); + setLocalProviderRef(providerRef); + setLocalProviderType(providerType); setModelValue(ttsModel); setLocalTTSInstructions(ttsInstructions); - }, [apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions]); + }, [apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions]); useEffect(() => { if (!authEnabled) { @@ -216,12 +233,31 @@ export function SettingsModal({ className = '' }: { className?: string }) { }, [modelValue, ttsModels]); useEffect(() => { - if (!restrictUserApiKeys) return; if (selectedProviderOption) return; - if (ttsProviders.length > 0) { - setLocalTTSProvider(ttsProviders[0].id); + if (ttsProviders.length === 0) return; + + const fallback = ttsProviders[0]; + setLocalProviderRef(fallback.id); + setLocalProviderType(fallback.providerType); + + if (fallback.shared) { + const shared = sharedProviders.find((p) => p.slug === fallback.id); + if (shared?.defaultModel) { + setModelValue(shared.defaultModel); + } + setLocalTTSInstructions(shared?.defaultInstructions ?? ''); + setLocalApiKey(''); + setLocalBaseUrl(''); + setCustomModelInput(''); + return; } - }, [restrictUserApiKeys, selectedProviderOption, ttsProviders]); + + if (isBuiltInTtsProviderId(fallback.providerType)) { + setModelValue(defaultModelForProviderType(fallback.providerType)); + setLocalBaseUrl(defaultBaseUrlForProviderType(fallback.providerType)); + setCustomModelInput(''); + } + }, [selectedProviderOption, ttsProviders, sharedProviders]); const handleRefresh = async () => { try { @@ -364,7 +400,8 @@ export function SettingsModal({ className = '' }: { className?: string }) { setIsOpen(false); setLocalApiKey(apiKey); setLocalBaseUrl(baseUrl); - setLocalTTSProvider(ttsProvider); + setLocalProviderRef(providerRef); + setLocalProviderType(providerType); setModelValue(ttsModel); setLocalTTSInstructions(ttsInstructions); if (!ttsModels.some(m => m.id === ttsModel) && ttsModel !== '') { @@ -372,7 +409,7 @@ export function SettingsModal({ className = '' }: { className?: string }) { } else { setCustomModelInput(''); } - }, [apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, ttsModels]); + }, [apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions, ttsModels]); const [systemIsDark, setSystemIsDark] = useState( typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches @@ -428,11 +465,22 @@ export function SettingsModal({ className = '' }: { className?: string }) { const btnPrimary = `${btnBase} bg-accent text-background hover:bg-secondary-accent hover:scale-[1.04]`; const btnSecondary = `${btnBase} bg-background text-foreground hover:bg-offbase hover:text-accent hover:scale-[1.04]`; const btnOutline = `${btnBase} bg-background border border-offbase text-foreground hover:bg-offbase hover:text-accent hover:scale-[1.02]`; - const effectiveProviderType = selectedSharedProvider?.providerType ?? localTTSProvider; + const effectiveProviderType = resolveEffectiveProviderType({ + providerRef: selectedProviderRef, + providerType: localProviderType, + sharedProviders, + }); + const providerModelPolicy = resolveTtsProviderModelPolicy({ + providerRef: selectedProviderRef, + providerType: effectiveProviderType, + model: modelValue, + sharedProviders, + }); const shouldShowBaseUrl = !restrictUserApiKeys && !isSharedSelected - && effectiveProviderType !== 'replicate' - && (effectiveProviderType === 'custom-openai' || !localBaseUrl || localBaseUrl === ''); + && providerModelPolicy.isResolvedProviderType + && providerModelPolicy.providerType !== 'replicate' + && (providerModelPolicy.providerType === 'custom-openai' || !localBaseUrl || localBaseUrl === ''); const shouldShowApiKey = !restrictUserApiKeys && !isSharedSelected; const selectedModel = ttsModels.find(m => m.id === selectedModelId) || ttsModels[0]; const selectedModelVersion = selectedModel?.id?.includes(':') @@ -551,26 +599,21 @@ export function SettingsModal({ className = '' }: { className?: string }) { { - setLocalTTSProvider(provider.id); + const defaults = resolveProviderDefaults({ + providerRef: provider.id, + providerType: provider.providerType, + sharedProviders, + }); + setLocalProviderRef(provider.id); + setLocalProviderType(defaults.providerType); + setModelValue(defaults.defaultModel); + setLocalTTSInstructions(defaults.defaultInstructions); if (provider.shared) { // Shared admin provider — credentials live on the server. setLocalApiKey(''); setLocalBaseUrl(''); - const shared = sharedProviders.find((p) => p.slug === provider.id); - if (shared?.defaultModel) setModelValue(shared.defaultModel); - setLocalTTSInstructions(shared?.defaultInstructions ?? ''); - } else if (provider.id === 'openai') { - setModelValue('tts-1'); - setLocalBaseUrl('https://api.openai.com/v1'); - } else if (provider.id === 'custom-openai') { - setModelValue('kokoro'); - setLocalBaseUrl(''); - } else if (provider.id === 'replicate') { - setModelValue(REPLICATE_KOKORO_82M_VERSIONED_MODEL); - setLocalBaseUrl(''); - } else if (provider.id === 'deepinfra') { - setModelValue('hexgrad/Kokoro-82M'); - setLocalBaseUrl('https://api.deepinfra.com/v1/openai'); + } else if (isBuiltInTtsProviderId(provider.providerType)) { + setLocalBaseUrl(defaultBaseUrlForProviderType(provider.providerType)); } setCustomModelInput(''); }} @@ -652,7 +695,7 @@ export function SettingsModal({ className = '' }: { className?: string }) { type="password" value={localApiKey} onChange={(e) => handleInputChange('apiKey', e.target.value)} - placeholder={!showAllDeepInfra && localTTSProvider === 'deepinfra' ? "Deepinfra free or use your API key" : "Using environment variable"} + placeholder={!showAllDeepInfra && providerModelPolicy.providerType === 'deepinfra' ? "Deepinfra free or use your API key" : "Using environment variable"} className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" />
@@ -665,6 +708,11 @@ export function SettingsModal({ className = '' }: { className?: string }) {
+ {!showAllProviderModels && ( +

+ This instance restricts model selection to each provider's default model. +

+ )}
m.id === selectedModelId) || ttsModels[0]} @@ -752,7 +800,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
- {supportsTtsInstructions(modelValue) && ( + {providerModelPolicy.supportsInstructions && (