Refactor: Remove audio processing and game state management modules

This commit is contained in:
2025-10-15 23:33:40 +02:00
parent 56d7511bd6
commit 58c668de63
69 changed files with 5836 additions and 1319 deletions

View 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');
}
}