Use direct signed segment audio URLs in ensure/manifest
This commit is contained in:
parent
89391410da
commit
16bf52f889
4 changed files with 133 additions and 27 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
42
src/lib/server/tts/segment-audio-urls.ts
Normal file
42
src/lib/server/tts/segment-audio-urls.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
61
tests/unit/tts-segment-audio-urls.spec.ts
Normal file
61
tests/unit/tts-segment-audio-urls.spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue