import { join } from '@std/path'; import type { Context } from '@oak/oak'; import { FileSystemService } from './FileSystemService.ts'; import { TokenStoreService } from './TokenStoreService.ts'; import { getMimeType } from './MimeTypeService.ts'; import { PREFERRED_EXTENSION } from '../shared/constants.ts'; import { NotFoundError, ValidationError } from '../shared/errors.ts'; import { logger } from '../shared/logger.ts'; import type { AudioToken } from '../domain/types.ts'; /** * Audio streaming service for serving audio files */ export class AudioStreamingService { constructor( private readonly fileSystem: FileSystemService, private readonly tokenStore: TokenStoreService, ) {} /** * Create a streaming token for an audio file */ async createAudioToken(fileName: string, ttlMs?: number): Promise { const resolved = this.fileSystem.resolveSafePath(fileName); if (!await this.fileSystem.fileExists(resolved)) { throw new NotFoundError('Audio file not found'); } // Prefer .opus sibling for streaming (better compression) let finalPath = resolved; const ext = resolved.slice(resolved.lastIndexOf('.')).toLowerCase(); if (ext !== PREFERRED_EXTENSION) { const opusCandidate = resolved.slice(0, -ext.length) + PREFERRED_EXTENSION; if (await this.fileSystem.fileExists(opusCandidate)) { finalPath = opusCandidate; } } // Get file info const stat = await this.fileSystem.statFile(finalPath); const mime = getMimeType(finalPath, 'audio/mpeg'); const tokenData: AudioToken = { path: finalPath, mime, size: stat.size, }; return this.tokenStore.putToken(tokenData, ttlMs); } /** * Handle HEAD request for audio streaming */ async handleHeadRequest(ctx: Context, token: string): Promise { const tokenData = this.tokenStore.getToken(token); if (!tokenData) { throw new NotFoundError('Token not found or expired'); } this.setCommonHeaders(ctx); ctx.response.headers.set('Content-Type', tokenData.mime); ctx.response.headers.set('Content-Length', String(tokenData.size)); ctx.response.status = 200; } /** * Handle GET request for audio streaming with range support */ async handleStreamRequest(ctx: Context, token: string): Promise { const tokenData = this.tokenStore.getToken(token); if (!tokenData) { throw new NotFoundError('Token not found or expired'); } const { path: filePath, mime, size } = tokenData; const rangeHeader = ctx.request.headers.get('range'); this.setCommonHeaders(ctx); if (rangeHeader) { await this.streamRange(ctx, filePath, size, mime, rangeHeader); } else { await this.streamFull(ctx, filePath, size, mime); } } /** * Stream full file */ private async streamFull( ctx: Context, filePath: string, size: number, mime: string, ): Promise { ctx.response.status = 200; ctx.response.headers.set('Content-Type', mime); ctx.response.headers.set('Content-Length', String(size)); const file = await Deno.open(filePath, { read: true }); ctx.response.body = file.readable; } /** * Stream file range (for seeking/partial content) */ private async streamRange( ctx: Context, filePath: string, fileSize: number, mime: string, rangeHeader: string, ): Promise { const match = /bytes=(\d+)-(\d+)?/.exec(rangeHeader); if (!match) { throw new ValidationError('Invalid range header'); } let start = parseInt(match[1], 10); let end = match[2] ? parseInt(match[2], 10) : fileSize - 1; // Validate and clamp range if (isNaN(start)) start = 0; if (isNaN(end)) end = fileSize - 1; start = Math.min(Math.max(0, start), Math.max(0, fileSize - 1)); end = Math.min(Math.max(start, end), Math.max(0, fileSize - 1)); if (start > end || start >= fileSize) { ctx.response.status = 416; // Range Not Satisfiable ctx.response.headers.set('Content-Range', `bytes */${fileSize}`); return; } const chunkSize = end - start + 1; ctx.response.status = 206; // Partial Content ctx.response.headers.set('Content-Type', mime); ctx.response.headers.set('Content-Length', String(chunkSize)); ctx.response.headers.set('Content-Range', `bytes ${start}-${end}/${fileSize}`); // Open file and seek to start position const file = await Deno.open(filePath, { read: true }); // Create a readable stream for the range const reader = file.readable.getReader(); const stream = new ReadableStream({ async start(controller) { try { let bytesRead = 0; let totalSkipped = 0; while (totalSkipped < start) { const { value, done } = await reader.read(); if (done) break; const remaining = start - totalSkipped; if (value.length <= remaining) { totalSkipped += value.length; } else { // Partial skip - send remainder const chunk = value.slice(remaining); controller.enqueue(chunk); bytesRead += chunk.length; totalSkipped = start; } } // Read the requested range while (bytesRead < chunkSize) { const { value, done } = await reader.read(); if (done) break; const remaining = chunkSize - bytesRead; if (value.length <= remaining) { controller.enqueue(value); bytesRead += value.length; } else { controller.enqueue(value.slice(0, remaining)); bytesRead += remaining; } } controller.close(); } catch (error) { controller.error(error); } finally { reader.releaseLock(); file.close(); } }, }); ctx.response.body = stream; } /** * Set common caching headers */ private setCommonHeaders(ctx: Context): void { // Allow caching of audio content to prevent redundant range requests // The token system already provides security ctx.response.headers.set('Cache-Control', 'public, max-age=3600'); ctx.response.headers.set('Accept-Ranges', 'bytes'); } }