Add optimized media delivery and HLS support
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled
هذا الالتزام موجود في:
@@ -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;
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم