first commit
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import express from 'express';
|
||||
import { setRoutes } from './routes/index';
|
||||
import { errorHandler } from './middlewares/errorHandler';
|
||||
import requestLogger from './middlewares/requestLogger';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
app.use(requestLogger);
|
||||
app.use(express.json());
|
||||
|
||||
setRoutes(app);
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server is running on port ${PORT}`);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Request, Response } from 'express';
|
||||
import StreamService from '../services/streamService';
|
||||
|
||||
export default class StreamController {
|
||||
constructor(private streamService: StreamService) { }
|
||||
|
||||
// GET /api/streams/:key
|
||||
async streamAudio(req: Request, res: Response) {
|
||||
const { fileName: key } = req.params as { fileName: string };
|
||||
const range = req.headers.range as string | undefined;
|
||||
if (!key) return res.status(400).send('Missing file key');
|
||||
|
||||
try {
|
||||
const result = await this.streamService.streamFromS3({ key, range });
|
||||
res.writeHead(result.status, result.headers);
|
||||
result.body.pipe(res);
|
||||
} catch (err: any) {
|
||||
const message = err?.message || 'Error streaming audio';
|
||||
const status = err?.statusCode || 500;
|
||||
res.status(status).send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ErrorRequestHandler } from 'express';
|
||||
|
||||
export const errorHandler: ErrorRequestHandler = (err, req, res, _next) => {
|
||||
const message = err?.message || 'An error occurred';
|
||||
const status = (err as any)?.statusCode || 500;
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
console.error(err?.stack || message);
|
||||
}
|
||||
res.status(status).json({ message, error: message });
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
const requestLogger = (req: Request, res: Response, next: NextFunction) => {
|
||||
console.log(`${req.method} ${req.url}`);
|
||||
next();
|
||||
};
|
||||
|
||||
export default requestLogger;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Application, Router, Request, Response } from 'express';
|
||||
import StreamController from '../controllers/streamController';
|
||||
import StreamService from '../services/streamService';
|
||||
|
||||
const router = Router();
|
||||
const streamService = new StreamService();
|
||||
const streamController = new StreamController(streamService);
|
||||
|
||||
export function setRoutes(app: Application) {
|
||||
// Health endpoints
|
||||
app.get('/', (_req: Request, res: Response) => res.status(200).send('OK'));
|
||||
app.get('/healthz', (_req: Request, res: Response) => res.status(200).json({ status: 'ok' }));
|
||||
|
||||
app.use('/api/streams', router);
|
||||
router.get('/:fileName', streamController.streamAudio.bind(streamController));
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import StreamController from '../controllers/streamController';
|
||||
import StreamService from '../services/streamService';
|
||||
|
||||
const router = Router();
|
||||
const streamService = new StreamService();
|
||||
const streamController = new StreamController(streamService);
|
||||
|
||||
router.get('/:fileName', streamController.streamAudio.bind(streamController));
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { S3 } from 'aws-sdk';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
export interface GetObjectStreamResult {
|
||||
stream: Readable;
|
||||
contentLength: number; // length of the returned body
|
||||
totalLength: number; // full object size
|
||||
contentType?: string;
|
||||
contentRange?: string;
|
||||
}
|
||||
|
||||
export default class S3Client {
|
||||
private s3: S3;
|
||||
|
||||
constructor() {
|
||||
this.s3 = new S3({
|
||||
region: process.env.AWS_REGION,
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
||||
s3ForcePathStyle: process.env.S3_FORCE_PATH_STYLE === 'true',
|
||||
endpoint: process.env.S3_ENDPOINT || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async headObject(bucket: string, key: string) {
|
||||
return this.s3.headObject({ Bucket: bucket, Key: key }).promise();
|
||||
}
|
||||
|
||||
async getObjectStream(bucket: string, key: string, range?: { start: number; end: number }): Promise<GetObjectStreamResult> {
|
||||
const head = await this.headObject(bucket, key);
|
||||
const totalLength = head.ContentLength || 0;
|
||||
const contentType = head.ContentType;
|
||||
|
||||
const params: S3.GetObjectRequest = { Bucket: bucket, Key: key };
|
||||
let contentRange: string | undefined;
|
||||
if (range && totalLength > 0) {
|
||||
params.Range = `bytes=${range.start}-${range.end}`;
|
||||
contentRange = `bytes ${range.start}-${range.end}/${totalLength}`;
|
||||
}
|
||||
|
||||
const req = this.s3.getObject(params);
|
||||
const stream = req.createReadStream();
|
||||
|
||||
// When a range is requested, S3 returns partial content length (end-start+1)
|
||||
const contentLength = range && totalLength > 0 ? range.end - range.start + 1 : totalLength;
|
||||
|
||||
return { stream, contentLength, totalLength, contentType, contentRange };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Readable } from 'stream';
|
||||
import S3Client, { GetObjectStreamResult } from './s3Client';
|
||||
import { rangeParser } from '../utils/rangeParser';
|
||||
|
||||
export interface StreamResult {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
body: Readable;
|
||||
}
|
||||
|
||||
export default class StreamService {
|
||||
private s3: S3Client;
|
||||
private bucket: string;
|
||||
private prefix: string;
|
||||
|
||||
constructor(bucket = process.env.S3_BUCKET || '', prefix = process.env.S3_PREFIX || '') {
|
||||
this.s3 = new S3Client();
|
||||
this.bucket = bucket;
|
||||
this.prefix = (prefix || '').replace(/^\/+|\/+$/g, ''); // trim leading/trailing '/'
|
||||
}
|
||||
|
||||
async streamFromS3({ key, range }: { key: string; range?: string }): Promise<StreamResult> {
|
||||
if (!this.bucket) throw new Error('S3_BUCKET not configured');
|
||||
|
||||
const actualKey = this.buildKey(key);
|
||||
|
||||
// Determine byte range
|
||||
let parsedRange: { start: number; end: number } | undefined;
|
||||
// We need object size to parse range properly, so head first
|
||||
const head = await this.s3.headObject(this.bucket, actualKey);
|
||||
const total = head.ContentLength || 0;
|
||||
if (range) {
|
||||
const r = rangeParser(range, total);
|
||||
if (r) parsedRange = r;
|
||||
}
|
||||
|
||||
const { stream, contentLength, totalLength, contentType, contentRange }: GetObjectStreamResult =
|
||||
await this.s3.getObjectStream(this.bucket, actualKey, parsedRange);
|
||||
|
||||
const isPartial = Boolean(parsedRange);
|
||||
const status = isPartial ? 206 : 200;
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': contentType || 'audio/mp4',
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': String(contentLength),
|
||||
};
|
||||
if (isPartial && contentRange) headers['Content-Range'] = contentRange;
|
||||
|
||||
return { status, headers, body: stream };
|
||||
}
|
||||
|
||||
private buildKey(key: string): string {
|
||||
const cleanKey = (key || '').replace(/^\/+/, '');
|
||||
if (!this.prefix) return cleanKey;
|
||||
return `${this.prefix}/${cleanKey}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface AudioFile {
|
||||
key: string;
|
||||
bucket: string;
|
||||
contentType: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface StreamRequest {
|
||||
range: string;
|
||||
audioFile: AudioFile;
|
||||
}
|
||||
|
||||
export interface StreamResponse {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
body: Buffer;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export function rangeParser(range: string, size: number): { start: number; end: number } | null {
|
||||
if (!range) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = range.replace(/bytes=/, '').split('-');
|
||||
const start = parseInt(parts[0], 10);
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : size - 1;
|
||||
|
||||
if (isNaN(start) || isNaN(end) || start > end || start >= size) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
Reference in New Issue
Block a user