diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index 426b0ff..ca1db5d 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -29,6 +29,7 @@ import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/li import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/audiobooks/user-scope'; import { getFFmpegPath } from '@/lib/server/audiobooks/ffmpeg-bin'; import { generateTTSBuffer } from '@/lib/server/tts/generate'; +import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response'; import { supportsNativeModelSpeed } from '@/lib/shared/tts-provider-catalog'; import type { AudiobookGenerationSettings } from '@/types/client'; import type { TTSAudiobookFormat } from '@/types/tts'; @@ -54,6 +55,7 @@ type ChapterObject = { const SAFE_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; const PROBLEM_TYPES = { dailyQuotaExceeded: 'https://openreader.app/problems/daily-quota-exceeded', + upstreamRateLimited: 'https://openreader.app/problems/upstream-rate-limited', } as const; type ProblemDetails = { @@ -273,6 +275,7 @@ export async function POST(request: NextRequest) { let workDir: string | null = null; let didCreateDeviceIdCookie = false; let deviceIdToSet: string | null = null; + let providerForError: string | null = null; try { if (!isS3Configured()) return s3NotConfiguredResponse(); @@ -405,6 +408,7 @@ export async function POST(request: NextRequest) { const provider = request.headers.get('x-tts-provider') || mergedSettings?.ttsProvider || 'openai'; + providerForError = provider; const openApiKey = request.headers.get('x-openai-key') || process.env.API_KEY || 'none'; const openApiBaseUrl = request.headers.get('x-openai-base-url') || process.env.API_BASE; const model = mergedSettings?.ttsModel; @@ -595,6 +599,36 @@ export async function POST(request: NextRequest) { attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie); return response; } + + const upstreamStatus = getUpstreamStatus(error); + if (upstreamStatus === 429) { + const retryAfterSeconds = getUpstreamRetryAfterSeconds(error); + const problem: ProblemDetails = { + type: PROBLEM_TYPES.upstreamRateLimited, + title: 'Upstream rate limited', + status: 429, + detail: retryAfterSeconds + ? `The TTS provider is rate limiting requests. Please retry in about ${retryAfterSeconds}s.` + : 'The TTS provider is rate limiting requests. Please try again shortly.', + code: 'UPSTREAM_RATE_LIMIT', + provider: providerForError ?? undefined, + upstreamStatus, + retryAfterSeconds, + instance: request.nextUrl.pathname, + }; + + const response = new NextResponse(JSON.stringify(problem), { + status: 429, + headers: { + 'Content-Type': 'application/problem+json', + ...(retryAfterSeconds ? { 'Retry-After': String(retryAfterSeconds) } : {}), + }, + }); + + attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie); + return response; + } + console.error('Error processing audio chapter:', error); const response = NextResponse.json({ error: 'Failed to process audio chapter' }, { status: 500 }); attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie); diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts deleted file mode 100644 index 64bd12b..0000000 --- a/src/app/api/tts/route.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import type { TTSRequestPayload } from '@/types/client'; -import type { TTSError } from '@/types/tts'; -import { headers } from 'next/headers'; -import { auth } from '@/lib/server/auth/auth'; -import { rateLimiter, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter'; -import { isAuthEnabled } from '@/lib/server/auth/config'; -import { getClientIp } from '@/lib/server/rate-limit/request-ip'; -import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id'; -import { buildDailyQuotaExceededResponse } from '@/lib/server/rate-limit/problem-response'; -import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; -import { - buildTTSCacheKey, - generateTTSBuffer, - getCachedTTSBuffer, - getTTSContentType, -} from '@/lib/server/tts/generate'; -import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response'; - -export const runtime = 'nodejs'; -export const maxDuration = 60; - -function attachDeviceIdCookie(response: NextResponse, deviceId: string | null, didCreate: boolean) { - if (didCreate && deviceId) { - setDeviceIdCookie(response, deviceId); - } -} - -const PROBLEM_TYPES = { - upstreamRateLimited: 'https://openreader.app/problems/upstream-rate-limited', -} as const; - -type ProblemDetails = { - type: string; - title: string; - status: number; - detail?: string; - instance?: string; - code?: string; - [key: string]: unknown; -}; - -export async function POST(req: NextRequest) { - let providerForError: string | null = null; - let didCreateDeviceIdCookie = false; - let deviceIdToSet: string | null = null; - - try { - const body = (await req.json()) as TTSRequestPayload; - const { text, voice, speed, format, model: reqModel, instructions } = body; - - if (!text || !voice || !speed) { - const errorBody: TTSError = { - code: 'MISSING_PARAMETERS', - message: 'Missing required parameters', - }; - return NextResponse.json(errorBody, { status: 400 }); - } - - if (isAuthEnabled() && auth) { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session?.user) { - return NextResponse.json( - { code: 'UNAUTHORIZED', message: 'Authentication required' }, - { status: 401 } - ); - } - - const isAnonymous = Boolean(session.user.isAnonymous); - if (isTtsRateLimitEnabled()) { - const charCount = text.length; - const ip = getClientIp(req); - const device = isAnonymous ? getOrCreateDeviceId(req) : null; - if (device?.didCreate) { - didCreateDeviceIdCookie = true; - deviceIdToSet = device.deviceId; - } - - const rateLimitResult = await rateLimiter.checkAndIncrementLimit( - { id: session.user.id, isAnonymous }, - charCount, - { - deviceId: device?.deviceId ?? null, - ip, - } - ); - - if (!rateLimitResult.allowed) { - const response = buildDailyQuotaExceededResponse({ - rateLimitResult, - isAnonymousUser: isAnonymous, - pathname: req.nextUrl.pathname, - }); - - attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie); - return response; - } - } - } - - const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none'; - const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE; - const provider = req.headers.get('x-tts-provider') || 'openai'; - const testNamespace = getOpenReaderTestNamespace(req.headers); - providerForError = provider; - const varyHeader = 'x-tts-provider, x-openai-key, x-openai-base-url, x-openreader-test-namespace'; - - const ttsRequest = { - text, - voice, - speed, - format, - model: reqModel, - instructions, - provider, - apiKey: openApiKey, - baseUrl: openApiBaseUrl, - testNamespace, - }; - - const contentType = getTTSContentType(format); - const cacheKey = buildTTSCacheKey(ttsRequest); - const etag = `W/"${cacheKey}"`; - const ifNoneMatch = req.headers.get('if-none-match'); - - const cachedBuffer = getCachedTTSBuffer(cacheKey); - if (cachedBuffer) { - if (ifNoneMatch && (ifNoneMatch.includes(cacheKey) || ifNoneMatch.includes(etag))) { - const response = new NextResponse(null, { - status: 304, - headers: { - ETag: etag, - 'Cache-Control': 'private, max-age=1800', - Vary: varyHeader, - }, - }); - - attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie); - return response; - } - - const response = new NextResponse(new Uint8Array(cachedBuffer), { - headers: { - 'Content-Type': contentType, - 'X-Cache': 'HIT', - ETag: etag, - 'Content-Length': String(cachedBuffer.byteLength), - 'Cache-Control': 'private, max-age=1800', - Vary: varyHeader, - }, - }); - - attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie); - return response; - } - - const buffer = await generateTTSBuffer(ttsRequest, req.signal); - - const response = new NextResponse(new Uint8Array(buffer), { - headers: { - 'Content-Type': contentType, - 'X-Cache': 'MISS', - ETag: etag, - 'Content-Length': String(buffer.byteLength), - 'Cache-Control': 'private, max-age=1800', - Vary: varyHeader, - }, - }); - - attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie); - return response; - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - return new NextResponse(null, { status: 499 }); - } - - const upstreamStatus = getUpstreamStatus(error); - if (upstreamStatus === 429) { - const retryAfterSeconds = getUpstreamRetryAfterSeconds(error); - const problem: ProblemDetails = { - type: PROBLEM_TYPES.upstreamRateLimited, - title: 'Upstream rate limited', - status: 429, - detail: retryAfterSeconds - ? `The TTS provider is rate limiting requests. Please retry in about ${retryAfterSeconds}s.` - : 'The TTS provider is rate limiting requests. Please try again shortly.', - code: 'UPSTREAM_RATE_LIMIT', - provider: providerForError ?? undefined, - upstreamStatus, - retryAfterSeconds, - instance: req.nextUrl.pathname, - }; - - return new NextResponse(JSON.stringify(problem), { - status: 429, - headers: { - 'Content-Type': 'application/problem+json', - ...(retryAfterSeconds ? { 'Retry-After': String(retryAfterSeconds) } : {}), - }, - }); - } - - const statusHint = getUpstreamStatus(error); - const errorMessage = error instanceof Error ? error.message : String(error); - console.warn( - `Error generating TTS${typeof statusHint === 'number' ? ` (upstream ${statusHint})` : ''}: ${errorMessage}`, - ); - const errorBody: TTSError = { - code: 'TTS_GENERATION_FAILED', - message: 'Failed to generate audio', - details: process.env.NODE_ENV !== 'production' ? String(error) : undefined, - }; - return NextResponse.json(errorBody, { status: 500 }); - } -} diff --git a/src/app/api/tts/segments/ensure/route.ts b/src/app/api/tts/segments/ensure/route.ts index 495ece3..50accd7 100644 --- a/src/app/api/tts/segments/ensure/route.ts +++ b/src/app/api/tts/segments/ensure/route.ts @@ -26,6 +26,7 @@ import { rateLimiter, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate import { getClientIp } from '@/lib/server/rate-limit/request-ip'; import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id'; import { buildDailyQuotaExceededResponse } from '@/lib/server/rate-limit/problem-response'; +import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response'; import { alignAudioWithText } from '@/lib/server/whisper/alignment'; import type { TTSSegmentInput, @@ -520,11 +521,24 @@ export async function POST(request: NextRequest) { } catch (error) { const message = error instanceof Error ? error.message : 'Failed to generate segment'; const aborted = isAbortLikeError(error); + const upstreamStatus = getUpstreamStatus(error); + const retryAfterSeconds = upstreamStatus === 429 + ? getUpstreamRetryAfterSeconds(error) + : undefined; + const errorCode = upstreamStatus === 429 + ? 'UPSTREAM_RATE_LIMIT' + : upstreamStatus && upstreamStatus >= 500 + ? 'UPSTREAM_TTS_ERROR' + : 'TTS_SEGMENT_GENERATION_FAILED'; await db .update(ttsSegmentVariants) .set({ status: aborted ? 'pending' : 'error', - error: aborted ? null : message, + error: aborted ? null : ( + upstreamStatus + ? `${errorCode}${retryAfterSeconds ? ` (retry after ${retryAfterSeconds}s)` : ''}: ${message}` + : message + ), updatedAt: Date.now(), }) .where(and( @@ -542,6 +556,14 @@ export async function POST(request: NextRequest) { alignment: null, locator: segment.locator, status: aborted ? 'pending' : 'error', + error: aborted + ? null + : { + code: errorCode, + detail: message, + ...(typeof upstreamStatus === 'number' ? { upstreamStatus } : {}), + ...(typeof retryAfterSeconds === 'number' ? { retryAfterSeconds } : {}), + }, }); } } diff --git a/src/lib/client/api/audiobooks.ts b/src/lib/client/api/audiobooks.ts index 7fa63e2..d737815 100644 --- a/src/lib/client/api/audiobooks.ts +++ b/src/lib/client/api/audiobooks.ts @@ -1,5 +1,4 @@ import type { - TTSRequestPayload, TTSRequestHeaders, TTSRetryOptions, TTSRequestError, @@ -11,7 +10,7 @@ import type { TTSSegmentsEnsureRequest, TTSSegmentsEnsureResponse, } from '@/types/client'; -import type { TTSAudiobookChapter, TTSAudioBuffer } from '@/types/tts'; +import type { TTSAudiobookChapter } from '@/types/tts'; /** * Executes a function with exponential backoff retry logic @@ -69,8 +68,8 @@ export const withRetry = async ( } // Narrow client retries to transport-level failures only. - // If we got an HTTP status from /api/tts, do not retry from the client. - // Server-side /api/tts already applies upstream retry logic. + // If we got an HTTP status from a server route, do not retry from the client. + // Server-side routes already apply upstream retry logic where needed. const message = lastError.message.toLowerCase(); const isTransportFailure = status === undefined && @@ -144,8 +143,25 @@ export const createAudiobookChapter = async ( } if (!response.ok) { - const data = await response.json().catch(() => null) as { error?: string } | null; - throw new Error(data?.error || 'Failed to convert audio chapter'); + let problem: unknown = undefined; + const contentType = response.headers.get('content-type') || ''; + if (contentType.includes('application/problem+json') || contentType.includes('application/json')) { + problem = await response.json().catch(() => null); + } + + const err = new Error(`Audiobook chapter generation failed with status ${response.status}`) as TTSRequestError; + err.status = response.status; + if (typeof problem === 'object' && problem !== null) { + const rec = problem as Record; + if (typeof rec.code === 'string') err.code = rec.code; + if (typeof rec.type === 'string') err.type = rec.type; + if (typeof rec.title === 'string') err.title = rec.title; + if (typeof rec.detail === 'string') err.detail = rec.detail; + } else { + err.detail = 'Failed to convert audio chapter'; + } + + throw err; } return await response.json(); @@ -192,55 +208,6 @@ export const getVoices = async (headers: HeadersInit): Promise = return await response.json(); }; -export const generateTTS = async ( - payload: TTSRequestPayload, - headers: TTSRequestHeaders, - signal?: AbortSignal -): Promise => { - const response = await fetch('/api/tts', { - method: 'POST', - headers: headers as HeadersInit, - body: JSON.stringify(payload), - signal - }); - - if (!response.ok) { - let problem: unknown = undefined; - const contentType = response.headers.get('content-type') || ''; - if (contentType.includes('application/problem+json') || contentType.includes('application/json')) { - try { - problem = await response.json(); - } catch { - // ignore JSON parse errors - } - } - - const err = new Error(`TTS processing failed with status ${response.status}`) as TTSRequestError; - err.status = response.status; - - if (typeof problem === 'object' && problem !== null) { - const rec = problem as Record; - if (typeof rec.code === 'string') err.code = rec.code; - if (typeof rec.type === 'string') err.type = rec.type; - if (typeof rec.title === 'string') err.title = rec.title; - if (typeof rec.detail === 'string') err.detail = rec.detail; - } - - // Avoid noisy logs for expected user quota failures - if (!(err.status === 429 && err.code === 'USER_DAILY_QUOTA_EXCEEDED')) { - console.error(`TTS request failed: ${response.status}`, err.code ? { code: err.code } : undefined); - } - - throw err; - } - - const buffer = await response.arrayBuffer(); - if (buffer.byteLength === 0) { - throw new Error('Received empty audio buffer from TTS'); - } - return buffer; -}; - // --- Whisper API --- diff --git a/src/types/client.ts b/src/types/client.ts index bf2a623..f8b9755 100644 --- a/src/types/client.ts +++ b/src/types/client.ts @@ -7,20 +7,7 @@ import type { // --- TTS Client Request Types --- -// Supported output formats for the TTS endpoint -export type TTSRequestFormat = 'mp3'; - -// JSON payload accepted by the /api/tts endpoint -export interface TTSRequestPayload { - text: string; - voice: string; - speed: number; - model?: string | null; - format?: TTSRequestFormat; - instructions?: string; -} - -// Headers used when calling the /api/tts endpoint from the client +// Headers used when calling TTS-related endpoints from the client. export type TTSRequestHeaders = Record; // Options for retrying TTS requests on failure in withRetry @@ -94,7 +81,7 @@ export interface TTSSegmentSettings { ttsInstructions?: string; } -export type TTSReaderType = 'pdf' | 'epub' | 'html'; +type TTSReaderType = 'pdf' | 'epub' | 'html'; /** * Locator describing where a TTS segment came from inside a document. @@ -191,6 +178,12 @@ export interface TTSSegmentManifestItem { alignment: TTSSentenceAlignment | null; locator: TTSSegmentLocator | null; status: 'pending' | 'completed' | 'error'; + error?: { + code: string; + detail?: string; + upstreamStatus?: number; + retryAfterSeconds?: number; + } | null; } export interface TTSSegmentsEnsureResponse { diff --git a/src/types/tts.ts b/src/types/tts.ts index b716396..0ed05cf 100644 --- a/src/types/tts.ts +++ b/src/types/tts.ts @@ -6,21 +6,6 @@ export type TTSLocation = string | number; export type TTSAudioBuffer = ArrayBuffer; export type TTSAudioBytes = number[]; // JSON-safe representation (Array.from(new Uint8Array(buffer))) -// Standardized error codes for the TTS API -export type TTSErrorCode = - | 'MISSING_PARAMETERS' - | 'INVALID_REQUEST' - | 'TTS_GENERATION_FAILED' - | 'ABORTED' - | 'INTERNAL_ERROR'; - -// Structured error object returned by the TTS API -export interface TTSError { - code: TTSErrorCode; - message: string; - details?: unknown; -} - // Core playback state exposed by the TTS context export interface TTSPlaybackState { isPlaying: boolean; @@ -55,7 +40,7 @@ export interface TTSSentenceAlignment { words: TTSSentenceWord[]; } -export interface EpubRenderedLocationWalkItem { +interface EpubRenderedLocationWalkItem { /** Page-start CFI from the rendition — best-effort jump hint only. */ cfi: string; /** Plain text content of the rendered page chunk. */ diff --git a/src/types/user-state.ts b/src/types/user-state.ts index cb69b9e..9100bf1 100644 --- a/src/types/user-state.ts +++ b/src/types/user-state.ts @@ -26,7 +26,7 @@ export const SYNCED_PREFERENCE_KEYS = [ ] as const; export type SyncedPreferenceKey = (typeof SYNCED_PREFERENCE_KEYS)[number]; -export type SyncedPreferences = Pick; +type SyncedPreferences = Pick; export type SyncedPreferencesPatch = Partial; export type ReaderType = 'pdf' | 'epub' | 'html'; diff --git a/tests/export.spec.ts b/tests/export.spec.ts index d049769..d8137a0 100644 --- a/tests/export.spec.ts +++ b/tests/export.spec.ts @@ -224,7 +224,8 @@ test('exports full MP3 audiobook for PDF using mocked 10s TTS sample', async ({ // Choose MP3 so we can validate MP3 duration end-to-end. await setContainerFormatToMP3(page); - // Start generation; this will call the mocked /api/tts which returns a 10s sample.mp3 per page + // Start generation; in test namespace mode the server returns tests/files/sample.mp3 + // for each generated chapter via generateTTSBuffer's test mock path. await startGeneration(page); // Wait for chapters list to appear and populate at least two items (Pages 1 and 2) diff --git a/tests/helpers.ts b/tests/helpers.ts index 9c6ccd0..a29032a 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -4,32 +4,11 @@ import path from 'path'; import { createHash } from 'crypto'; const DIR = './tests/files/'; -const TTS_MOCK_PATH = path.join(__dirname, 'files', 'sample.mp3'); -let ttsMockBuffer: Buffer | null = null; function isAuthEnabledForTests() { return Boolean(process.env.AUTH_SECRET && process.env.BASE_URL); } -async function ensureTtsRouteMock(page: Page) { - if (!ttsMockBuffer) { - ttsMockBuffer = fs.readFileSync(TTS_MOCK_PATH); - } - - await page.route('**/api/tts', async (route) => { - // Only mock the POST TTS generation calls; let anything else pass through. - if (route.request().method().toUpperCase() !== 'POST') { - return route.continue(); - } - - await route.fulfill({ - status: 200, - contentType: 'audio/mpeg', - body: ttsMockBuffer as Buffer, - }); - }); -} - // Small util to safely use filenames inside regex patterns function escapeRegExp(input: string) { return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -256,9 +235,6 @@ export async function setupTest(page: Page, testInfo?: TestInfo) { } } - // Mock the TTS API so tests don't hit the real TTS service. - await ensureTtsRouteMock(page); - // Pre-seed consent to prevent the cookie banner from blocking interactions. await page.addInitScript(() => { try {