Stream fallback segment audio and support byte-range requests

This commit is contained in:
Richard R 2026-05-14 07:55:48 -06:00
parent da629b3ecd
commit 513128b802
4 changed files with 166 additions and 8 deletions

View file

@ -2,11 +2,11 @@ import { NextRequest, NextResponse } from 'next/server';
import { isS3Configured } from '@/lib/server/storage/s3';
import {
buildSegmentAudioCacheHeaders,
normalizeAudioByteRangeHeader,
resolveCompletedSegmentAudio,
streamAudioBuffer,
ttsSegmentsS3NotConfiguredResponse,
} from '@/lib/server/tts/segments-audio';
import { getTtsSegmentAudioObject } from '@/lib/server/tts/segments-blobstore';
import { getTtsSegmentAudioObjectStream } from '@/lib/server/tts/segments-blobstore';
export const dynamic = 'force-dynamic';
@ -17,12 +17,32 @@ export async function GET(request: NextRequest) {
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',
...buildSegmentAudioCacheHeaders('fallback'),
},
const range = normalizeAudioByteRangeHeader(request.headers.get('range'));
if (request.headers.has('range') && !range) {
return new NextResponse(null, {
status: 416,
headers: {
...buildSegmentAudioCacheHeaders('fallback'),
'Accept-Ranges': 'bytes',
},
});
}
const audio = await getTtsSegmentAudioObjectStream(resolved.audioKey, range ? { range } : undefined);
const headers: Record<string, string> = {
'Content-Type': audio.contentType || 'audio/mpeg',
...buildSegmentAudioCacheHeaders('fallback'),
'Accept-Ranges': audio.acceptRanges || 'bytes',
};
if (audio.contentLength !== null) headers['Content-Length'] = String(audio.contentLength);
if (audio.contentRange) headers['Content-Range'] = audio.contentRange;
if (audio.etag) headers.ETag = audio.etag;
if (audio.lastModified) headers['Last-Modified'] = audio.lastModified.toUTCString();
const status = range ? 206 : 200;
return new NextResponse(audio.stream, {
status: audio.statusCode || status,
headers,
});
} catch (error) {
console.error('Error serving TTS segment audio fallback:', error);

View file

@ -40,6 +40,23 @@ export function buildSegmentAudioCacheHeaders(kind: 'redirect' | 'fallback'): Re
};
}
export function normalizeAudioByteRangeHeader(value: string | null | undefined): string | null {
if (!value) return null;
const normalized = value.trim();
if (!/^bytes=\d*-\d*(,\s*\d*-\d*)*$/.test(normalized)) {
return null;
}
const firstRange = normalized.split(',')[0]?.trim() ?? '';
const [, startRaw = '', endRaw = ''] = /^bytes=(\d*)-(\d*)$/.exec(firstRange) ?? [];
if (!startRaw && !endRaw) {
return null;
}
if (startRaw && endRaw && Number(startRaw) > Number(endRaw)) {
return null;
}
return firstRange;
}
export async function resolveCompletedSegmentAudio(
request: NextRequest,
): Promise<ResolvedSegmentAudio | Response> {

View file

@ -46,6 +46,88 @@ async function bodyToBuffer(body: unknown): Promise<Buffer> {
throw new Error('Unsupported S3 response body type');
}
function bodyToReadableStream(body: unknown): ReadableStream<Uint8Array> {
if (!body) {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.close();
},
});
}
if (typeof body === 'object' && body !== null && 'transformToWebStream' in body) {
const maybe = body as { transformToWebStream?: () => ReadableStream<Uint8Array> };
if (typeof maybe.transformToWebStream === 'function') {
return maybe.transformToWebStream();
}
}
if (body instanceof Uint8Array) {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(body);
controller.close();
},
});
}
if (ArrayBuffer.isView(body)) {
const view = body as ArrayBufferView;
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
controller.close();
},
});
}
if (body instanceof ArrayBuffer) {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(body));
controller.close();
},
});
}
if (isNodeReadableStream(body)) {
return new ReadableStream<Uint8Array>({
start(controller) {
body.on('data', (chunk) => {
if (Buffer.isBuffer(chunk)) {
controller.enqueue(new Uint8Array(chunk));
return;
}
if (typeof chunk === 'string') {
controller.enqueue(new Uint8Array(Buffer.from(chunk)));
return;
}
controller.enqueue(new Uint8Array(chunk as Uint8Array));
});
body.on('end', () => controller.close());
body.on('error', (err) => controller.error(err));
},
cancel() {
const nodeBody = body as NodeJS.ReadableStream & { destroy?: () => void };
if (typeof nodeBody.destroy === 'function') {
nodeBody.destroy();
}
},
});
}
throw new Error('Unsupported S3 response body type');
}
export type TtsSegmentAudioObjectStream = {
stream: ReadableStream<Uint8Array>;
contentType: string | null;
contentLength: number | null;
contentRange: string | null;
acceptRanges: string | null;
etag: string | null;
lastModified: Date | null;
statusCode: number;
};
export async function putTtsSegmentAudioObject(key: string, buffer: Buffer): Promise<void> {
const cfg = getS3Config();
const client = getS3ProxyClient();
@ -65,6 +147,29 @@ export async function getTtsSegmentAudioObject(key: string): Promise<Buffer> {
return bodyToBuffer(res.Body);
}
export async function getTtsSegmentAudioObjectStream(
key: string,
options?: { range?: string },
): Promise<TtsSegmentAudioObjectStream> {
const cfg = getS3Config();
const client = getS3ProxyClient();
const res = await client.send(new GetObjectCommand({
Bucket: cfg.bucket,
Key: key,
...(options?.range ? { Range: options.range } : {}),
}));
return {
stream: bodyToReadableStream(res.Body),
contentType: res.ContentType ?? null,
contentLength: typeof res.ContentLength === 'number' ? res.ContentLength : null,
contentRange: typeof res.ContentRange === 'string' ? res.ContentRange : null,
acceptRanges: typeof res.AcceptRanges === 'string' ? res.AcceptRanges : null,
etag: typeof res.ETag === 'string' ? res.ETag : null,
lastModified: res.LastModified ?? null,
statusCode: res.$metadata.httpStatusCode ?? (options?.range ? 206 : 200),
};
}
export async function presignTtsSegmentAudioGet(
key: string,
options?: { expiresInSeconds?: number },

View file

@ -1,6 +1,7 @@
import { expect, test } from '@playwright/test';
import {
buildSegmentAudioCacheHeaders,
normalizeAudioByteRangeHeader,
TTS_SEGMENT_AUDIO_VARY,
TTS_SEGMENT_FALLBACK_CACHE_CONTROL,
TTS_SEGMENT_REDIRECT_CACHE_CONTROL,
@ -20,4 +21,19 @@ test.describe('tts segment audio cache headers', () => {
Vary: TTS_SEGMENT_AUDIO_VARY,
});
});
test('normalizes valid byte range header', () => {
expect(normalizeAudioByteRangeHeader('bytes=0-1023')).toBe('bytes=0-1023');
expect(normalizeAudioByteRangeHeader(' bytes=2048- ')).toBe('bytes=2048-');
expect(normalizeAudioByteRangeHeader('bytes=-512')).toBe('bytes=-512');
});
test('rejects invalid byte range header', () => {
expect(normalizeAudioByteRangeHeader(null)).toBeNull();
expect(normalizeAudioByteRangeHeader('')).toBeNull();
expect(normalizeAudioByteRangeHeader('items=0-10')).toBeNull();
expect(normalizeAudioByteRangeHeader('bytes=-')).toBeNull();
expect(normalizeAudioByteRangeHeader('bytes=20-10')).toBeNull();
expect(normalizeAudioByteRangeHeader('bytes=abc-def')).toBeNull();
});
});