Eliminate the smartSentenceSplitting configuration flag from application state, UI, and all TTS and audiobook adapter logic. Consolidate TTS segment planning to always include context units, streamlining code and reducing conditional branches. Update tests and type definitions to reflect the removal of this feature toggle.
321 lines
11 KiB
TypeScript
321 lines
11 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { eq } from 'drizzle-orm';
|
|
import { db } from '@/db';
|
|
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';
|
|
import {
|
|
deserializeUserPreferencesPayload,
|
|
extractUserPreferencesMeta,
|
|
withUserPreferencesMeta,
|
|
type UserPreferencesMeta,
|
|
} from '@/lib/server/user/preferences-payload';
|
|
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
function serializePreferencesForDb(payload: Record<string, unknown>): Record<string, unknown> | string {
|
|
if (process.env.POSTGRES_URL) return payload;
|
|
return JSON.stringify(payload);
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null;
|
|
}
|
|
|
|
interface PreferenceNormalizationContext {
|
|
defaultProviderRef: string;
|
|
showAllProviderModels: boolean;
|
|
sharedProviders: Array<{
|
|
slug: string;
|
|
providerType: TtsProviderId;
|
|
defaultModel: string | null;
|
|
defaultInstructions: string | null;
|
|
}>;
|
|
}
|
|
|
|
async function loadPreferenceNormalizationContext(): Promise<PreferenceNormalizationContext> {
|
|
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; meta: UserPreferencesMeta } {
|
|
const payload = deserializeUserPreferencesPayload(value);
|
|
const meta = extractUserPreferencesMeta(payload);
|
|
const sanitized = sanitizePreferencesPatch(payload, context, { fillMissingProvider: true });
|
|
return { ...sanitized, meta };
|
|
}
|
|
|
|
function sanitizeSavedVoices(value: unknown): Record<string, string> {
|
|
if (!isRecord(value)) return {};
|
|
const out: Record<string, string> = {};
|
|
for (const [key, val] of Object.entries(value)) {
|
|
if (typeof key !== 'string' || key.length === 0) continue;
|
|
if (typeof val !== 'string') continue;
|
|
out[key] = val;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
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<string, unknown>;
|
|
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 rec)) continue;
|
|
const value = rec[key];
|
|
|
|
switch (key) {
|
|
case 'viewType':
|
|
if (value === 'single' || value === 'dual' || value === 'scroll') out[key] = value;
|
|
break;
|
|
case 'voice':
|
|
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':
|
|
case 'segmentPreloadSentenceLookahead':
|
|
case 'ttsSegmentMaxBlockLength':
|
|
case 'headerMargin':
|
|
case 'footerMargin':
|
|
case 'leftMargin':
|
|
case 'rightMargin':
|
|
if (Number.isFinite(value)) out[key] = Number(value);
|
|
break;
|
|
case 'skipBlank':
|
|
case 'epubTheme':
|
|
case 'pdfHighlightEnabled':
|
|
case 'pdfWordHighlightEnabled':
|
|
case 'epubHighlightEnabled':
|
|
case 'epubWordHighlightEnabled':
|
|
case 'htmlHighlightEnabled':
|
|
case 'htmlWordHighlightEnabled':
|
|
if (typeof value === 'boolean') out[key] = value;
|
|
break;
|
|
case 'savedVoices':
|
|
out[key] = sanitizeSavedVoices(value);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
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 {
|
|
const normalized = coerceTimestampMs(value, nowTimestampMs());
|
|
if (normalized <= 0) return nowTimestampMs();
|
|
return normalized;
|
|
}
|
|
|
|
export async function GET(req: NextRequest) {
|
|
try {
|
|
const normalizationContext = await loadPreferenceNormalizationContext();
|
|
const scope = await resolveUserStateScope(req);
|
|
if (scope instanceof Response) return scope;
|
|
|
|
const rows = await db
|
|
.select({
|
|
dataJson: userPreferences.dataJson,
|
|
clientUpdatedAtMs: userPreferences.clientUpdatedAtMs,
|
|
})
|
|
.from(userPreferences)
|
|
.where(eq(userPreferences.userId, scope.ownerUserId))
|
|
.limit(1);
|
|
|
|
const row = rows[0];
|
|
const stored = parseStoredPreferences(row?.dataJson, normalizationContext);
|
|
const storedPatch = stored.patch;
|
|
const storedPayload = withUserPreferencesMeta(storedPatch, stored.meta);
|
|
const clientUpdatedAtMs = Number(row?.clientUpdatedAtMs ?? 0);
|
|
|
|
if (row && stored.migrated) {
|
|
const updatedAt = nowTimestampMs();
|
|
await db
|
|
.insert(userPreferences)
|
|
.values({
|
|
userId: scope.ownerUserId,
|
|
dataJson: serializePreferencesForDb(storedPayload),
|
|
clientUpdatedAtMs: clientUpdatedAtMs > 0 ? clientUpdatedAtMs : updatedAt,
|
|
updatedAt,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [userPreferences.userId],
|
|
set: {
|
|
dataJson: serializePreferencesForDb(storedPayload),
|
|
updatedAt,
|
|
},
|
|
});
|
|
}
|
|
|
|
return NextResponse.json({
|
|
preferences: storedPatch,
|
|
clientUpdatedAtMs,
|
|
hasStoredPreferences: Boolean(row),
|
|
});
|
|
} catch (error) {
|
|
console.error('Error loading user preferences:', error);
|
|
return NextResponse.json({ error: 'Failed to load user preferences' }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
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,
|
|
normalizationContext,
|
|
{ fillMissingProvider: false },
|
|
).patch;
|
|
const clientUpdatedAtMs = normalizeClientUpdatedAtMs(body?.clientUpdatedAtMs);
|
|
|
|
if (Object.keys(patch).length === 0) {
|
|
return NextResponse.json({ error: 'No valid preferences provided' }, { status: 400 });
|
|
}
|
|
|
|
const existingRows = await db
|
|
.select({
|
|
dataJson: userPreferences.dataJson,
|
|
clientUpdatedAtMs: userPreferences.clientUpdatedAtMs,
|
|
})
|
|
.from(userPreferences)
|
|
.where(eq(userPreferences.userId, scope.ownerUserId))
|
|
.limit(1);
|
|
const existing = existingRows[0];
|
|
const existingUpdated = Number(existing?.clientUpdatedAtMs ?? 0);
|
|
const existingStored = parseStoredPreferences(existing?.dataJson, normalizationContext);
|
|
const existingPatch = existingStored.patch;
|
|
|
|
if (existing && clientUpdatedAtMs < existingUpdated) {
|
|
return NextResponse.json({
|
|
preferences: existingPatch,
|
|
clientUpdatedAtMs: existingUpdated,
|
|
applied: false,
|
|
});
|
|
}
|
|
|
|
const mergedPatch = { ...existingPatch, ...patch };
|
|
const payloadWithMeta = withUserPreferencesMeta(mergedPatch, existingStored.meta);
|
|
const dataJson = serializePreferencesForDb(payloadWithMeta);
|
|
const updatedAt = nowTimestampMs();
|
|
|
|
await db
|
|
.insert(userPreferences)
|
|
.values({
|
|
userId: scope.ownerUserId,
|
|
dataJson,
|
|
clientUpdatedAtMs,
|
|
updatedAt,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [userPreferences.userId],
|
|
set: {
|
|
dataJson,
|
|
clientUpdatedAtMs,
|
|
updatedAt,
|
|
},
|
|
});
|
|
|
|
return NextResponse.json({
|
|
preferences: mergedPatch,
|
|
clientUpdatedAtMs,
|
|
applied: true,
|
|
});
|
|
} catch (error) {
|
|
console.error('Error updating user preferences:', error);
|
|
return NextResponse.json({ error: 'Failed to update user preferences' }, { status: 500 });
|
|
}
|
|
}
|