From 16bf52f889b4396d3753e4b460913913f3c0a08b Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 13 May 2026 23:33:00 -0600 Subject: [PATCH] Use direct signed segment audio URLs in ensure/manifest --- src/app/api/tts/segments/ensure/route.ts | 29 +++++----- src/app/api/tts/segments/manifest/route.ts | 28 +++++----- src/lib/server/tts/segment-audio-urls.ts | 42 +++++++++++++++ tests/unit/tts-segment-audio-urls.spec.ts | 61 ++++++++++++++++++++++ 4 files changed, 133 insertions(+), 27 deletions(-) create mode 100644 src/lib/server/tts/segment-audio-urls.ts create mode 100644 tests/unit/tts-segment-audio-urls.spec.ts diff --git a/src/app/api/tts/segments/ensure/route.ts b/src/app/api/tts/segments/ensure/route.ts index cc23d9a..e6e4789 100644 --- a/src/app/api/tts/segments/ensure/route.ts +++ b/src/app/api/tts/segments/ensure/route.ts @@ -33,6 +33,7 @@ import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tt import { alignAudioWithText } from '@/lib/server/whisper/alignment'; import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config'; import { resolveTtsModelForProvider } from '@/lib/shared/tts-provider-policy'; +import { resolveSegmentAudioUrls } from '@/lib/server/tts/segment-audio-urls'; import type { TTSSegmentInput, TTSSegmentManifestItem, @@ -116,18 +117,6 @@ 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}`, - }; -} - function isAbortLikeError(error: unknown): boolean { const message = error instanceof Error ? error.message @@ -414,11 +403,17 @@ export async function POST(request: NextRequest) { } } + const audioUrls = await resolveSegmentAudioUrls({ + documentId: parsed.documentId, + segmentId: segment.segmentId, + audioKey: existing.audioKey, + }); + manifest.push({ segmentId: segment.segmentId, segmentIndex: segment.original.segmentIndex, segmentKey: segmentKeyForRow, - ...buildSegmentAudioUrls(parsed.documentId, segment.segmentId), + ...audioUrls, durationMs: existing.durationMs ?? 0, alignment, locator, @@ -561,11 +556,17 @@ export async function POST(request: NextRequest) { eq(ttsSegmentVariants.userId, scope.storageUserId), )); + const audioUrls = await resolveSegmentAudioUrls({ + documentId: parsed.documentId, + segmentId: segment.segmentId, + audioKey, + }); + manifest.push({ segmentId: segment.segmentId, segmentIndex: segment.original.segmentIndex, segmentKey: segmentKeyForRow, - ...buildSegmentAudioUrls(parsed.documentId, segment.segmentId), + ...audioUrls, durationMs, alignment, locator: segment.locator, diff --git a/src/app/api/tts/segments/manifest/route.ts b/src/app/api/tts/segments/manifest/route.ts index 6ff9e3d..313a8ec 100644 --- a/src/app/api/tts/segments/manifest/route.ts +++ b/src/app/api/tts/segments/manifest/route.ts @@ -19,6 +19,7 @@ import type { } from '@/types/client'; import { isTtsProviderType } from '@/lib/shared/tts-provider-catalog'; import { resolveEffectiveProviderType } from '@/lib/shared/tts-provider-policy'; +import { resolveSegmentAudioUrls } from '@/lib/server/tts/segment-audio-urls'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -91,18 +92,6 @@ function locatorFromProjection(row: ManifestGroupRow): TTSSegmentLocator | null return null; } -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}`, - }; -} - function isAbortLikeMessage(message: string | null | undefined): boolean { if (!message) return false; return /abort/i.test(message); @@ -297,6 +286,19 @@ export async function GET(request: NextRequest) { updatedAt: number | null; }>; + const audioUrlsBySegmentId = new Map(); + await Promise.all( + variantRows.map(async (row) => { + if (row.status !== 'completed' || !row.audioKey) return; + const urls = await resolveSegmentAudioUrls({ + documentId, + segmentId: row.segmentId, + audioKey: row.audioKey, + }); + audioUrlsBySegmentId.set(row.segmentId, urls); + }), + ); + const grouped = new Map & { variants: Array<{ dedupeKey: string; variant: TTSSegmentVariant }>; }>(); @@ -335,7 +337,7 @@ export async function GET(request: NextRequest) { : 'pending'; const audioUrls = row.status === 'completed' && row.audioKey - ? buildSegmentAudioUrls(documentId, row.segmentId) + ? (audioUrlsBySegmentId.get(row.segmentId) ?? { audioPresignUrl: null, audioFallbackUrl: null }) : { audioPresignUrl: null, audioFallbackUrl: null }; entry.variants.push({ diff --git a/src/lib/server/tts/segment-audio-urls.ts b/src/lib/server/tts/segment-audio-urls.ts new file mode 100644 index 0000000..cc17564 --- /dev/null +++ b/src/lib/server/tts/segment-audio-urls.ts @@ -0,0 +1,42 @@ +import { presignTtsSegmentAudioGet } from '@/lib/server/tts/segments-blobstore'; + +export type TTSSegmentAudioUrls = { + audioPresignUrl: string | null; + audioFallbackUrl: string | null; +}; + +export function buildSegmentAudioFallbackUrl(documentId: string, segmentId: string): string { + const encodedDocumentId = encodeURIComponent(documentId); + const encodedSegmentId = encodeURIComponent(segmentId); + return `/api/tts/segments/audio/fallback?documentId=${encodedDocumentId}&segmentId=${encodedSegmentId}`; +} + +type ResolveSegmentAudioUrlOptions = { + documentId: string; + segmentId: string; + audioKey: string | null; + expiresInSeconds?: number; + presignResolver?: (audioKey: string, options?: { expiresInSeconds?: number }) => Promise; +}; + +export async function resolveSegmentAudioUrls( + options: ResolveSegmentAudioUrlOptions, +): Promise { + const fallbackUrl = buildSegmentAudioFallbackUrl(options.documentId, options.segmentId); + if (!options.audioKey) { + return { + audioPresignUrl: null, + audioFallbackUrl: null, + }; + } + + const presignResolver = options.presignResolver ?? presignTtsSegmentAudioGet; + const directUrl = await presignResolver(options.audioKey, { + expiresInSeconds: options.expiresInSeconds, + }).catch(() => null); + + return { + audioPresignUrl: directUrl ?? fallbackUrl, + audioFallbackUrl: fallbackUrl, + }; +} diff --git a/tests/unit/tts-segment-audio-urls.spec.ts b/tests/unit/tts-segment-audio-urls.spec.ts new file mode 100644 index 0000000..93b8d92 --- /dev/null +++ b/tests/unit/tts-segment-audio-urls.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from '@playwright/test'; +import { + buildSegmentAudioFallbackUrl, + resolveSegmentAudioUrls, +} from '../../src/lib/server/tts/segment-audio-urls'; + +test.describe('tts segment audio url resolution', () => { + test('builds fallback URL with encoded params', () => { + const url = buildSegmentAudioFallbackUrl('doc 1', 'segment/2'); + expect(url).toBe('/api/tts/segments/audio/fallback?documentId=doc%201&segmentId=segment%2F2'); + }); + + test('returns null urls when no audio key is present', async () => { + const urls = await resolveSegmentAudioUrls({ + documentId: 'doc-1', + segmentId: 'seg-1', + audioKey: null, + }); + expect(urls).toEqual({ + audioPresignUrl: null, + audioFallbackUrl: null, + }); + }); + + test('returns direct signed URL when presign succeeds', async () => { + let receivedKey = ''; + let receivedExpiresIn: number | undefined; + const urls = await resolveSegmentAudioUrls({ + documentId: 'doc-1', + segmentId: 'seg-1', + audioKey: 'audio-key-1', + expiresInSeconds: 1200, + presignResolver: async (audioKey, options) => { + receivedKey = audioKey; + receivedExpiresIn = options?.expiresInSeconds; + return 'https://signed.example/audio.mp3'; + }, + }); + expect(receivedKey).toBe('audio-key-1'); + expect(receivedExpiresIn).toBe(1200); + expect(urls).toEqual({ + audioPresignUrl: 'https://signed.example/audio.mp3', + audioFallbackUrl: '/api/tts/segments/audio/fallback?documentId=doc-1&segmentId=seg-1', + }); + }); + + test('falls back to proxy URL when presign fails', async () => { + const urls = await resolveSegmentAudioUrls({ + documentId: 'doc-1', + segmentId: 'seg-1', + audioKey: 'audio-key-1', + presignResolver: async () => { + throw new Error('presign failed'); + }, + }); + expect(urls).toEqual({ + audioPresignUrl: '/api/tts/segments/audio/fallback?documentId=doc-1&segmentId=seg-1', + audioFallbackUrl: '/api/tts/segments/audio/fallback?documentId=doc-1&segmentId=seg-1', + }); + }); +});