refactor(tts,audiobook,tests): centralize TTS catalog, harden epub & UI flows
- Move TTS provider resolution and voice-fetch logic into shared catalog and make
remote fetches more resilient (timeouts, abort handling, baseUrl normalization,
treat explicit empty custom-openai responses as valid).
- Add continuation guard to TTS feature set and update API voices endpoint error
logging to better surface auth/session failures.
- Extract and stabilize ebook pipeline and epub adapter:
- Cache prepared EPUB chapters to avoid repeated parsing and handle missing/empty
chapter titles safely.
- Fail early when created chapter lacks a bookId.
- Improve config/preferences handling:
- Replace JSON stringify comparisons with a deepEqual utility to compare defaults.
- Tighten ConfigContext effect dependencies and consolidate synced preference
queuing logic to include additional keys.
- Harden end-to-end test helpers and unit tests:
- Make settings dialog dismissal deterministic (use Enter/Escape and explicit
visibility/enabled checks) and simplify upload retry behavior.
- Add unit test to ensure custom-openai empty voice lists are preserved.
Why: Reduce runtime and test flakiness, centralize TTS logic for easier maintenance,
and make EPUB processing and preference diffing more robust. Breaking changes: none.
This commit is contained in:
parent
e373d268d2
commit
97dba6d987
9 changed files with 106 additions and 20 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "openreader",
|
||||
"version": "v2.1.1",
|
||||
"version": "2.1.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node scripts/openreader-entrypoint.mjs -- pnpm dev:raw",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export async function GET(req: NextRequest) {
|
|||
});
|
||||
return NextResponse.json({ voices });
|
||||
} catch (error) {
|
||||
// This catch mainly guards auth/session access failures; voice resolution itself falls back internally.
|
||||
console.error('Error in voices endpoint:', error);
|
||||
const provider = req.headers.get('x-tts-provider') || 'openai';
|
||||
const model = req.headers.get('x-tts-model') || 'tts-1';
|
||||
|
|
|
|||
|
|
@ -257,7 +257,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [isDBReady, authEnabled, appConfig, buildSyncedPreferencePatch, isSessionPending, sessionKey]);
|
||||
}, [isDBReady, authEnabled, appConfig, isSessionPending, sessionKey]);
|
||||
|
||||
// Destructure for convenience and to match context shape
|
||||
const {
|
||||
|
|
@ -326,9 +326,13 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
}, key, value);
|
||||
|
||||
await updateAppConfig(storagePatch);
|
||||
if (key === 'voice' || key === 'ttsProvider' || key === 'ttsModel' || key === 'savedVoices') {
|
||||
queueSyncedPreferencePatch(syncPatch);
|
||||
} else if (syncedPreferenceKeys.has(String(key))) {
|
||||
if (
|
||||
key === 'voice' ||
|
||||
key === 'ttsProvider' ||
|
||||
key === 'ttsModel' ||
|
||||
key === 'savedVoices' ||
|
||||
syncedPreferenceKeys.has(String(key))
|
||||
) {
|
||||
queueSyncedPreferencePatch(syncPatch);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ async function buildPreparedEpubChapters({
|
|||
for (const chapter of chapters) {
|
||||
if (!chapter.href) continue;
|
||||
const chapterBaseHref = chapter.href.split('#')[0];
|
||||
const chapterTitle = chapter.label.trim();
|
||||
const chapterTitle = typeof chapter.label === 'string' ? chapter.label.trim() : '';
|
||||
if (!chapterTitle) continue;
|
||||
|
||||
for (const section of sections) {
|
||||
const sectionBaseHref = section.href.split('#')[0];
|
||||
|
|
@ -41,12 +42,24 @@ async function buildPreparedEpubChapters({
|
|||
}
|
||||
|
||||
export function createEpubAudiobookSourceAdapter(options: EpubAudiobookAdapterOptions): AudiobookSourceAdapter {
|
||||
let preparedChaptersPromise: Promise<PreparedAudiobookChapter[]> | null = null;
|
||||
|
||||
const getPreparedChapters = () => {
|
||||
if (!preparedChaptersPromise) {
|
||||
preparedChaptersPromise = buildPreparedEpubChapters(options).catch((error) => {
|
||||
preparedChaptersPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return preparedChaptersPromise;
|
||||
};
|
||||
|
||||
return {
|
||||
noContentMessage: 'No text content found in book',
|
||||
noAudioGeneratedMessage: 'No audio was generated from the book content',
|
||||
prepareChapters: async () => buildPreparedEpubChapters(options),
|
||||
prepareChapters: async () => getPreparedChapters(),
|
||||
prepareChapter: async (chapterIndex: number) => {
|
||||
const chapters = await buildPreparedEpubChapters(options);
|
||||
const chapters = await getPreparedChapters();
|
||||
const chapter = chapters[chapterIndex];
|
||||
if (!chapter) {
|
||||
throw new Error('Invalid chapter index');
|
||||
|
|
|
|||
|
|
@ -174,7 +174,11 @@ export async function runAudiobookGeneration({
|
|||
}
|
||||
|
||||
if (!bookId) {
|
||||
bookId = createdChapter.bookId!;
|
||||
if (createdChapter.bookId) {
|
||||
bookId = createdChapter.bookId;
|
||||
} else {
|
||||
throw new Error('Created chapter is missing bookId');
|
||||
}
|
||||
}
|
||||
|
||||
onChapterComplete?.(createdChapter);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,36 @@
|
|||
import { APP_CONFIG_DEFAULTS, type AppConfigValues } from '@/types/config';
|
||||
import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state';
|
||||
|
||||
function isObjectLike(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function deepEqual(left: unknown, right: unknown): boolean {
|
||||
if (left === right) return true;
|
||||
|
||||
if (Array.isArray(left) && Array.isArray(right)) {
|
||||
if (left.length !== right.length) return false;
|
||||
for (let i = 0; i < left.length; i += 1) {
|
||||
if (!deepEqual(left[i], right[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isObjectLike(left) && isObjectLike(right)) {
|
||||
const leftKeys = Object.keys(left);
|
||||
const rightKeys = Object.keys(right);
|
||||
if (leftKeys.length !== rightKeys.length) return false;
|
||||
|
||||
for (const key of leftKeys) {
|
||||
if (!(key in right)) return false;
|
||||
if (!deepEqual(left[key], right[key])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function buildSyncedPreferencePatch(
|
||||
source: Partial<AppConfigValues>,
|
||||
options?: { nonDefaultOnly?: boolean },
|
||||
|
|
@ -16,7 +46,7 @@ export function buildSyncedPreferencePatch(
|
|||
const defaultValue = APP_CONFIG_DEFAULTS[key];
|
||||
const same =
|
||||
typeof value === 'object'
|
||||
? JSON.stringify(value) === JSON.stringify(defaultValue)
|
||||
? deepEqual(value, defaultValue)
|
||||
: value === defaultValue;
|
||||
if (same) continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,14 +167,22 @@ export function resolveVoiceSource(provider: string, model: string): TtsVoiceSou
|
|||
}
|
||||
|
||||
async function fetchDeepinfraVoices(apiKey: string): Promise<string[]> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, 10_000);
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.deepinfra.com/v1/voices', {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch Deepinfra voices');
|
||||
}
|
||||
|
|
@ -187,6 +195,10 @@ async function fetchDeepinfraVoices(apiKey: string): Promise<string[]> {
|
|||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
return [];
|
||||
}
|
||||
console.error('Error fetching Deepinfra voices:', error);
|
||||
return [];
|
||||
}
|
||||
|
|
@ -194,7 +206,8 @@ async function fetchDeepinfraVoices(apiKey: string): Promise<string[]> {
|
|||
|
||||
async function fetchCustomOpenAiVoices(baseUrl: string, apiKey: string): Promise<string[] | null> {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/audio/voices`, {
|
||||
const normalizedBaseUrl = baseUrl.replace(/\/+$/, '');
|
||||
const response = await fetch(`${normalizedBaseUrl}/audio/voices`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -230,7 +243,7 @@ export async function resolveVoices({ provider, model, apiKey = '', baseUrl = ''
|
|||
return defaultVoices;
|
||||
}
|
||||
const apiVoices = await fetchCustomOpenAiVoices(baseUrl, apiKey);
|
||||
if (apiVoices && apiVoices.length > 0) {
|
||||
if (apiVoices !== null) {
|
||||
return apiVoices;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,8 +95,6 @@ export async function uploadAndDisplay(page: Page, fileName: string) {
|
|||
break;
|
||||
} catch (error) {
|
||||
if (attempt === 2) throw error;
|
||||
const message = String(error);
|
||||
if (!message.includes('intercepts pointer events')) throw error;
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
}
|
||||
|
|
@ -118,15 +116,18 @@ async function dismissSettingsModalIfVisible(page: Page): Promise<void> {
|
|||
const saveBtn = settingsDialog.getByRole('button', { name: 'Save' });
|
||||
const saveVisible = await saveBtn.isVisible().catch(() => false);
|
||||
if (saveVisible) {
|
||||
await expect(saveBtn).toBeEnabled({ timeout: 10000 }).catch(() => { });
|
||||
await saveBtn.click({ force: true }).catch(async () => {
|
||||
await page.keyboard.press('Escape').catch(() => { });
|
||||
});
|
||||
const saveEnabled = await saveBtn.isEnabled();
|
||||
if (saveEnabled) {
|
||||
await saveBtn.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
} else {
|
||||
await page.keyboard.press('Escape');
|
||||
}
|
||||
} else {
|
||||
await page.keyboard.press('Escape').catch(() => { });
|
||||
await page.keyboard.press('Escape');
|
||||
}
|
||||
|
||||
await settingsDialog.waitFor({ state: 'hidden', timeout: 15000 }).catch(() => { });
|
||||
await settingsDialog.waitFor({ state: 'hidden', timeout: 15000 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
|
|||
import {
|
||||
getDefaultVoices,
|
||||
providerSupportsCustomModel,
|
||||
resolveVoices,
|
||||
resolveProviderModels,
|
||||
supportsTtsInstructions,
|
||||
} from '../../src/lib/shared/tts-provider-catalog';
|
||||
|
|
@ -48,6 +49,25 @@ test.describe('tts provider catalog', () => {
|
|||
expect(providerSupportsCustomModel('openai')).toBe(false);
|
||||
expect(providerSupportsCustomModel('deepinfra')).toBe(true);
|
||||
});
|
||||
|
||||
test('keeps explicit empty custom-openai voices response', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ voices: [] }),
|
||||
}) as Response;
|
||||
|
||||
try {
|
||||
await expect(resolveVoices({
|
||||
provider: 'custom-openai',
|
||||
model: 'kokoro',
|
||||
apiKey: 'token',
|
||||
baseUrl: 'https://example.com',
|
||||
})).resolves.toEqual([]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('config helpers', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue