diff --git a/src/app/api/tts/segments/audio/fallback/route.ts b/src/app/api/tts/segments/audio/fallback/route.ts index 9884217..aa62567 100644 --- a/src/app/api/tts/segments/audio/fallback/route.ts +++ b/src/app/api/tts/segments/audio/fallback/route.ts @@ -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 = { + '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); diff --git a/src/lib/server/tts/segments-audio.ts b/src/lib/server/tts/segments-audio.ts index c921407..f080ac9 100644 --- a/src/lib/server/tts/segments-audio.ts +++ b/src/lib/server/tts/segments-audio.ts @@ -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 { diff --git a/src/lib/server/tts/segments-blobstore.ts b/src/lib/server/tts/segments-blobstore.ts index 7410d87..de2d56d 100644 --- a/src/lib/server/tts/segments-blobstore.ts +++ b/src/lib/server/tts/segments-blobstore.ts @@ -46,6 +46,88 @@ async function bodyToBuffer(body: unknown): Promise { throw new Error('Unsupported S3 response body type'); } +function bodyToReadableStream(body: unknown): ReadableStream { + if (!body) { + return new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + } + + if (typeof body === 'object' && body !== null && 'transformToWebStream' in body) { + const maybe = body as { transformToWebStream?: () => ReadableStream }; + if (typeof maybe.transformToWebStream === 'function') { + return maybe.transformToWebStream(); + } + } + + if (body instanceof Uint8Array) { + return new ReadableStream({ + start(controller) { + controller.enqueue(body); + controller.close(); + }, + }); + } + if (ArrayBuffer.isView(body)) { + const view = body as ArrayBufferView; + return new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(view.buffer, view.byteOffset, view.byteLength)); + controller.close(); + }, + }); + } + if (body instanceof ArrayBuffer) { + return new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(body)); + controller.close(); + }, + }); + } + + if (isNodeReadableStream(body)) { + return new ReadableStream({ + 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; + 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 { const cfg = getS3Config(); const client = getS3ProxyClient(); @@ -65,6 +147,29 @@ export async function getTtsSegmentAudioObject(key: string): Promise { return bodyToBuffer(res.Body); } +export async function getTtsSegmentAudioObjectStream( + key: string, + options?: { range?: string }, +): Promise { + 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 }, diff --git a/tests/unit/tts-segments-audio-cache.spec.ts b/tests/unit/tts-segments-audio-cache.spec.ts index 8ef9404..d1ce894 100644 --- a/tests/unit/tts-segments-audio-cache.spec.ts +++ b/tests/unit/tts-segments-audio-cache.spec.ts @@ -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(); + }); });