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 { 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<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 {
|
||||
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'),
|
||||
|
|
|
|||
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