Compare commits
10 Commits
c167c8623c
...
7d7dc4eb39
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d7dc4eb39 | |||
| 894c71309b | |||
| 5eec64fffb | |||
| 994da9a4e1 | |||
| 37411bc890 | |||
| aec89c6f5a | |||
| a3e4faa7e9 | |||
| 1991b9b6b6 | |||
| 7f93017686 | |||
| 533b48f4a4 |
@@ -0,0 +1 @@
|
||||
*.log
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
// See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
|
||||
// Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
|
||||
|
||||
// List of extensions which should be recommended for users of this workspace.
|
||||
"recommendations": [
|
||||
|
||||
],
|
||||
// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
|
||||
"unwantedRecommendations": [
|
||||
|
||||
]
|
||||
}
|
||||
Generated
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "minio-audio-streamer",
|
||||
"name": "s3-ffmpeg-stream",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
|
||||
@@ -1,2 +1,17 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.env
|
||||
.DS_Store
|
||||
coverage/
|
||||
.idea/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
.sass-cache/
|
||||
.cache/
|
||||
.next/
|
||||
out/
|
||||
build/
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ARG NODE_VERSION=20-alpine
|
||||
ARG BUN_VERSION=1.0.0
|
||||
|
||||
# FROM node:${NODE_VERSION} AS base
|
||||
FROM oven/bun:latest AS base
|
||||
FROM oven/bun:${BUN_VERSION} AS base
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS deps
|
||||
COPY package.json ./
|
||||
# Install only production dependencies for the runtime image
|
||||
RUN bun install --omit=dev
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --production --no-cache
|
||||
|
||||
FROM base AS build
|
||||
COPY package.json ./
|
||||
# Install all dependencies for building (includes devDeps like typescript)
|
||||
RUN bun install
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --no-cache
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN bun run build
|
||||
@@ -23,8 +20,8 @@ FROM base AS runner
|
||||
ENV NODE_ENV=production
|
||||
USER bun
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --link --from=deps /app/node_modules ./node_modules
|
||||
COPY --link --from=build /app/dist ./dist
|
||||
COPY package.json ./
|
||||
COPY --from=build /app/dist ./dist
|
||||
EXPOSE 3000
|
||||
CMD ["bun", "dist/app.js"]
|
||||
CMD ["bun", "run", "dist/app.js"]
|
||||
|
||||
+238
-922
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: audio-streamer-config
|
||||
name: media-streamer-config
|
||||
data:
|
||||
S3_BUCKET: "zik"
|
||||
AWS_REGION: "us-east-1"
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: s3-audio-streamer
|
||||
name: media-streamer
|
||||
labels:
|
||||
app: s3-audio-streamer
|
||||
app: media-streamer
|
||||
spec:
|
||||
replicas: 2
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: s3-audio-streamer
|
||||
app: media-streamer
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: s3-audio-streamer
|
||||
app: media-streamer
|
||||
spec:
|
||||
containers:
|
||||
- name: s3-audio-streamer
|
||||
image: gitea-http.taildb3494.ts.net/will/s3-audio-streamer
|
||||
- name: media-streamer
|
||||
image: gitea-http.taildb3494.ts.net/will/media-streamer
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
env:
|
||||
@@ -24,41 +24,41 @@ spec:
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: aws-secret
|
||||
key: access-key-id
|
||||
key: AWS_ACCESS_KEY_ID
|
||||
- name: AWS_SECRET_ACCESS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: aws-secret
|
||||
key: secret-access-key
|
||||
key: AWS_SECRET_ACCESS_KEY
|
||||
- name: AWS_REGION
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: audio-streamer-config
|
||||
name: media-streamer-config
|
||||
key: AWS_REGION
|
||||
- name: S3_BUCKET
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: audio-streamer-config
|
||||
name: media-streamer-config
|
||||
key: S3_BUCKET
|
||||
- name: S3_ENDPOINT
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: audio-streamer-config
|
||||
name: media-streamer-config
|
||||
key: S3_ENDPOINT
|
||||
- name: S3_FORCE_PATH_STYLE
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: audio-streamer-config
|
||||
name: media-streamer-config
|
||||
key: S3_FORCE_PATH_STYLE
|
||||
- name: S3_PREFIX
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: audio-streamer-config
|
||||
name: media-streamer-config
|
||||
key: S3_PREFIX
|
||||
- name: PORT
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: audio-streamer-config
|
||||
name: media-streamer-config
|
||||
key: PORT
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
apiVersion: autoscaling/v2beta2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: s3-audio-streamer-hpa
|
||||
namespace: default
|
||||
name: media-streamer-hpa
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: s3-audio-streamer
|
||||
name: media-streamer
|
||||
minReplicas: 1
|
||||
maxReplicas: 10
|
||||
metrics:
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: audio-streamer-ingress
|
||||
name: media-streamer-ingress
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /
|
||||
spec:
|
||||
rules:
|
||||
- host: s3-audio-streamer.192.168.153.243.nip.io
|
||||
- host: media-streamer.192.168.153.243.nip.io
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: s3-audio-streamer
|
||||
name: media-streamer
|
||||
port:
|
||||
number: 3000
|
||||
ingressClassName: nginx
|
||||
|
||||
@@ -2,8 +2,7 @@ apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: aws-secret
|
||||
namespace: default
|
||||
type: Opaque
|
||||
data:
|
||||
access-key-id: bXZ3ZldTc1Q3b2RkdTE0amxuMHk=
|
||||
secret-access-key: Y1dscWVsQTFabGxTU0ZsdU4xTktWRnBxU2s1MFRrbDFNVkpSZW01UlkyaHdkREpCYVRkTmRBbz0=
|
||||
AWS_ACCESS_KEY_ID: ZW1ZeVVERjZlVkJvTnpaU1RYQTRhemhvV2s4Sw==
|
||||
AWS_SECRET_ACCESS_KEY: b1FZQ3Axb2MyRHd5U2RxSkZwVVg3bjgxZU5qV0MyaDVScEpNQU5GNQ==
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: s3-audio-streamer
|
||||
name: media-streamer
|
||||
labels:
|
||||
app: s3-audio-streamer
|
||||
app: media-streamer
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
@@ -11,4 +11,4 @@ spec:
|
||||
targetPort: 3000
|
||||
protocol: TCP
|
||||
selector:
|
||||
app: s3-audio-streamer
|
||||
app: media-streamer
|
||||
|
||||
Generated
+5139
File diff suppressed because it is too large
Load Diff
+14
-15
@@ -4,27 +4,26 @@
|
||||
"description": "A simple application to stream audio files stored in S3.",
|
||||
"main": "dist/app.js",
|
||||
"scripts": {
|
||||
"start": "bun dist/app.js",
|
||||
"start:dev": "ts-node src/app.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"test": "jest"
|
||||
"start": "bun run dist/app.js",
|
||||
"start:dev": "bun run --watch src/app.ts",
|
||||
"build": "bun run build:tsc",
|
||||
"build:tsc": "tsc -p tsconfig.json",
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"aws-sdk": "^2.1234.0",
|
||||
"express": "^4.17.1",
|
||||
"morgan": "^1.10.0"
|
||||
"@aws-sdk/client-s3": "^3.879.0",
|
||||
"express": "^4.18.2",
|
||||
"pino": "^9.9.0",
|
||||
"pino-http": "^10.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/aws-sdk": "^2.7.0",
|
||||
"@types/express": "^4.17.0",
|
||||
"@types/jest": "^26.0.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^20.11.0",
|
||||
"jest": "^26.6.0",
|
||||
"ts-jest": "^26.5.0",
|
||||
"ts-node": "^9.1.1",
|
||||
"typescript": "^4.1.3"
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import express from 'express';
|
||||
import { setRoutes } from './routes/index';
|
||||
import { errorHandler } from './middlewares/errorHandler';
|
||||
import requestLogger from './middlewares/requestLogger';
|
||||
import express from "express";
|
||||
import { setRoutes } from "./routes/index";
|
||||
import { errorHandler } from "./middlewares/errorHandler";
|
||||
import pino from "pino";
|
||||
import pinoHttp from "pino-http";
|
||||
|
||||
const logger = pino({ level: process.env.LOG_LEVEL || "info" });
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
app.use(requestLogger);
|
||||
app.set("logger", logger);
|
||||
app.use(pinoHttp({ logger }));
|
||||
app.use(express.json());
|
||||
|
||||
setRoutes(app);
|
||||
@@ -14,5 +17,5 @@ setRoutes(app);
|
||||
app.use(errorHandler);
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server is running on port ${PORT}`);
|
||||
logger.info(`Server is running on port ${PORT}`);
|
||||
});
|
||||
@@ -1,21 +1,32 @@
|
||||
import { Request, Response } from 'express';
|
||||
import StreamService from '../services/streamService';
|
||||
import { Request, Response } from "express";
|
||||
import StreamService from "../services/streamService";
|
||||
import { Logger } from "pino";
|
||||
|
||||
export default class StreamController {
|
||||
constructor(private streamService: StreamService) { }
|
||||
constructor(
|
||||
private streamService: StreamService,
|
||||
private logger: Logger,
|
||||
) {}
|
||||
|
||||
// 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');
|
||||
if (!key) return res.status(400).send("Missing file key");
|
||||
|
||||
try {
|
||||
this.logger.info(
|
||||
`Attempting to stream file: ${key}, range: ${range || "none"}`,
|
||||
);
|
||||
const result = await this.streamService.streamFromS3({ key, range });
|
||||
this.logger.info(
|
||||
`Streaming successful for ${key}, status: ${result.status}, content-length: ${result.headers["Content-Length"]}`,
|
||||
);
|
||||
res.writeHead(result.status, result.headers);
|
||||
result.body.pipe(res);
|
||||
} catch (err: any) {
|
||||
const message = err?.message || 'Error streaming audio';
|
||||
this.logger.error({ err, key }, `Error streaming audio file ${key}`);
|
||||
const message = err?.message || "Error streaming audio";
|
||||
const status = err?.statusCode || 500;
|
||||
res.status(status).send(message);
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
const requestLogger = (req: Request, res: Response, next: NextFunction) => {
|
||||
console.log(`${req.method} ${req.url}`);
|
||||
next();
|
||||
};
|
||||
|
||||
export default requestLogger;
|
||||
@@ -1,18 +1,28 @@
|
||||
import { Application, Router, Request, Response } from 'express';
|
||||
import StreamController from '../controllers/streamController';
|
||||
import StreamService from '../services/streamService';
|
||||
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.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));
|
||||
const streamService = new StreamService(
|
||||
app.get("logger"),
|
||||
process.env.S3_BUCKET,
|
||||
process.env.S3_PREFIX,
|
||||
);
|
||||
const streamController = new StreamController(
|
||||
streamService,
|
||||
app.get("logger"),
|
||||
);
|
||||
|
||||
router.get("/:fileName", streamController.streamAudio.bind(streamController));
|
||||
app.use("/api/streams", router);
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -1,11 +0,0 @@
|
||||
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;
|
||||
@@ -1,49 +1,113 @@
|
||||
import { S3 } from 'aws-sdk';
|
||||
import { Readable } from 'stream';
|
||||
import {
|
||||
S3Client as AwsS3Client,
|
||||
HeadObjectCommand,
|
||||
GetObjectCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { Readable } from "stream";
|
||||
import { Logger } from "pino";
|
||||
|
||||
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;
|
||||
private s3: AwsS3Client;
|
||||
private logger: Logger;
|
||||
|
||||
constructor() {
|
||||
this.s3 = new S3({
|
||||
constructor(logger: Logger) {
|
||||
this.logger = logger;
|
||||
|
||||
const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
|
||||
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
|
||||
|
||||
if (!accessKeyId || !secretAccessKey) {
|
||||
throw new Error(
|
||||
"AWS credentials not configured. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.",
|
||||
);
|
||||
}
|
||||
|
||||
const config = {
|
||||
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',
|
||||
credentials: {
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
},
|
||||
forcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true",
|
||||
endpoint: process.env.S3_ENDPOINT || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
this.logger.info(
|
||||
{
|
||||
region: config.region,
|
||||
endpoint: config.endpoint,
|
||||
forcePathStyle: config.forcePathStyle,
|
||||
},
|
||||
"S3Client: Initializing with config",
|
||||
);
|
||||
|
||||
this.s3 = new AwsS3Client(config);
|
||||
}
|
||||
|
||||
async headObject(bucket: string, key: string) {
|
||||
return this.s3.headObject({ Bucket: bucket, Key: key }).promise();
|
||||
this.logger.info({ bucket, key }, `S3Client: headObject`);
|
||||
try {
|
||||
const command = new HeadObjectCommand({ Bucket: bucket, Key: key });
|
||||
const result = await this.s3.send(command);
|
||||
this.logger.info(
|
||||
{ size: result.ContentLength, type: result.ContentType },
|
||||
`S3Client: headObject success`,
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error({ error, bucket, key }, `S3Client: headObject error`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
async getObjectStream(
|
||||
bucket: string,
|
||||
key: string,
|
||||
totalLength: number,
|
||||
contentType: string | undefined,
|
||||
range?: { start: number; end: number },
|
||||
): Promise<GetObjectStreamResult> {
|
||||
this.logger.info(
|
||||
{ bucket, key, range: range ? `${range.start}-${range.end}` : "none" },
|
||||
`S3Client: getObjectStream`,
|
||||
);
|
||||
|
||||
const params: S3.GetObjectRequest = { Bucket: bucket, Key: key };
|
||||
const params = {
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Range: undefined as string | undefined,
|
||||
};
|
||||
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();
|
||||
try {
|
||||
const command = new GetObjectCommand(params);
|
||||
const result = await this.s3.send(command);
|
||||
const stream = result.Body as Readable;
|
||||
|
||||
// When a range is requested, S3 returns partial content length (end-start+1)
|
||||
const contentLength = range && totalLength > 0 ? range.end - range.start + 1 : totalLength;
|
||||
const contentLength = result.ContentLength || 0;
|
||||
|
||||
return { stream, contentLength, totalLength, contentType, contentRange };
|
||||
this.logger.info(
|
||||
{ contentLength, totalLength },
|
||||
`S3Client: getObjectStream success`,
|
||||
);
|
||||
return { stream, contentLength, contentType, contentRange };
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
{ error, bucket, key },
|
||||
`S3Client: getObjectStream error`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Readable } from 'stream';
|
||||
import S3Client, { GetObjectStreamResult } from './s3Client';
|
||||
import { rangeParser } from '../utils/rangeParser';
|
||||
import { Readable } from "stream";
|
||||
import S3Client, { GetObjectStreamResult } from "./s3Client";
|
||||
import { rangeParser } from "../utils/rangeParser";
|
||||
import { Logger } from "pino";
|
||||
|
||||
export interface StreamResult {
|
||||
status: number;
|
||||
@@ -12,45 +13,88 @@ export default class StreamService {
|
||||
private s3: S3Client;
|
||||
private bucket: string;
|
||||
private prefix: string;
|
||||
private logger: Logger;
|
||||
|
||||
constructor(bucket = process.env.S3_BUCKET || '', prefix = process.env.S3_PREFIX || '') {
|
||||
this.s3 = new S3Client();
|
||||
constructor(
|
||||
logger: Logger,
|
||||
bucket = process.env.S3_BUCKET || "",
|
||||
prefix = process.env.S3_PREFIX || "",
|
||||
) {
|
||||
this.s3 = new S3Client(logger);
|
||||
this.bucket = bucket;
|
||||
this.prefix = (prefix || '').replace(/^\/+|\/+$/g, ''); // trim leading/trailing '/'
|
||||
this.prefix = (prefix || "").replace(/^\/+|\/+$/g, ""); // trim leading/trailing '/'
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
async streamFromS3({ key, range }: { key: string; range?: string }): Promise<StreamResult> {
|
||||
if (!this.bucket) throw new Error('S3_BUCKET not configured');
|
||||
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);
|
||||
this.logger.info(
|
||||
`StreamService: Attempting to stream from bucket: ${this.bucket}, key: ${actualKey}`,
|
||||
);
|
||||
|
||||
try {
|
||||
// Determine byte range
|
||||
let parsedRange: { start: number; end: number } | undefined;
|
||||
// We need object size to parse range properly, so head first
|
||||
this.logger.info(`StreamService: Getting head object for ${actualKey}`);
|
||||
const head = await this.s3.headObject(this.bucket, actualKey);
|
||||
const total = head.ContentLength || 0;
|
||||
const contentType = head.ContentType;
|
||||
this.logger.info(
|
||||
`StreamService: Object size: ${total}, content-type: ${contentType}`,
|
||||
);
|
||||
|
||||
if (range) {
|
||||
const r = rangeParser(range, total);
|
||||
if (r) parsedRange = r;
|
||||
this.logger.info(
|
||||
`StreamService: Parsed range: ${r ? `${r.start}-${r.end}` : "invalid"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const { stream, contentLength, totalLength, contentType, contentRange }: GetObjectStreamResult =
|
||||
await this.s3.getObjectStream(this.bucket, actualKey, parsedRange);
|
||||
this.logger.info(`StreamService: Getting object stream for ${actualKey}`);
|
||||
const { stream, contentLength, contentRange }: GetObjectStreamResult =
|
||||
await this.s3.getObjectStream(
|
||||
this.bucket,
|
||||
actualKey,
|
||||
total,
|
||||
contentType,
|
||||
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),
|
||||
"Content-Type": contentType || "audio/mp4",
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": String(contentLength),
|
||||
};
|
||||
if (isPartial && contentRange) headers['Content-Range'] = contentRange;
|
||||
if (isPartial && contentRange) headers["Content-Range"] = contentRange;
|
||||
|
||||
this.logger.info(
|
||||
{ headers, status },
|
||||
`StreamService: Successfully prepared stream`,
|
||||
);
|
||||
return { status, headers, body: stream };
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
{ error, actualKey },
|
||||
`StreamService: Error in streamFromS3`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private buildKey(key: string): string {
|
||||
const cleanKey = (key || '').replace(/^\/+/, '');
|
||||
const cleanKey = (key || "").replace(/^\/+/, "");
|
||||
if (!this.prefix) return cleanKey;
|
||||
return `${this.prefix}/${cleanKey}`;
|
||||
}
|
||||
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
import { Logger } from 'pino';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
log: Logger;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,8 @@
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
"rootDir": "./src",
|
||||
"typeRoots": ["./src/types", "./node_modules/@types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "**/*.spec.ts"]
|
||||
|
||||
Reference in New Issue
Block a user