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

View File

@@ -0,0 +1,70 @@
import { LRUCache } from 'lru-cache';
import { parseFile } from 'music-metadata';
import type { CoverArt } from '../domain/types.ts';
import { COVER_CACHE_MAX_ITEMS, COVER_CACHE_MAX_BYTES } from '../shared/constants.ts';
import { logger } from '../shared/logger.ts';
/**
* Cover art caching service
*/
export class CoverArtService {
private readonly coverCache: LRUCache<string, CoverArt>;
constructor() {
this.coverCache = new LRUCache<string, CoverArt>({
max: COVER_CACHE_MAX_ITEMS,
maxSize: COVER_CACHE_MAX_BYTES,
sizeCalculation: (value) => value.buf.length,
ttl: 5 * 60 * 1000, // 5 minutes
ttlAutopurge: true,
});
}
/**
* Get cover art from audio file
*/
async getCoverArt(filePath: string): Promise<CoverArt | null> {
// Check cache first
const cached = this.coverCache.get(filePath);
if (cached) {
return cached;
}
try {
// Parse metadata from audio file
const metadata = await parseFile(filePath, { duration: false });
const picture = metadata.common?.picture?.[0];
if (!picture?.data) {
return null;
}
const coverArt: CoverArt = {
mime: picture.format || 'image/jpeg',
buf: new Uint8Array(picture.data),
};
// Cache the result
this.coverCache.set(filePath, coverArt);
return coverArt;
} catch (error) {
logger.error(`Failed to extract cover art from ${filePath}: ${error}`);
return null;
}
}
/**
* Clear cache
*/
clearCache(): void {
this.coverCache.clear();
}
/**
* Get cache size
*/
getCacheSize(): number {
return this.coverCache.size;
}
}

View File

@@ -0,0 +1,186 @@
import { join, resolve, normalize } from '@std/path';
import { exists } from '@std/fs';
import type { AppConfig } from '../shared/config.ts';
import { NotFoundError, ValidationError } from '../shared/errors.ts';
/**
* File system service for managing file paths and operations
*/
export class FileSystemService {
private readonly dataDir: string;
constructor(private readonly config: AppConfig) {
this.dataDir = resolve(config.dataDir);
}
/**
* Resolve a safe path within the data directory (prevent path traversal)
*/
resolveSafePath(name: string): string {
if (!name || typeof name !== 'string') {
throw new ValidationError('Invalid path name');
}
const joined = join(this.dataDir, name);
const resolved = resolve(joined);
// Normalize both paths for consistent comparison
const normalizedDataDir = normalize(this.dataDir);
const normalizedResolved = normalize(resolved);
// Ensure resolved path is within data directory
// Check if paths are equal or if resolved starts with dataDir
if (normalizedResolved === normalizedDataDir) {
return resolved;
}
// Check if the resolved path is a subdirectory of dataDir
// Add separator to prevent partial directory name matches (e.g., /data vs /data2)
const dataDirWithSep = normalizedDataDir + (normalizedDataDir.endsWith('\\') || normalizedDataDir.endsWith('/') ? '' : '\\');
const dataDirWithSepAlt = normalizedDataDir + (normalizedDataDir.endsWith('\\') || normalizedDataDir.endsWith('/') ? '' : '/');
if (normalizedResolved.startsWith(dataDirWithSep) || normalizedResolved.startsWith(dataDirWithSepAlt)) {
return resolved;
}
throw new ValidationError('Path traversal detected');
}
/**
* Check if file exists
*/
async fileExists(path: string): Promise<boolean> {
try {
return await exists(path);
} catch {
return false;
}
}
/**
* Get file stat information
*/
async statFile(path: string): Promise<Deno.FileInfo> {
try {
return await Deno.stat(path);
} catch (error) {
throw new NotFoundError(`File not found: ${path}`);
}
}
/**
* Read file as text
*/
async readTextFile(path: string): Promise<string> {
try {
return await Deno.readTextFile(path);
} catch (error) {
throw new NotFoundError(`Cannot read file: ${path}`);
}
}
/**
* Read file as JSON
*/
async readJsonFile<T>(path: string, fallback?: T): Promise<T> {
try {
const text = await this.readTextFile(path);
return JSON.parse(text);
} catch (error) {
if (fallback !== undefined) {
return fallback;
}
throw new NotFoundError(`Cannot read JSON file: ${path}`);
}
}
/**
* Write text to file
*/
async writeTextFile(path: string, content: string): Promise<void> {
try {
await Deno.writeTextFile(path, content);
} catch (error) {
throw new Error(`Cannot write file: ${path}`);
}
}
/**
* Write JSON to file
*/
async writeJsonFile(path: string, data: unknown): Promise<void> {
const content = JSON.stringify(data, null, 2);
await this.writeTextFile(path, content);
}
/**
* List files in directory
*/
async listFiles(dirPath: string, pattern?: RegExp): Promise<string[]> {
const files: string[] = [];
try {
for await (const entry of Deno.readDir(dirPath)) {
if (entry.isFile) {
if (!pattern || pattern.test(entry.name)) {
files.push(entry.name);
}
}
}
} catch (error) {
throw new NotFoundError(`Cannot list directory: ${dirPath}`);
}
return files;
}
/**
* List subdirectories
*/
async listDirectories(dirPath: string): Promise<string[]> {
const dirs: string[] = [];
try {
for await (const entry of Deno.readDir(dirPath)) {
if (entry.isDirectory) {
dirs.push(entry.name);
}
}
} catch (error) {
throw new NotFoundError(`Cannot list directory: ${dirPath}`);
}
return dirs;
}
/**
* Get playlist directory path
*/
getPlaylistDir(playlistId: string = 'default'): string {
return playlistId === 'default'
? this.dataDir
: join(this.dataDir, playlistId);
}
/**
* Get years.json path for playlist
*/
getYearsPath(playlistId: string = 'default'): string {
const dir = this.getPlaylistDir(playlistId);
return join(dir, 'years.json');
}
/**
* Get data directory
*/
getDataDir(): string {
return this.dataDir;
}
/**
* Get public directory
*/
getPublicDir(): string {
return resolve(this.config.publicDir);
}
}

