Refactor: Remove audio processing and game state management modules
This commit is contained in:
210
src/server-deno/infrastructure/AudioStreamingService.ts
Normal file
210
src/server-deno/infrastructure/AudioStreamingService.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 {
|
||||
ctx.response.headers.set('Cache-Control', 'no-store');
|
||||
ctx.response.headers.set('Accept-Ranges', 'bytes');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user