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');
|
||||
}
|
||||
}
|
||||
70
src/server-deno/infrastructure/CoverArtService.ts
Normal file
70
src/server-deno/infrastructure/CoverArtService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
186
src/server-deno/infrastructure/FileSystemService.ts
Normal file
186
src/server-deno/infrastructure/FileSystemService.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
157
src/server-deno/infrastructure/MetadataService.ts
Normal file
157
src/server-deno/infrastructure/MetadataService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
40
src/server-deno/infrastructure/MimeTypeService.ts
Normal file
40
src/server-deno/infrastructure/MimeTypeService.ts
Normal 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);
|
||||
}
|
||||
72
src/server-deno/infrastructure/TokenStoreService.ts
Normal file
72
src/server-deno/infrastructure/TokenStoreService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
9
src/server-deno/infrastructure/mod.ts
Normal file
9
src/server-deno/infrastructure/mod.ts
Normal 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';
|
||||
Reference in New Issue
Block a user