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'));
}
function createAudiobookAbortError(): Error {
const error = new Error('Audiobook generation cancelled');
error.name = 'AbortError';
return error;
}
export async function runAudiobookGeneration({
adapter,
apiKey,
@ -120,7 +126,9 @@ export async function runAudiobookGeneration({
const existingData = await getAudiobookStatus(bookId);
if (existingData.chapters && existingData.chapters.length > 0) {
for (const chapter of existingData.chapters) {
existingIndices.add(chapter.index);
if (chapter.status === 'completed') {
existingIndices.add(chapter.index);
}
}
}
} catch (error) {
@ -130,10 +138,7 @@ export async function runAudiobookGeneration({
for (const chapter of chapters) {
if (signal?.aborted) {
if (bookId) {
return bookId;
}
throw new Error('Audiobook generation cancelled');
throw createAudiobookAbortError();
}
const trimmedText = chapter.text.trim();
@ -167,10 +172,7 @@ export async function runAudiobookGeneration({
);
if (signal?.aborted) {
if (bookId) {
return bookId;
}
throw new Error('Audiobook generation cancelled');
throw createAudiobookAbortError();
}
if (!bookId) {
@ -186,10 +188,7 @@ export async function runAudiobookGeneration({
onProgress((processedLength / totalLength) * 100);
} catch (error) {
if (isAbortLikeError(error)) {
if (bookId) {
return bookId;
}
throw new Error('Audiobook generation cancelled');
throw createAudiobookAbortError();
}
console.error('Error processing chapter:', error);
@ -200,6 +199,8 @@ export async function runAudiobookGeneration({
bookId,
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 {
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 (left.length !== right.length) return false;
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> {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, 10_000);
try {
const normalizedBaseUrl = baseUrl.replace(/\/+$/, '');
const response = await fetch(`${normalizedBaseUrl}/audio/voices`, {
signal: controller.signal,
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
});
clearTimeout(timeoutId);
if (!response.ok) {
return null;
}
@ -221,6 +229,7 @@ async function fetchCustomOpenAiVoices(baseUrl: string, apiKey: string): Promise
const data = await response.json();
return Array.isArray(data.voices) ? data.voices : null;
} catch {
clearTimeout(timeoutId);
console.log('Custom endpoint does not support voices, using defaults');
return null;
}