All checks were successful
Build and Push Docker Image / docker (push) Successful in 9s
219 lines
6.6 KiB
TypeScript
219 lines
6.6 KiB
TypeScript
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 {
|
|
// 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');
|
|
|
|
// iOS Safari specific headers to improve streaming
|
|
ctx.response.headers.set('Access-Control-Allow-Origin', '*');
|
|
ctx.response.headers.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
|
|
ctx.response.headers.set('Access-Control-Allow-Headers', 'Range');
|
|
ctx.response.headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range, Accept-Ranges');
|
|
}
|
|
}
|