Use direct signed segment audio URLs in ensure/manifest

This commit is contained in:
Richard R 2026-05-13 23:33:00 -06:00
parent 89391410da
commit 16bf52f889
4 changed files with 133 additions and 27 deletions

View file

@ -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,

View file

@ -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<string, { audioPresignUrl: string | null; audioFallbackUrl: string | null }>();
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<string, Omit<TTSSegmentRow, 'variants'> & {
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({

View file

@ -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<string>;
};
export async function resolveSegmentAudioUrls(
options: ResolveSegmentAudioUrlOptions,
): Promise<TTSSegmentAudioUrls> {
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,
};
}

View file

@ -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',
});
});
});