diff --git a/package.json b/package.json index e86214f..ac8fdf6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/api/tts/voices/route.ts b/src/app/api/tts/voices/route.ts index 913e0e2..31f1155 100644 --- a/src/app/api/tts/voices/route.ts +++ b/src/app/api/tts/voices/route.ts @@ -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'; diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index d0de9e9..5bcd124 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -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) { diff --git a/src/lib/client/audiobooks/adapters/epub.ts b/src/lib/client/audiobooks/adapters/epub.ts index 6b69be0..ecd71cc 100644 --- a/src/lib/client/audiobooks/adapters/epub.ts +++ b/src/lib/client/audiobooks/adapters/epub.ts @@ -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 | 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'); diff --git a/src/lib/client/audiobooks/pipeline.ts b/src/lib/client/audiobooks/pipeline.ts index 66dc2e4..295cef4 100644 --- a/src/lib/client/audiobooks/pipeline.ts +++ b/src/lib/client/audiobooks/pipeline.ts @@ -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); diff --git a/src/lib/client/config/preferences.ts b/src/lib/client/config/preferences.ts index 1a3e019..261a000 100644 --- a/src/lib/client/config/preferences.ts +++ b/src/lib/client/config/preferences.ts @@ -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 { + 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, 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; } diff --git a/src/lib/shared/tts-provider-catalog.ts b/src/lib/shared/tts-provider-catalog.ts index 243acf6..4713b34 100644 --- a/src/lib/shared/tts-provider-catalog.ts +++ b/src/lib/shared/tts-provider-catalog.ts @@ -167,14 +167,22 @@ export function resolveVoiceSource(provider: string, model: string): TtsVoiceSou } async function fetchDeepinfraVoices(apiKey: string): Promise { + 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 { } 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 { async function fetchCustomOpenAiVoices(baseUrl: string, apiKey: string): Promise { 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; } } diff --git a/tests/helpers.ts b/tests/helpers.ts index 65a5b19..21270a9 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -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 { 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 }); } /** diff --git a/tests/unit/tts-provider-catalog.spec.ts b/tests/unit/tts-provider-catalog.spec.ts index dfd7ba7..120bd10 100644 --- a/tests/unit/tts-provider-catalog.spec.ts +++ b/tests/unit/tts-provider-catalog.spec.ts @@ -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', () => {