refactor(audiobooks,tts,config): tighten abort handling, progress accounting, equality checks, and voice fetch timeout

Improve robustness across audiobook pipeline, TTS catalog, and preferences comparison.

- audiobooks: centralize abort creation with createAudiobookAbortError and replace repeated ad-hoc throws with it; avoid reprocessing non-completed chapters when collecting existing indices; update progress accounting by advancing processedLength and emitting onProgress after saving a chapter.
- tts: add a 10s AbortController timeout when fetching custom OpenAI voices, wire the signal to fetch, and ensure the timeout is cleared on success or failure; fall back cleanly when endpoint doesn't support voices.
- config: harden deepEqual by checking mismatched array vs object shapes early to avoid incorrect comparisons.

No user-visible behavior changes besides improved cancellation, progress reporting, and more resilient remote voice discovery.
This commit is contained in:
Richard R 2026-04-06 11:28:18 -06:00
parent 7d9d8de967
commit 09944ec4e4
3 changed files with 26 additions and 13 deletions

View file

@ -86,6 +86,12 @@ function isAbortLikeError(error: unknown): boolean {
return error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled')); return error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'));
} }
function createAudiobookAbortError(): Error {
const error = new Error('Audiobook generation cancelled');
error.name = 'AbortError';
return error;
}
export async function runAudiobookGeneration({ export async function runAudiobookGeneration({
adapter, adapter,
apiKey, apiKey,
@ -120,7 +126,9 @@ export async function runAudiobookGeneration({
const existingData = await getAudiobookStatus(bookId); const existingData = await getAudiobookStatus(bookId);
if (existingData.chapters && existingData.chapters.length > 0) { if (existingData.chapters && existingData.chapters.length > 0) {
for (const chapter of existingData.chapters) { for (const chapter of existingData.chapters) {
existingIndices.add(chapter.index); if (chapter.status === 'completed') {
existingIndices.add(chapter.index);
}
} }
} }
} catch (error) { } catch (error) {
@ -130,10 +138,7 @@ export async function runAudiobookGeneration({
for (const chapter of chapters) { for (const chapter of chapters) {
if (signal?.aborted) { if (signal?.aborted) {
if (bookId) { throw createAudiobookAbortError();
return bookId;
}
throw new Error('Audiobook generation cancelled');
} }
const trimmedText = chapter.text.trim(); const trimmedText = chapter.text.trim();
@ -167,10 +172,7 @@ export async function runAudiobookGeneration({
); );
if (signal?.aborted) { if (signal?.aborted) {
if (bookId) { throw createAudiobookAbortError();
return bookId;
}
throw new Error('Audiobook generation cancelled');
} }
if (!bookId) { if (!bookId) {
@ -186,10 +188,7 @@ export async function runAudiobookGeneration({
onProgress((processedLength / totalLength) * 100); onProgress((processedLength / totalLength) * 100);
} catch (error) { } catch (error) {
if (isAbortLikeError(error)) { if (isAbortLikeError(error)) {
if (bookId) { throw createAudiobookAbortError();
return bookId;
}
throw new Error('Audiobook generation cancelled');
} }
console.error('Error processing chapter:', error); console.error('Error processing chapter:', error);
@ -200,6 +199,8 @@ export async function runAudiobookGeneration({
bookId, bookId,
format: effectiveFormat, format: effectiveFormat,
}); });
processedLength += trimmedText.length;
onProgress((processedLength / totalLength) * 100);
} }
} }

View file

@ -8,6 +8,9 @@ function isObjectLike(value: unknown): value is Record<string, unknown> {
function deepEqual(left: unknown, right: unknown): boolean { function deepEqual(left: unknown, right: unknown): boolean {
if (left === right) return true; if (left === right) return true;
if (Array.isArray(left) !== Array.isArray(right)) return false;
if (isObjectLike(left) !== isObjectLike(right)) return false;
if (Array.isArray(left) && Array.isArray(right)) { if (Array.isArray(left) && Array.isArray(right)) {
if (left.length !== right.length) return false; if (left.length !== right.length) return false;
for (let i = 0; i < left.length; i += 1) { for (let i = 0; i < left.length; i += 1) {

View file

@ -205,15 +205,23 @@ async function fetchDeepinfraVoices(apiKey: string): Promise<string[]> {
} }
async function fetchCustomOpenAiVoices(baseUrl: string, apiKey: string): Promise<string[] | null> { async function fetchCustomOpenAiVoices(baseUrl: string, apiKey: string): Promise<string[] | null> {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, 10_000);
try { try {
const normalizedBaseUrl = baseUrl.replace(/\/+$/, ''); const normalizedBaseUrl = baseUrl.replace(/\/+$/, '');
const response = await fetch(`${normalizedBaseUrl}/audio/voices`, { const response = await fetch(`${normalizedBaseUrl}/audio/voices`, {
signal: controller.signal,
headers: { headers: {
Authorization: `Bearer ${apiKey}`, Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}); });
clearTimeout(timeoutId);
if (!response.ok) { if (!response.ok) {
return null; return null;
} }
@ -221,6 +229,7 @@ async function fetchCustomOpenAiVoices(baseUrl: string, apiKey: string): Promise
const data = await response.json(); const data = await response.json();
return Array.isArray(data.voices) ? data.voices : null; return Array.isArray(data.voices) ? data.voices : null;
} catch { } catch {
clearTimeout(timeoutId);
console.log('Custom endpoint does not support voices, using defaults'); console.log('Custom endpoint does not support voices, using defaults');
return null; return null;
} }