Stream fallback segment audio and support byte-range requests
This commit is contained in:
parent
da629b3ecd
commit
513128b802
4 changed files with 166 additions and 8 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue