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.
This commit is contained in:
Richard R 2026-05-04 12:04:24 -06:00
parent 0a7d392d77
commit 7d7e933323
10 changed files with 225 additions and 233 deletions

View file

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

View file

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

View file

@ -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<Uint8Array> {
return new ReadableStream<Uint8Array>({
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 });
}
}

View file

@ -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);

View file

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

View file

@ -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<Map<string, Promise<string>>>(new Map());
const preloadRequests = useRef<Map<string, Promise<TTSSegmentPlaybackSource | null>>>(new Map());
// Track active abort controllers for TTS requests
const activeAbortControllers = useRef<Set<AbortController>>(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<TTSAudioBuffer | undefined>} The generated audio buffer
*/
const getAudio = useCallback(async (
const getSegmentPlaybackSource = useCallback(async (
sentence: string,
sentenceIndex: number,
preload = false,
): Promise<TTSAudioBuffer | undefined> => {
): Promise<TTSSegmentPlaybackSource | undefined> => {
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<string>} The URL of the processed audio
* @returns {Promise<TTSSegmentPlaybackSource | null>} Prepared playback source metadata
*/
const processSentence = useCallback(async (
sentence: string,
sentenceIndex: number,
preload = false,
): Promise<string> => {
): Promise<TTSSegmentPlaybackSource | null> => {
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<Howl | null> => {
const createHowl = async (
retryCount = 0,
useFallbackSource = false,
): Promise<Howl | null> => {
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

View file

@ -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<TTSSegmentsEnsureResponse> => {
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();
};

View file

@ -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<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(buffer));
controller.close();
},
});
}
export async function resolveCompletedSegmentAudio(
request: NextRequest,
): Promise<ResolvedSegmentAudio | Response> {
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 };
}

View file

@ -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<Buffer> {
return bodyToBuffer(res.Body);
}
export async function presignTtsSegmentAudioGet(
key: string,
options?: { expiresInSeconds?: number },
): Promise<string> {
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<number> {
const cfg = getS3Config();
const client = getS3ProxyClient();

View file

@ -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[];
}