From 7d7e93332315fd0032c7090399cd2ef708e5ddda Mon Sep 17 00:00:00 2001 From: Richard R Date: Mon, 4 May 2026 12:04:24 -0600 Subject: [PATCH] refactor(tts): replace manifest and audio endpoints with presigned and fallback URLs Remove legacy TTS segment manifest and audio endpoints in favor of new presigned and fallback audio URL approach. Update segment manifest structure to include `audioPresignUrl` and `audioFallbackUrl` fields, deprecating the previous `audioUrl`. Refactor segment ensure logic and TTS context to utilize the new playback source model. Add server utilities for generating presigned S3 URLs and introduce new API routes for presign and fallback audio retrieval. Clean up obsolete client API functions and types to reflect the new manifest format. --- .../api/tts/segments/audio/fallback/route.ts | 30 +++++ .../api/tts/segments/audio/presign/route.ts | 39 ++++++ src/app/api/tts/segments/audio/route.ts | 70 ----------- src/app/api/tts/segments/ensure/route.ts | 21 +++- src/app/api/tts/segments/manifest/route.ts | 81 ------------- src/contexts/TTSContext.tsx | 112 +++++++++--------- src/lib/client/api/audiobooks.ts | 20 ---- src/lib/server/tts/segments-audio.ts | 61 ++++++++++ src/lib/server/tts/segments-blobstore.ts | 19 ++- src/types/client.ts | 5 +- 10 files changed, 225 insertions(+), 233 deletions(-) create mode 100644 src/app/api/tts/segments/audio/fallback/route.ts create mode 100644 src/app/api/tts/segments/audio/presign/route.ts delete mode 100644 src/app/api/tts/segments/audio/route.ts delete mode 100644 src/app/api/tts/segments/manifest/route.ts create mode 100644 src/lib/server/tts/segments-audio.ts diff --git a/src/app/api/tts/segments/audio/fallback/route.ts b/src/app/api/tts/segments/audio/fallback/route.ts new file mode 100644 index 0000000..b159305 --- /dev/null +++ b/src/app/api/tts/segments/audio/fallback/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { isS3Configured } from '@/lib/server/storage/s3'; +import { + resolveCompletedSegmentAudio, + streamAudioBuffer, + ttsSegmentsS3NotConfiguredResponse, +} from '@/lib/server/tts/segments-audio'; +import { getTtsSegmentAudioObject } from '@/lib/server/tts/segments-blobstore'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: NextRequest) { + try { + if (!isS3Configured()) return ttsSegmentsS3NotConfiguredResponse(); + + const resolved = await resolveCompletedSegmentAudio(request); + if (resolved instanceof Response) return resolved; + + const audio = await getTtsSegmentAudioObject(resolved.audioKey); + return new NextResponse(streamAudioBuffer(audio), { + headers: { + 'Content-Type': 'audio/mpeg', + 'Cache-Control': 'no-store', + }, + }); + } catch (error) { + console.error('Error serving TTS segment audio fallback:', error); + return NextResponse.json({ error: 'Failed to load segment audio' }, { status: 500 }); + } +} diff --git a/src/app/api/tts/segments/audio/presign/route.ts b/src/app/api/tts/segments/audio/presign/route.ts new file mode 100644 index 0000000..8ea4706 --- /dev/null +++ b/src/app/api/tts/segments/audio/presign/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { isS3Configured } from '@/lib/server/storage/s3'; +import { + resolveCompletedSegmentAudio, + ttsSegmentsS3NotConfiguredResponse, +} from '@/lib/server/tts/segments-audio'; +import { presignTtsSegmentAudioGet } from '@/lib/server/tts/segments-blobstore'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: NextRequest) { + try { + if (!isS3Configured()) return ttsSegmentsS3NotConfiguredResponse(); + + const resolved = await resolveCompletedSegmentAudio(request); + if (resolved instanceof Response) return resolved; + + const fallbackUrl = `/api/tts/segments/audio/fallback?documentId=${encodeURIComponent(resolved.documentId)}&segmentId=${encodeURIComponent(resolved.segmentId)}`; + const directUrl = await presignTtsSegmentAudioGet(resolved.audioKey).catch(() => null); + if (!directUrl) { + console.warn('[blob-fallback] presign segment audio unavailable, redirecting to proxy fallback', { + documentId: resolved.documentId, + segmentId: resolved.segmentId, + }); + return NextResponse.redirect(fallbackUrl, { + status: 307, + headers: { 'Cache-Control': 'no-store' }, + }); + } + + return NextResponse.redirect(directUrl, { + status: 307, + headers: { 'Cache-Control': 'no-store' }, + }); + } catch (error) { + console.error('Error creating segment audio signature:', error); + return NextResponse.json({ error: 'Failed to prepare segment audio' }, { status: 500 }); + } +} diff --git a/src/app/api/tts/segments/audio/route.ts b/src/app/api/tts/segments/audio/route.ts deleted file mode 100644 index e2e0a93..0000000 --- a/src/app/api/tts/segments/audio/route.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { and, eq } from 'drizzle-orm'; -import { db } from '@/db'; -import { ttsSegments } from '@/db/schema'; -import { isS3Configured } from '@/lib/server/storage/s3'; -import { getTtsSegmentAudioObject } from '@/lib/server/tts/segments-blobstore'; -import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth'; - -export const dynamic = 'force-dynamic'; - -function s3NotConfiguredResponse(): NextResponse { - return NextResponse.json( - { error: 'TTS segments storage is not configured. Set S3_* environment variables.' }, - { status: 503 }, - ); -} - -function streamBuffer(buffer: Buffer): ReadableStream { - return new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array(buffer)); - controller.close(); - }, - }); -} - -export async function GET(request: NextRequest) { - try { - if (!isS3Configured()) return s3NotConfiguredResponse(); - - const documentId = (request.nextUrl.searchParams.get('documentId') || '').trim().toLowerCase(); - const segmentId = (request.nextUrl.searchParams.get('segmentId') || '').trim().toLowerCase(); - if (!documentId || !segmentId) { - return NextResponse.json({ error: 'Missing documentId or segmentId' }, { status: 400 }); - } - - const scope = await resolveSegmentDocumentScope(request, documentId); - if (scope instanceof Response) return scope; - - const rows = (await db - .select({ - audioKey: ttsSegments.audioKey, - status: ttsSegments.status, - }) - .from(ttsSegments) - .where(and( - eq(ttsSegments.userId, scope.storageUserId), - eq(ttsSegments.documentId, documentId), - eq(ttsSegments.documentVersion, scope.documentVersion), - eq(ttsSegments.segmentId, segmentId), - ))) as Array<{ audioKey: string | null; status: string }>; - - const row = rows[0]; - if (!row || !row.audioKey || row.status !== 'completed') { - return NextResponse.json({ error: 'Segment audio not found' }, { status: 404 }); - } - - const audio = await getTtsSegmentAudioObject(row.audioKey); - - return new NextResponse(streamBuffer(audio), { - headers: { - 'Content-Type': 'audio/mpeg', - 'Cache-Control': 'private, max-age=86400', - }, - }); - } catch (error) { - console.error('Error serving TTS segment audio:', error); - return NextResponse.json({ error: 'Failed to load segment audio' }, { status: 500 }); - } -} diff --git a/src/app/api/tts/segments/ensure/route.ts b/src/app/api/tts/segments/ensure/route.ts index a64cd9c..e7cf577 100644 --- a/src/app/api/tts/segments/ensure/route.ts +++ b/src/app/api/tts/segments/ensure/route.ts @@ -108,6 +108,18 @@ function textHmacSecret(): string { || 'openreader-default-tts-segment-secret'; } +function buildSegmentAudioUrls(documentId: string, segmentId: string): { + audioPresignUrl: string; + audioFallbackUrl: string; +} { + const encodedDocumentId = encodeURIComponent(documentId); + const encodedSegmentId = encodeURIComponent(segmentId); + return { + audioPresignUrl: `/api/tts/segments/audio/presign?documentId=${encodedDocumentId}&segmentId=${encodedSegmentId}`, + audioFallbackUrl: `/api/tts/segments/audio/fallback?documentId=${encodedDocumentId}&segmentId=${encodedSegmentId}`, + }; +} + export async function POST(request: NextRequest) { let didCreateDeviceIdCookie = false; let deviceIdToSet: string | null = null; @@ -230,7 +242,7 @@ export async function POST(request: NextRequest) { manifest.push({ segmentId: segment.segmentId, segmentIndex: existing.segmentIndex, - audioUrl: `/api/tts/segments/audio?documentId=${encodeURIComponent(parsed.documentId)}&segmentId=${encodeURIComponent(segment.segmentId)}`, + ...buildSegmentAudioUrls(parsed.documentId, segment.segmentId), durationMs: existing.durationMs ?? 0, alignment, locator, @@ -389,7 +401,7 @@ export async function POST(request: NextRequest) { manifest.push({ segmentId: segment.segmentId, segmentIndex: segment.original.segmentIndex, - audioUrl: `/api/tts/segments/audio?documentId=${encodeURIComponent(parsed.documentId)}&segmentId=${encodeURIComponent(segment.segmentId)}`, + ...buildSegmentAudioUrls(parsed.documentId, segment.segmentId), durationMs, alignment, locator: segment.locator, @@ -409,7 +421,8 @@ export async function POST(request: NextRequest) { manifest.push({ segmentId: segment.segmentId, segmentIndex: segment.original.segmentIndex, - audioUrl: '', + audioPresignUrl: null, + audioFallbackUrl: null, durationMs: 0, alignment: null, locator: segment.locator, @@ -420,8 +433,6 @@ export async function POST(request: NextRequest) { const response = NextResponse.json({ documentId: parsed.documentId, - documentVersion: scope.documentVersion, - settingsHash, segments: manifest, }); attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie); diff --git a/src/app/api/tts/segments/manifest/route.ts b/src/app/api/tts/segments/manifest/route.ts deleted file mode 100644 index ef01812..0000000 --- a/src/app/api/tts/segments/manifest/route.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { and, asc, eq, gte, lte } from 'drizzle-orm'; -import { db } from '@/db'; -import { ttsSegments } from '@/db/schema'; -import { isS3Configured } from '@/lib/server/storage/s3'; -import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth'; -import type { TTSSegmentManifestItem } from '@/types/client'; - -export const dynamic = 'force-dynamic'; - -function s3NotConfiguredResponse(): NextResponse { - return NextResponse.json( - { error: 'TTS segments storage is not configured. Set S3_* environment variables.' }, - { status: 503 }, - ); -} - -function parseIntOr(value: string | null, fallback: number): number { - const n = Number.parseInt(value || '', 10); - return Number.isFinite(n) ? n : fallback; -} - -export async function GET(request: NextRequest) { - try { - if (!isS3Configured()) return s3NotConfiguredResponse(); - - const documentId = (request.nextUrl.searchParams.get('documentId') || '').trim().toLowerCase(); - const settingsHash = (request.nextUrl.searchParams.get('settingsHash') || '').trim(); - if (!documentId || !settingsHash) { - return NextResponse.json({ error: 'Missing documentId or settingsHash' }, { status: 400 }); - } - - const scope = await resolveSegmentDocumentScope(request, documentId); - if (scope instanceof Response) return scope; - - const fromIndex = Math.max(0, parseIntOr(request.nextUrl.searchParams.get('fromIndex'), 0)); - const toIndex = Math.max(fromIndex, parseIntOr(request.nextUrl.searchParams.get('toIndex'), fromIndex + 8)); - - const rows = (await db - .select() - .from(ttsSegments) - .where(and( - eq(ttsSegments.userId, scope.storageUserId), - eq(ttsSegments.documentId, documentId), - eq(ttsSegments.documentVersion, scope.documentVersion), - eq(ttsSegments.settingsHash, settingsHash), - gte(ttsSegments.segmentIndex, fromIndex), - lte(ttsSegments.segmentIndex, toIndex), - )) - .orderBy(asc(ttsSegments.segmentIndex))) as Array<{ - segmentId: string; - segmentIndex: number; - locatorJson: string | null; - durationMs: number | null; - alignmentJson: string | null; - status: string; - }>; - - const segments: TTSSegmentManifestItem[] = rows.map((row) => ({ - segmentId: row.segmentId, - segmentIndex: row.segmentIndex, - audioUrl: row.status === 'completed' - ? `/api/tts/segments/audio?documentId=${encodeURIComponent(documentId)}&segmentId=${encodeURIComponent(row.segmentId)}` - : '', - durationMs: row.durationMs ?? 0, - alignment: row.alignmentJson ? JSON.parse(row.alignmentJson) : null, - locator: row.locatorJson ? JSON.parse(row.locatorJson) : null, - status: row.status === 'error' ? 'error' : row.status === 'completed' ? 'completed' : 'pending', - })); - - return NextResponse.json({ - documentId, - documentVersion: scope.documentVersion, - settingsHash, - segments, - }); - } catch (error) { - console.error('Error fetching TTS segment manifest:', error); - return NextResponse.json({ error: 'Failed to fetch TTS segment manifest' }, { status: 500 }); - } -} diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index e5cb55c..aecb1e7 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -30,7 +30,6 @@ import toast from 'react-hot-toast'; import { useParams, usePathname } from 'next/navigation'; import { useConfig } from '@/contexts/ConfigContext'; -import { useAudioCache } from '@/hooks/audio/useAudioCache'; import { useVoiceManagement } from '@/hooks/audio/useVoiceManagement'; import { useMediaSession } from '@/hooks/audio/useMediaSession'; import { useAudioContext } from '@/hooks/audio/useAudioContext'; @@ -47,7 +46,6 @@ import type { TTSPageTurnEstimate, TTSPlaybackState, TTSSentenceAlignment, - TTSAudioBuffer, } from '@/types/tts'; import type { TTSRequestHeaders, @@ -100,6 +98,12 @@ interface SetTextOptions { nextText?: string; } +type TTSSegmentPlaybackSource = { + presignUrl: string; + fallbackUrl: string; + manifest: TTSSegmentManifestItem; +}; + const CONTINUATION_LOOKAHEAD = 600; const MAX_CONTINUATION_CARRY_CHARS = 220; const MAX_CONTINUATION_CARRY_WORDS = 40; @@ -323,7 +327,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement // Audio and voice management hooks const audioContext = useAudioContext(); - const audioCache = useAudioCache(AUDIO_CACHE_MAX_ITEMS); const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl, configTTSProvider, configTTSModel); const { authEnabled, @@ -398,7 +401,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement ); // Track pending preload requests - const preloadRequests = useRef>>(new Map()); + const preloadRequests = useRef>>(new Map()); // Track active abort controllers for TTS requests const activeAbortControllers = useRef>(new Set()); // Synchronous guard to prevent duplicate playAudio calls from the main playback effect. @@ -1081,17 +1084,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement } }, [availableVoices, voice, configVoice, configTTSModel]); - /** - * Generates and plays audio for the current sentence - * - * @param {string} sentence - The sentence to generate audio for - * @returns {Promise} The generated audio buffer - */ - const getAudio = useCallback(async ( + const getSegmentPlaybackSource = useCallback(async ( sentence: string, sentenceIndex: number, preload = false, - ): Promise => { + ): Promise => { const alignmentEnabledForCurrentDoc = wordHighlightFeatureEnabled && ((!isEPUB && pdfHighlightEnabled && pdfWordHighlightEnabled) || @@ -1104,13 +1101,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement ttsModel, ); - const cachedAudio = audioCache.get(audioCacheKey); - if (cachedAudio) { - const cachedManifest = segmentManifestCacheRef.current.get(audioCacheKey); - if (alignmentEnabledForCurrentDoc && cachedManifest?.alignment) { + const cachedManifest = segmentManifestCacheRef.current.get(audioCacheKey); + if (cachedManifest?.status === 'completed' && cachedManifest.audioPresignUrl && cachedManifest.audioFallbackUrl) { + if (alignmentEnabledForCurrentDoc && cachedManifest.alignment) { sentenceAlignmentCacheRef.current.set(audioCacheKey, cachedManifest.alignment); } - return cachedAudio; + return { + presignUrl: cachedManifest.audioPresignUrl, + fallbackUrl: cachedManifest.audioFallbackUrl, + manifest: cachedManifest, + }; } if (!documentId) { @@ -1169,25 +1169,19 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement ); const segment = ensured.segments[0]; - if (!segment || segment.status !== 'completed' || !segment.audioUrl) { + if (!segment || segment.status !== 'completed' || !segment.audioPresignUrl || !segment.audioFallbackUrl) { throw new Error('Failed to prepare segment audio'); } - const response = await fetch(segment.audioUrl, { signal: controller.signal }); - if (!response.ok) { - throw new Error(`Segment audio fetch failed: ${response.status}`); - } - const arrayBuffer = await response.arrayBuffer(); - if (arrayBuffer.byteLength === 0) { - throw new Error('Received empty segment audio buffer'); - } - - audioCache.set(audioCacheKey, arrayBuffer); setSegmentManifestCache(audioCacheKey, segment); if (alignmentEnabledForCurrentDoc && segment.alignment) { sentenceAlignmentCacheRef.current.set(audioCacheKey, segment.alignment); } - return arrayBuffer; + return { + presignUrl: segment.audioPresignUrl, + fallbackUrl: segment.audioFallbackUrl, + manifest: segment, + }; } catch (error) { if (error instanceof Error && error.name === 'AbortError') { return undefined; @@ -1234,7 +1228,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement effectiveNativeSpeed, ttsModel, ttsInstructions, - audioCache, openApiKey, openApiBaseUrl, configTTSProvider, @@ -1260,13 +1253,13 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement * * @param {string} sentence - The sentence to process * @param {boolean} [preload=false] - Whether this is a preload request - * @returns {Promise} The URL of the processed audio + * @returns {Promise} Prepared playback source metadata */ const processSentence = useCallback(async ( sentence: string, sentenceIndex: number, preload = false, - ): Promise => { + ): Promise => { if (!audioContext) throw new Error('Audio context not initialized'); const requestKey = `${sentenceIndex}::${sentence}`; @@ -1288,18 +1281,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement // Create the audio processing promise const processPromise = (async () => { try { - const audioBuffer = await getAudio(sentence, sentenceIndex, preload); - if (!audioBuffer) { - // If quota or other handled error returns undefined, ensure we don't throw "No audio data" - // Just return empty string to signal graceful failure/skip - return ''; - } - - // Convert to base64 data URI - const bytes = new Uint8Array(audioBuffer); - const binaryString = bytes.reduce((acc, byte) => acc + String.fromCharCode(byte), ''); - const base64String = btoa(binaryString); - return `data:audio/mp3;base64,${base64String}`; + const source = await getSegmentPlaybackSource(sentence, sentenceIndex, preload); + return source || null; } catch (error) { setIsProcessing(false); throw error; @@ -1320,7 +1303,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement } return processPromise; - }, [audioContext, getAudio]); + }, [audioContext, getSegmentPlaybackSource]); /** * Plays the current sentence with Howl @@ -1338,15 +1321,18 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const MAX_RETRIES = 3; const INITIAL_RETRY_DELAY = 1000; // 1 second - const createHowl = async (retryCount = 0): Promise => { + const createHowl = async ( + retryCount = 0, + useFallbackSource = false, + ): Promise => { let playErrorAttempts = 0; - // Get the processed audio data URI directly from processSentence - const audioDataUri = await processSentence(sentence, sentenceIndex); - if (!audioDataUri) { + const playbackSource = await processSentence(sentence, sentenceIndex); + if (!playbackSource) { // Graceful exit for rate limit / abort / intentionally skipped sentence console.log('Skipping playback for sentence (no audio generated)'); return null; } + const audioUrl = useFallbackSource ? playbackSource.fallbackUrl : playbackSource.presignUrl; // Force unload any previous Howl instance to free up resources if (activeHowl) { @@ -1357,7 +1343,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement let howlFinished = false; return new Howl({ - src: [audioDataUri], + src: [audioUrl], format: ['mp3', 'mpeg'], html5: true, preload: true, @@ -1465,6 +1451,26 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const actualError = error ?? soundId; console.warn(`Error loading audio (attempt ${retryCount + 1}/${MAX_RETRIES}):`, actualError); + // First load failure on presigned URL should fail over to proxy fallback immediately. + if (!useFallbackSource && playbackSource.fallbackUrl && playbackSource.fallbackUrl !== playbackSource.presignUrl) { + try { + this.unload(); + } catch { + // ignore unload errors + } + + try { + const fallbackHowl = await createHowl(retryCount, true); + if (fallbackHowl) { + setActiveHowl(fallbackHowl); + fallbackHowl.play(); + return; + } + } catch (fallbackError) { + console.error('Error switching to fallback segment source:', fallbackError); + } + } + if (retryCount < MAX_RETRIES) { // Calculate exponential backoff delay const delay = INITIAL_RETRY_DELAY * Math.pow(2, retryCount); @@ -1482,7 +1488,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement // Try to create a new Howl instance try { - const retryHowl = await createHowl(retryCount + 1); + const retryHowl = await createHowl(retryCount + 1, useFallbackSource); if (retryHowl) { setActiveHowl(retryHowl); retryHowl.play(); @@ -1693,7 +1699,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement ttsModel, ); - if (audioCache.has(nextKey)) { + if (segmentManifestCacheRef.current.has(nextKey)) { preloadFromOffset(offset + 1); return; } @@ -1742,7 +1748,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement } catch (error) { console.error('Error initiating preload:', error); } - }, [isAtLimit, currentIndex, sentences, audioCache, processSentence, voice, effectiveNativeSpeed, configTTSProvider, ttsModel]); + }, [isAtLimit, currentIndex, sentences, processSentence, voice, effectiveNativeSpeed, configTTSProvider, ttsModel]); /** * Main Playback Driver diff --git a/src/lib/client/api/audiobooks.ts b/src/lib/client/api/audiobooks.ts index 2395308..ba912cb 100644 --- a/src/lib/client/api/audiobooks.ts +++ b/src/lib/client/api/audiobooks.ts @@ -288,23 +288,3 @@ export const ensureTtsSegments = async ( return await response.json(); }; - -export const fetchTtsSegmentManifest = async (params: { - documentId: string; - settingsHash: string; - fromIndex: number; - toIndex: number; -}): Promise => { - const query = new URLSearchParams({ - documentId: params.documentId, - settingsHash: params.settingsHash, - fromIndex: String(params.fromIndex), - toIndex: String(params.toIndex), - }); - const response = await fetch(`/api/tts/segments/manifest?${query.toString()}`); - if (!response.ok) { - const data = await response.json().catch(() => null) as { error?: string } | null; - throw new Error(data?.error || 'Failed to fetch segment manifest'); - } - return await response.json(); -}; diff --git a/src/lib/server/tts/segments-audio.ts b/src/lib/server/tts/segments-audio.ts new file mode 100644 index 0000000..e003cef --- /dev/null +++ b/src/lib/server/tts/segments-audio.ts @@ -0,0 +1,61 @@ +import { and, eq } from 'drizzle-orm'; +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { db } from '@/db'; +import { ttsSegments } from '@/db/schema'; +import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth'; + +export type ResolvedSegmentAudio = { + documentId: string; + segmentId: string; + audioKey: string; +}; + +export function ttsSegmentsS3NotConfiguredResponse(): NextResponse { + return NextResponse.json( + { error: 'TTS segments storage is not configured. Set S3_* environment variables.' }, + { status: 503 }, + ); +} + +export function streamAudioBuffer(buffer: Buffer): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(buffer)); + controller.close(); + }, + }); +} + +export async function resolveCompletedSegmentAudio( + request: NextRequest, +): Promise { + const documentId = (request.nextUrl.searchParams.get('documentId') || '').trim().toLowerCase(); + const segmentId = (request.nextUrl.searchParams.get('segmentId') || '').trim().toLowerCase(); + if (!documentId || !segmentId) { + return NextResponse.json({ error: 'Missing documentId or segmentId' }, { status: 400 }); + } + + const scope = await resolveSegmentDocumentScope(request, documentId); + if (scope instanceof Response) return scope; + + const rows = (await db + .select({ + audioKey: ttsSegments.audioKey, + status: ttsSegments.status, + }) + .from(ttsSegments) + .where(and( + eq(ttsSegments.userId, scope.storageUserId), + eq(ttsSegments.documentId, documentId), + eq(ttsSegments.documentVersion, scope.documentVersion), + eq(ttsSegments.segmentId, segmentId), + ))) as Array<{ audioKey: string | null; status: string }>; + + const row = rows[0]; + if (!row || !row.audioKey || row.status !== 'completed') { + return NextResponse.json({ error: 'Segment audio not found' }, { status: 404 }); + } + + return { documentId, segmentId, audioKey: row.audioKey }; +} diff --git a/src/lib/server/tts/segments-blobstore.ts b/src/lib/server/tts/segments-blobstore.ts index 6bb3c8a..305bd57 100644 --- a/src/lib/server/tts/segments-blobstore.ts +++ b/src/lib/server/tts/segments-blobstore.ts @@ -4,7 +4,8 @@ import { ListObjectsV2Command, PutObjectCommand, } from '@aws-sdk/client-s3'; -import { getS3Config, getS3ProxyClient } from '@/lib/server/storage/s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { getS3Client, getS3Config, getS3ProxyClient } from '@/lib/server/storage/s3'; function isNodeReadableStream(value: unknown): value is NodeJS.ReadableStream { return !!value && typeof value === 'object' && 'on' in value && typeof (value as NodeJS.ReadableStream).on === 'function'; @@ -64,6 +65,22 @@ export async function getTtsSegmentAudioObject(key: string): Promise { return bodyToBuffer(res.Body); } +export async function presignTtsSegmentAudioGet( + key: string, + options?: { expiresInSeconds?: number }, +): Promise { + const cfg = getS3Config(); + const client = getS3Client(); + return getSignedUrl( + client, + new GetObjectCommand({ + Bucket: cfg.bucket, + Key: key, + }), + { expiresIn: Math.max(30, Math.min(options?.expiresInSeconds ?? 300, 3600)) }, + ); +} + export async function deleteTtsSegmentPrefix(prefix: string): Promise { const cfg = getS3Config(); const client = getS3ProxyClient(); diff --git a/src/types/client.ts b/src/types/client.ts index a1985fb..778a59e 100644 --- a/src/types/client.ts +++ b/src/types/client.ts @@ -115,7 +115,8 @@ export interface TTSSegmentsEnsureRequest { export interface TTSSegmentManifestItem { segmentId: string; segmentIndex: number; - audioUrl: string; + audioPresignUrl: string | null; + audioFallbackUrl: string | null; durationMs: number; alignment: TTSSentenceAlignment | null; locator: TTSSegmentLocator | null; @@ -124,7 +125,5 @@ export interface TTSSegmentManifestItem { export interface TTSSegmentsEnsureResponse { documentId: string; - documentVersion: number; - settingsHash: string; segments: TTSSegmentManifestItem[]; }