View File

@@ -0,0 +1,157 @@
import { parseFile } from 'music-metadata';
import { join } from '@std/path';
import type { Track, YearMetadata, YearsIndex } from '../domain/types.ts';
import { FileSystemService } from './FileSystemService.ts';
import { AUDIO_EXTENSIONS, PREFERRED_EXTENSION, BATCH_SIZE } from '../shared/constants.ts';
import { logger } from '../shared/logger.ts';
/**
* Metadata service for parsing audio file metadata
*/
export class MetadataService {
constructor(private readonly fileSystem: FileSystemService) {}
/**
* Load years index from years.json
*/
async loadYearsIndex(playlistId: string = 'default'): Promise<Record<string, YearMetadata>> {
const yearsPath = this.fileSystem.getYearsPath(playlistId);
try {
const yearsData = await this.fileSystem.readJsonFile<YearsIndex>(yearsPath);
if (yearsData && yearsData.byFile && typeof yearsData.byFile === 'object') {
return yearsData.byFile;
}
} catch (error) {
logger.debug(`No years.json found for playlist '${playlistId}' at ${yearsPath}`);
}
return {};
}
/**
* Parse audio file metadata
*/
async parseAudioMetadata(filePath: string): Promise<{
title?: string;
artist?: string;
}> {
try {
const metadata = await parseFile(filePath, { duration: false });
return {
title: metadata.common.title,
artist: metadata.common.artist,
};
} catch (error) {
logger.error(`Failed to parse metadata from ${filePath}: ${error}`);
return {};
}
}
/**
* Load tracks from a playlist directory
*/
async loadTracksFromPlaylist(playlistId: string = 'default'): Promise<Track[]> {
const yearsIndex = await this.loadYearsIndex(playlistId);
const playlistDir = this.fileSystem.getPlaylistDir(playlistId);
// Check if directory exists
if (!await this.fileSystem.fileExists(playlistDir)) {
logger.error(`Playlist directory not found: ${playlistDir}`);
return [];
}
// Get audio files
const audioPattern = new RegExp(`(${AUDIO_EXTENSIONS.join('|').replace(/\./g, '\\.')})$`, 'i');
const files = await this.fileSystem.listFiles(playlistDir, audioPattern);
if (files.length === 0) {
logger.warn(`No audio files found in playlist: ${playlistId}`);
return [];
}
// Deduplicate files (prefer .opus)
const uniqueFiles = this.deduplicateFiles(files);
// Process in batches to avoid "too many open files" error
const tracks: Track[] = [];
for (let i = 0; i < uniqueFiles.length; i += BATCH_SIZE) {
const batch = uniqueFiles.slice(i, i + BATCH_SIZE);
const batchTracks = await Promise.all(
batch.map((fileName) => this.createTrackFromFile(fileName, playlistId, playlistDir, yearsIndex))
);
tracks.push(...batchTracks);
}
return tracks;
}
/**
* Create track object from file
*/
private async createTrackFromFile(
fileName: string,
playlistId: string,
playlistDir: string,
yearsIndex: Record<string, YearMetadata>
): Promise<Track> {
const filePath = join(playlistDir, fileName);
// For file references, include playlist subdirectory if not default
const relativeFile = playlistId === 'default' ? fileName : join(playlistId, fileName);
// Get metadata from years.json first (priority)
const jsonMeta = yearsIndex[fileName];
let year = jsonMeta?.year ?? null;
let title = jsonMeta?.title ?? this.getFileNameWithoutExt(fileName);
let artist = jsonMeta?.artist ?? '';
// Parse audio file metadata if JSON doesn't have title or artist
if (!jsonMeta || !jsonMeta.title || !jsonMeta.artist) {
const audioMeta = await this.parseAudioMetadata(filePath);
title = jsonMeta?.title || audioMeta.title || title;
artist = jsonMeta?.artist || audioMeta.artist || artist;
}
return {
id: relativeFile,
file: relativeFile,
title,
artist,
year,
};
}
/**
* Deduplicate files, preferring .opus versions
*/
private deduplicateFiles(files: string[]): string[] {
const fileMap = new Map<string, string>();
for (const file of files) {
const baseName = this.getFileNameWithoutExt(file);
const ext = file.slice(file.lastIndexOf('.')).toLowerCase();
const existing = fileMap.get(baseName);
if (!existing) {
fileMap.set(baseName, file);
} else if (ext === PREFERRED_EXTENSION) {
// Prefer .opus version
fileMap.set(baseName, file);
}
}
return Array.from(fileMap.values());
}
/**
* Get filename without extension
*/
private getFileNameWithoutExt(fileName: string): string {
const lastDot = fileName.lastIndexOf('.');
return lastDot > 0 ? fileName.slice(0, lastDot) : fileName;
}
}

