feat(audiobook): integrate settings coercion utility and migrate metadata handling
Refactor chapter API to use new coerceAudiobookGenerationSettings utility for validating and migrating audiobook metadata. Add src/lib/server/audiobooks/settings.ts with shared logic and introduce corresponding unit tests to ensure correct settings migration and validation.
This commit is contained in:
parent
0dee3c7b60
commit
1f548f71ea
3 changed files with 354 additions and 42 deletions
|
|
@ -33,10 +33,16 @@ import { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials';
|
||||||
import { resolveEffectiveTtsInstructions } from '@/lib/server/admin/tts-instructions';
|
import { resolveEffectiveTtsInstructions } from '@/lib/server/admin/tts-instructions';
|
||||||
import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response';
|
import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response';
|
||||||
import { defaultVoiceForProviderType, resolveTtsModelForProvider, resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
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 { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
|
||||||
|
import { listAdminProviders } from '@/lib/server/admin/providers';
|
||||||
import type { AudiobookGenerationSettings } from '@/types/client';
|
import type { AudiobookGenerationSettings } from '@/types/client';
|
||||||
import type { TTSAudiobookFormat } from '@/types/tts';
|
import type { TTSAudiobookFormat } from '@/types/tts';
|
||||||
|
import {
|
||||||
|
canonicalizeAudiobookSettingsForRuntime,
|
||||||
|
coerceAudiobookGenerationSettings,
|
||||||
|
type SharedProviderPolicyEntry,
|
||||||
|
} from '@/lib/server/audiobooks/settings';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
|
@ -109,30 +115,6 @@ function normalizeNativeSpeedForSettings(settings: AudiobookGenerationSettings):
|
||||||
: { ...settings, nativeSpeed: 1 };
|
: { ...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.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 {
|
function chapterFileMimeType(format: TTSAudiobookFormat): string {
|
||||||
return format === 'mp3' ? 'audio/mpeg' : 'audio/mp4';
|
return format === 'mp3' ? 'audio/mpeg' : 'audio/mp4';
|
||||||
}
|
}
|
||||||
|
|
@ -298,6 +280,7 @@ export async function POST(request: NextRequest) {
|
||||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||||
|
|
||||||
const { userId, authEnabled, user } = ctxOrRes;
|
const { userId, authEnabled, user } = ctxOrRes;
|
||||||
|
const runtimeConfig = await getResolvedRuntimeConfig();
|
||||||
const testNamespace = getOpenReaderTestNamespace(request.headers);
|
const testNamespace = getOpenReaderTestNamespace(request.headers);
|
||||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
||||||
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId);
|
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId);
|
||||||
|
|
@ -333,15 +316,20 @@ export async function POST(request: NextRequest) {
|
||||||
const hasChapters = existingChapters.length > 0;
|
const hasChapters = existingChapters.length > 0;
|
||||||
|
|
||||||
let normalizedExistingSettings: AudiobookGenerationSettings | undefined;
|
let normalizedExistingSettings: AudiobookGenerationSettings | undefined;
|
||||||
|
let existingSettingsNeedsMigration = false;
|
||||||
try {
|
try {
|
||||||
const parsedSettings = JSON.parse(
|
const parsedSettings = JSON.parse(
|
||||||
(await getAudiobookObjectBuffer(bookId, storageUserId, 'audiobook.meta.json', testNamespace)).toString('utf8'),
|
(await getAudiobookObjectBuffer(bookId, storageUserId, 'audiobook.meta.json', testNamespace)).toString('utf8'),
|
||||||
) as unknown;
|
) 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 });
|
console.error('Invalid audiobook.meta.json settings payload', { bookId, storageUserId });
|
||||||
return NextResponse.json({ error: 'Invalid audiobook metadata settings' }, { status: 500 });
|
return NextResponse.json({ error: 'Invalid audiobook metadata settings' }, { status: 500 });
|
||||||
}
|
}
|
||||||
normalizedExistingSettings = normalizeNativeSpeedForSettings(parsedSettings);
|
normalizedExistingSettings = normalizeNativeSpeedForSettings(existingResult.settings);
|
||||||
|
existingSettingsNeedsMigration = existingResult.migrated;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isMissingBlobError(error)) throw error;
|
if (!isMissingBlobError(error)) throw error;
|
||||||
normalizedExistingSettings = undefined;
|
normalizedExistingSettings = undefined;
|
||||||
|
|
@ -351,33 +339,91 @@ export async function POST(request: NextRequest) {
|
||||||
if (data.settings === undefined) {
|
if (data.settings === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (!isAudiobookGenerationSettings(data.settings)) {
|
const incomingResult = coerceAudiobookGenerationSettings(data.settings, {
|
||||||
|
fallbackProviderRef: runtimeConfig.defaultTtsProvider,
|
||||||
|
});
|
||||||
|
if (!incomingResult.settings) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return normalizeNativeSpeedForSettings(data.settings);
|
return normalizeNativeSpeedForSettings(incomingResult.settings);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
if (incomingSettings === null) {
|
if (incomingSettings === null) {
|
||||||
return NextResponse.json({ error: 'Invalid audiobook settings payload' }, { status: 400 });
|
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({
|
? normalizeNativeSpeedForSettings({
|
||||||
...normalizedExistingSettings,
|
...normalizedExistingSettings,
|
||||||
...incomingSettings,
|
...normalizedIncomingSettings,
|
||||||
})
|
})
|
||||||
: normalizedExistingSettings ?? incomingSettings;
|
: normalizedExistingSettings ?? normalizedIncomingSettings;
|
||||||
|
|
||||||
if (normalizedExistingSettings && hasChapters && incomingSettings) {
|
if (normalizedExistingSettings && hasChapters && normalizedIncomingSettings) {
|
||||||
const mismatch =
|
const mismatch =
|
||||||
normalizedExistingSettings.providerRef !== incomingSettings.providerRef ||
|
normalizedExistingSettings.providerRef !== normalizedIncomingSettings.providerRef ||
|
||||||
normalizedExistingSettings.providerType !== incomingSettings.providerType ||
|
normalizedExistingSettings.providerType !== normalizedIncomingSettings.providerType ||
|
||||||
normalizedExistingSettings.ttsModel !== incomingSettings.ttsModel ||
|
normalizedExistingSettings.ttsModel !== normalizedIncomingSettings.ttsModel ||
|
||||||
normalizedExistingSettings.voice !== incomingSettings.voice ||
|
normalizedExistingSettings.voice !== normalizedIncomingSettings.voice ||
|
||||||
normalizedExistingSettings.nativeSpeed !== incomingSettings.nativeSpeed ||
|
normalizedExistingSettings.nativeSpeed !== normalizedIncomingSettings.nativeSpeed ||
|
||||||
normalizedExistingSettings.postSpeed !== incomingSettings.postSpeed ||
|
normalizedExistingSettings.postSpeed !== normalizedIncomingSettings.postSpeed ||
|
||||||
normalizedExistingSettings.format !== incomingSettings.format ||
|
normalizedExistingSettings.format !== normalizedIncomingSettings.format ||
|
||||||
(normalizedExistingSettings.ttsInstructions || '') !== (incomingSettings.ttsInstructions || '');
|
(normalizedExistingSettings.ttsInstructions || '') !== (normalizedIncomingSettings.ttsInstructions || '');
|
||||||
if (mismatch) {
|
if (mismatch) {
|
||||||
return NextResponse.json({ error: 'Audiobook settings mismatch', settings: normalizedExistingSettings }, { status: 409 });
|
return NextResponse.json({ error: 'Audiobook settings mismatch', settings: normalizedExistingSettings }, { status: 409 });
|
||||||
}
|
}
|
||||||
|
|
@ -419,7 +465,6 @@ export async function POST(request: NextRequest) {
|
||||||
|| mergedSettings?.providerRef
|
|| mergedSettings?.providerRef
|
||||||
|| 'openai';
|
|| 'openai';
|
||||||
providerForError = requestedProvider;
|
providerForError = requestedProvider;
|
||||||
const runtimeConfig = await getResolvedRuntimeConfig();
|
|
||||||
const credResolved = await resolveTtsCredentials({
|
const credResolved = await resolveTtsCredentials({
|
||||||
providerHeader: requestedProvider,
|
providerHeader: requestedProvider,
|
||||||
apiKeyHeader: request.headers.get('x-openai-key'),
|
apiKeyHeader: request.headers.get('x-openai-key'),
|
||||||
|
|
|
||||||
152
src/lib/server/audiobooks/settings.ts
Normal file
152
src/lib/server/audiobooks/settings.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
115
tests/unit/audiobook-settings.spec.ts
Normal file
115
tests/unit/audiobook-settings.spec.ts
Normal file
|
|
@ -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.');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue