Add optimized media delivery and HLS support
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled

هذا الالتزام موجود في:
2026-05-18 23:57:12 +03:00
الأصل 4912a99b8d
التزام 87adaae04b
9 ملفات معدلة مع 464 إضافات و14 حذوفات

عرض الملف

@@ -1,10 +1,15 @@
import { BadRequestException, Injectable, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3';
import {
DeleteObjectCommand,
DeleteObjectsCommand,
ListObjectsV2Command,
S3Client,
} from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';
import { randomUUID } from 'crypto';
import { mkdir, unlink, writeFile } from 'fs/promises';
import { join, posix } from 'path';
import { mkdir, rm, unlink, writeFile } from 'fs/promises';
import { dirname, join, posix } from 'path';
@Injectable()
export class ManagedStorageService implements OnModuleDestroy {
@@ -48,6 +53,67 @@ export class ManagedStorageService implements OnModuleDestroy {
return `/${objectKey}`;
}
async saveFiles(params: {
folderSegments: string[];
files: Array<{
relativePath: string;
buffer: Buffer;
contentType?: string;
}>;
}): Promise<Record<string, string>> {
if (!params.files.length) {
return {};
}
const provider = this.getProvider();
const basePath = this.getBasePath();
const normalizedSegments = params.folderSegments.map((segment) =>
segment.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''),
);
const entries = params.files.map((file) => {
const relativePath = this.normalizeRelativePath(file.relativePath);
const objectKey = posix.join(basePath, ...normalizedSegments, relativePath);
return {
...file,
relativePath,
objectKey,
};
});
if (provider === 's3') {
const client = this.getS3Client();
const bucket = this.getS3Bucket();
const urls: Record<string, string> = {};
for (const entry of entries) {
const upload = new Upload({
client,
params: {
Bucket: bucket,
Key: entry.objectKey,
Body: entry.buffer,
ContentType: entry.contentType || undefined,
},
});
await upload.done();
urls[entry.relativePath] = this.resolvePublicUrl(entry.objectKey);
}
return urls;
}
const urls: Record<string, string> = {};
for (const entry of entries) {
const targetPath = join(process.cwd(), ...entry.objectKey.split('/'));
await mkdir(dirname(targetPath), { recursive: true });
await writeFile(targetPath, entry.buffer);
urls[entry.relativePath] = `/${entry.objectKey}`;
}
return urls;
}
async deleteFile(fileUrl?: string): Promise<void> {
if (!fileUrl) {
return;
@@ -75,12 +141,47 @@ export class ManagedStorageService implements OnModuleDestroy {
}
try {
await unlink(join(process.cwd(), relativePath.replace(/\//g, '\\')));
await unlink(join(process.cwd(), ...relativePath.split('/')));
} catch {
// Ignore cleanup failures for already-missing files.
}
}
async deleteContainingDirectory(fileUrl?: string): Promise<void> {
if (!fileUrl) {
return;
}
if (this.getProvider() === 's3') {
const objectKey = this.resolveS3ObjectKey(fileUrl);
if (!objectKey) {
return;
}
const prefix = posix.dirname(objectKey).replace(/\/?$/, '/');
const basePrefix = `${this.getBasePath()}/`;
if (!prefix.startsWith(basePrefix) || prefix === basePrefix) {
return;
}
await this.deleteS3Prefix(prefix);
return;
}
const relativePath = this.resolveLocalRelativePath(fileUrl);
if (!relativePath || relativePath.includes('..')) {
return;
}
const directoryPath = dirname(relativePath);
const basePath = this.getBasePath();
if (!directoryPath || directoryPath === '.' || directoryPath === basePath) {
return;
}
await rm(join(process.cwd(), ...directoryPath.split('/')), { recursive: true, force: true });
}
onModuleDestroy(): void {
this.s3Client = null;
}
@@ -98,6 +199,20 @@ export class ManagedStorageService implements OnModuleDestroy {
.replace(/^\/+|\/+$/g, '');
}
private normalizeRelativePath(relativePath: string): string {
const normalized = relativePath.replace(/\\/g, '/').replace(/^\/+/, '');
if (
!normalized ||
normalized
.split('/')
.some((segment) => !segment || segment === '.' || segment === '..')
) {
throw new BadRequestException('Invalid managed file path');
}
return normalized;
}
private getS3Bucket(): string {
const bucket = this.configService.get<string>('storage.s3.bucket', { infer: true }) ?? '';
if (!bucket) {
@@ -207,4 +322,36 @@ export class ManagedStorageService implements OnModuleDestroy {
return null;
}
private async deleteS3Prefix(prefix: string): Promise<void> {
const client = this.getS3Client();
const bucket = this.getS3Bucket();
let continuationToken: string | undefined;
do {
const listed = await client.send(
new ListObjectsV2Command({
Bucket: bucket,
Prefix: prefix,
ContinuationToken: continuationToken,
}),
);
const keys =
listed.Contents?.map((item) => item.Key).filter((key): key is string => !!key) ?? [];
if (keys.length) {
await client.send(
new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
Objects: keys.map((key) => ({ Key: key })),
Quiet: true,
},
}),
);
}
continuationToken = listed.IsTruncated ? listed.NextContinuationToken : undefined;
} while (continuationToken);
}
}

عرض الملف

@@ -2,7 +2,7 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { spawn } from 'child_process';
import { randomUUID } from 'crypto';
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { extname, join } from 'path';
@@ -20,6 +20,14 @@ export type OptimizedVideoResult = {
extension: '.jpg';
contentType: 'image/jpeg';
};
generatedHls?: {
playlistRelativePath: string;
files: Array<{
relativePath: string;
buffer: Buffer;
contentType: string;
}>;
};
};
@Injectable()
@@ -44,6 +52,7 @@ export class VideoProcessingService {
const inputPath = join(workingDir, `input-${randomUUID()}${this.resolveInputExtension(file)}`);
const outputPath = join(workingDir, `optimized-${randomUUID()}.mp4`);
const thumbnailPath = join(workingDir, `thumbnail-${randomUUID()}.jpg`);
const hlsDir = join(workingDir, `hls-${randomUUID()}`);
try {
await writeFile(inputPath, file.buffer);
@@ -121,9 +130,21 @@ export class VideoProcessingService {
}
}
let generatedHls: OptimizedVideoResult['generatedHls'];
if (this.shouldGenerateHls()) {
try {
generatedHls = await this.generateHlsPackage(outputPath, hlsDir);
} catch (error) {
this.logger.warn(
`HLS generation failed: ${error instanceof Error ? error.message : 'unknown error'}`,
);
}
}
return {
file: optimizedFile,
generatedThumbnail,
generatedHls,
};
} catch (error) {
this.logger.warn(
@@ -149,6 +170,10 @@ export class VideoProcessingService {
);
}
private shouldGenerateHls(): boolean {
return this.configService.get<boolean>('videoProcessing.generateHls', { infer: true }) ?? true;
}
private getFfmpegPath(): string {
return (
this.configService.get<string>('videoProcessing.ffmpegPath', { infer: true }) ?? 'ffmpeg'
@@ -163,6 +188,14 @@ export class VideoProcessingService {
return this.configService.get<number>('videoProcessing.thumbnailWidth', { infer: true }) ?? 720;
}
private getHlsSegmentDurationSeconds(): number {
return (
this.configService.get<number>('videoProcessing.hlsSegmentDurationSeconds', {
infer: true,
}) ?? 4
);
}
private getMaxFps(): number {
return this.configService.get<number>('videoProcessing.maxFps', { infer: true }) ?? 30;
}
@@ -218,6 +251,75 @@ export class VideoProcessingService {
}
}
private async generateHlsPackage(
optimizedMp4Path: string,
hlsDir: string,
): Promise<NonNullable<OptimizedVideoResult['generatedHls']>> {
await mkdir(hlsDir, { recursive: true });
await this.runFfmpeg([
'-y',
'-i',
optimizedMp4Path,
'-map',
'0:v:0',
'-map',
'0:a:0?',
'-c:v',
'copy',
'-c:a',
'copy',
'-f',
'hls',
'-hls_time',
String(this.getHlsSegmentDurationSeconds()),
'-hls_playlist_type',
'vod',
'-hls_list_size',
'0',
'-hls_flags',
'independent_segments',
'-hls_segment_type',
'fmp4',
'-hls_fmp4_init_filename',
'init.mp4',
'-hls_segment_filename',
join(hlsDir, 'segment-%03d.m4s'),
join(hlsDir, 'playlist.m3u8'),
]);
const fileNames = (await readdir(hlsDir)).sort((left, right) => left.localeCompare(right));
const files = await Promise.all(
fileNames.map(async (fileName) => ({
relativePath: fileName,
buffer: await readFile(join(hlsDir, fileName)),
contentType: this.resolveStreamingContentType(fileName),
})),
);
return {
playlistRelativePath: 'playlist.m3u8',
files,
};
}
private resolveStreamingContentType(fileName: string): string {
const extension = extname(fileName).toLowerCase();
switch (extension) {
case '.m3u8':
return 'application/vnd.apple.mpegurl';
case '.m4s':
return 'video/iso.segment';
case '.ts':
return 'video/mp2t';
case '.mp4':
return 'video/mp4';
default:
return 'application/octet-stream';
}
}
private async ensureFfmpegAvailable(): Promise<boolean> {
if (this.ffmpegAvailabilityResolved) {
return this.ffmpegAvailable;