View File

@@ -0,0 +1,40 @@
/**
* MIME type utility for determining content types
*/
const MIME_TYPES: Record<string, string> = {
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.m4a': 'audio/mp4',
'.ogg': 'audio/ogg',
'.opus': 'audio/ogg', // Opus in Ogg container
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
};
/**
* Get MIME type from file extension
*/
export function getMimeType(filePath: string, fallback = 'application/octet-stream'): string {
const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase();
return MIME_TYPES[ext] || fallback;
}
/**
* Check if file is an audio file
*/
export function isAudioFile(filePath: string): boolean {
const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase();
return ['.mp3', '.wav', '.m4a', '.ogg', '.opus'].includes(ext);
}
/**
* Check if file is an image file
*/
export function isImageFile(filePath: string): boolean {
const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase();
return ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext);
}

View File

@@ -0,0 +1,72 @@
import { encodeHex } from '@std/encoding/hex';
import { LRUCache } from 'lru-cache';
import type { AudioToken } from '../domain/types.ts';
import { TOKEN_CACHE_MAX_ITEMS, TOKEN_TTL_MS } from '../shared/constants.ts';
/**
* Token store service for managing short-lived audio streaming tokens
*/
export class TokenStoreService {
private readonly tokenCache: LRUCache<string, AudioToken>;
constructor(ttlMs: number = TOKEN_TTL_MS) {
this.tokenCache = new LRUCache<string, AudioToken>({
max: TOKEN_CACHE_MAX_ITEMS,
ttl: ttlMs,
ttlAutopurge: true,
allowStale: false,
updateAgeOnGet: false,
updateAgeOnHas: false,
});
}
/**
* Generate a random token
*/
private generateToken(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return encodeHex(bytes);
}
/**
* Create and store a new token
*/
putToken(value: AudioToken, ttlMs?: number): string {
const token = this.generateToken();
const options = ttlMs ? { ttl: Math.max(1000, ttlMs) } : undefined;
this.tokenCache.set(token, value, options);
return token;
}
/**
* Retrieve token data
*/
getToken(token: string): AudioToken | undefined {
if (!token) return undefined;
return this.tokenCache.get(token);
}
/**
* Remove a token
*/
deleteToken(token: string): boolean {
return this.tokenCache.delete(token);
}
/**
* Clear all tokens
*/
clearAll(): void {
this.tokenCache.clear();
}
/**
* Get cache size
*/
getSize(): number {
return this.tokenCache.size;
}
}

View File

@@ -0,0 +1,9 @@
/**
* Infrastructure layer exports
*/
export { FileSystemService } from './FileSystemService.ts';
export { TokenStoreService } from './TokenStoreService.ts';
export { CoverArtService } from './CoverArtService.ts';
export { MetadataService } from './MetadataService.ts';
export { AudioStreamingService } from './AudioStreamingService.ts';
export { getMimeType, isAudioFile, isImageFile } from './MimeTypeService.ts';