Harden media health checks and duration extraction
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled

هذا الالتزام موجود في:
boutmoun123
2026-05-31 18:53:07 +03:00
الأصل 1973b8b904
التزام 637782aed6
11 ملفات معدلة مع 587 إضافات و138 حذوفات

عرض الملف

@@ -9,7 +9,7 @@ import {
import { Upload } from '@aws-sdk/lib-storage';
import { randomUUID } from 'crypto';
import { constants } from 'fs';
import { access, mkdir, rm, unlink, writeFile } from 'fs/promises';
import { access, mkdir, rm, stat, unlink, writeFile } from 'fs/promises';
import { dirname, join, posix } from 'path';
@Injectable()
@@ -186,16 +186,25 @@ export class ManagedStorageService implements OnModuleDestroy {
async getHealth(): Promise<Record<string, unknown>> {
const provider = this.getProvider();
const basePath = this.getBasePath();
const publicBaseUrl =
(this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? '').replace(
/\/$/,
'',
);
const storagePublicBaseUrl = (
this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? ''
).replace(/\/$/, '');
const publicBaseUrl = (
this.configService.get<string>('publicBaseUrl', { infer: true }) ?? ''
).replace(/\/$/, '');
const health: Record<string, unknown> = {
provider,
storageProvider: provider,
basePath,
storageBasePath: basePath,
publicPath: `/${basePath}`,
uploadsPublicPath: `/${basePath}`,
publicBaseUrlConfigured: !!publicBaseUrl,
publicBaseUrl,
storagePublicBaseUrlConfigured: !!storagePublicBaseUrl,
storagePublicBaseUrl,
isLocalStorage: provider === 'local',
isS3Configured: provider === 's3' ? this.hasS3Configuration() : false,
s3Configured: provider === 's3' ? this.hasS3Configuration() : undefined,
};
@@ -219,12 +228,20 @@ export class ManagedStorageService implements OnModuleDestroy {
}
}
const exists = await this.pathExists(uploadDir);
const readable = await this.canAccess(uploadDir, constants.R_OK);
health.local = {
runtimePath: uploadDir,
absolutePath: uploadDir,
exists,
writable,
readable: await this.canAccess(uploadDir, constants.R_OK),
readable,
error,
};
health.absolutePath = uploadDir;
health.uploadPathExists = exists;
health.uploadPathReadable = readable;
health.uploadPathWritable = writable;
}
return health;
@@ -235,10 +252,12 @@ export class ManagedStorageService implements OnModuleDestroy {
}
private getProvider(): 'local' | 's3' {
return (this.configService.get<string>('storage.provider', { infer: true }) as
| 'local'
| 's3'
| undefined) ?? 'local';
return (
(this.configService.get<string>('storage.provider', { infer: true }) as
| 'local'
| 's3'
| undefined) ?? 'local'
);
}
private getBasePath(): string {
@@ -251,9 +270,7 @@ export class ManagedStorageService implements OnModuleDestroy {
const normalized = relativePath.replace(/\\/g, '/').replace(/^\/+/, '');
if (
!normalized ||
normalized
.split('/')
.some((segment) => !segment || segment === '.' || segment === '..')
normalized.split('/').some((segment) => !segment || segment === '.' || segment === '..')
) {
throw new BadRequestException('Invalid managed file path');
}
@@ -301,19 +318,16 @@ export class ManagedStorageService implements OnModuleDestroy {
}
private resolvePublicUrl(objectKey: string): string {
const publicBaseUrl =
(this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? '').replace(
/\/$/,
'',
);
const publicBaseUrl = (
this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? ''
).replace(/\/$/, '');
if (publicBaseUrl) {
return `${publicBaseUrl}/${objectKey}`;
}
const endpoint = (this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? '').replace(
/\/$/,
'',
);
const endpoint = (
this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? ''
).replace(/\/$/, '');
const bucket = this.getS3Bucket();
const forcePathStyle =
this.configService.get<boolean>('storage.s3.forcePathStyle', { infer: true }) ?? false;
@@ -341,20 +355,17 @@ export class ManagedStorageService implements OnModuleDestroy {
private resolveS3ObjectKey(fileUrl: string): string | null {
const normalizedUrl = fileUrl.split('?')[0].split('#')[0];
const publicBaseUrl =
(this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? '').replace(
/\/$/,
'',
);
const publicBaseUrl = (
this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? ''
).replace(/\/$/, '');
if (publicBaseUrl && normalizedUrl.startsWith(`${publicBaseUrl}/`)) {
return normalizedUrl.slice(publicBaseUrl.length + 1);
}
const endpoint = (this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? '').replace(
/\/$/,
'',
);
const endpoint = (
this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? ''
).replace(/\/$/, '');
const bucket = this.getS3Bucket();
const forcePathStyle =
this.configService.get<boolean>('storage.s3.forcePathStyle', { infer: true }) ?? false;
@@ -389,6 +400,15 @@ export class ManagedStorageService implements OnModuleDestroy {
}
}
private async pathExists(path: string): Promise<boolean> {
try {
await stat(path);
return true;
} catch {
return false;
}
}
private async deleteS3Prefix(prefix: string): Promise<void> {
const client = this.getS3Client();
const bucket = this.getS3Bucket();

عرض الملف

@@ -0,0 +1,208 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { spawn } from 'child_process';
import { randomUUID } from 'crypto';
import { mkdtemp, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { extname, join } from 'path';
type CommandProbeResult = {
path: string;
available: boolean;
version: string;
error?: string;
};
@Injectable()
export class MediaProbeService {
private readonly logger = new Logger(MediaProbeService.name);
private readonly commandProbeCache = new Map<string, CommandProbeResult>();
constructor(private readonly configService: ConfigService) {}
getFfmpegPath(): string {
return (
this.configService.get<string>('videoProcessing.ffmpegPath', { infer: true }) ?? 'ffmpeg'
).trim();
}
getFfprobePath(): string {
const configured = (
this.configService.get<string>('videoProcessing.ffprobePath', { infer: true }) ?? ''
).trim();
if (configured) {
return configured;
}
const ffmpegPath = this.getFfmpegPath();
if (/ffmpeg(?:\.exe)?$/i.test(ffmpegPath)) {
return ffmpegPath.replace(/ffmpeg(?:\.exe)?$/i, (match) =>
match.toLowerCase().endsWith('.exe') ? 'ffprobe.exe' : 'ffprobe',
);
}
return 'ffprobe';
}
async checkFfmpeg(): Promise<CommandProbeResult> {
return this.checkCommand(this.getFfmpegPath());
}
async checkFfprobe(): Promise<CommandProbeResult> {
return this.checkCommand(this.getFfprobePath());
}
async extractDurationSecondsFromBuffer(
buffer: Buffer,
options: { originalname?: string; mimetype?: string } = {},
): Promise<number | null> {
if (!buffer.length) {
return null;
}
const workingDir = await mkdtemp(join(tmpdir(), 'oudelaa-media-probe-'));
const inputPath = join(
workingDir,
`input-${randomUUID()}${this.resolveInputExtension(options)}`,
);
try {
await writeFile(inputPath, buffer);
return await this.extractDurationSeconds(inputPath);
} catch (error) {
this.logger.warn(
`Media duration extraction failed for "${options.originalname ?? 'upload'}": ${
error instanceof Error ? error.message : 'unknown error'
}`,
);
return null;
} finally {
await rm(workingDir, { recursive: true, force: true });
}
}
async extractDurationSeconds(filePath: string): Promise<number | null> {
const ffprobe = await this.checkFfprobe();
if (!ffprobe.available) {
this.logger.warn(
`ffprobe is unavailable at "${ffprobe.path}"; media duration extraction skipped`,
);
return null;
}
try {
const stdout = await this.runCommand(
ffprobe.path,
[
'-v',
'error',
'-show_entries',
'format=duration',
'-of',
'default=noprint_wrappers=1:nokey=1',
filePath,
],
5000,
);
const seconds = Number.parseFloat(stdout.trim());
if (!Number.isFinite(seconds) || seconds <= 0) {
return null;
}
return Math.round(seconds);
} catch (error) {
this.logger.warn(
`ffprobe duration check failed: ${error instanceof Error ? error.message : 'unknown error'}`,
);
return null;
}
}
private async checkCommand(command: string): Promise<CommandProbeResult> {
const cached = this.commandProbeCache.get(command);
if (cached) {
return cached;
}
try {
const stdout = await this.runCommand(command, ['-version'], 3000);
const version = stdout.split(/\r?\n/)[0]?.trim() ?? '';
const result = { path: command, available: true, version };
this.commandProbeCache.set(command, result);
return result;
} catch (error) {
const result = {
path: command,
available: false,
version: '',
error: error instanceof Error ? error.message : 'unknown command error',
};
this.commandProbeCache.set(command, result);
return result;
}
}
private resolveInputExtension(options: { originalname?: string; mimetype?: string }): string {
const extension = extname(options.originalname ?? '').toLowerCase();
if (extension) {
return extension;
}
switch (options.mimetype) {
case 'video/mp4':
return '.mp4';
case 'video/quicktime':
return '.mov';
case 'video/webm':
return '.webm';
case 'audio/mpeg':
return '.mp3';
case 'audio/mp4':
case 'audio/x-m4a':
return '.m4a';
case 'audio/wav':
case 'audio/x-wav':
return '.wav';
case 'audio/aac':
return '.aac';
case 'audio/ogg':
return '.ogg';
default:
return '.media';
}
}
private async runCommand(command: string, args: string[], timeoutMs: number): Promise<string> {
return new Promise<string>((resolve, reject) => {
const child = spawn(command, args, { windowsHide: true });
let stdout = '';
let stderr = '';
const timeout = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error(`${command} timed out after ${timeoutMs}ms`));
}, timeoutMs);
child.stdout.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('error', (error) => {
clearTimeout(timeout);
reject(error);
});
child.on('close', (code) => {
clearTimeout(timeout);
if (code === 0) {
resolve(stdout);
return;
}
reject(new Error(stderr.trim() || `${command} exited with code ${code ?? 'unknown'}`));
});
});
}
}

عرض الملف

@@ -1,11 +1,22 @@
import { Global, Module } from '@nestjs/common';
import { ImageProcessingService } from './image-processing.service';
import { ManagedStorageService } from './managed-storage.service';
import { MediaProbeService } from './media-probe.service';
import { VideoProcessingService } from './video-processing.service';
@Global()
@Module({
providers: [ManagedStorageService, VideoProcessingService, ImageProcessingService],
exports: [ManagedStorageService, VideoProcessingService, ImageProcessingService],
providers: [
ManagedStorageService,
VideoProcessingService,
ImageProcessingService,
MediaProbeService,
],
exports: [
ManagedStorageService,
VideoProcessingService,
ImageProcessingService,
MediaProbeService,
],
})
export class StorageModule {}

عرض الملف

@@ -1,4 +1,4 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { spawn } from 'child_process';
import { randomUUID } from 'crypto';
@@ -165,9 +165,7 @@ export class VideoProcessingService {
error instanceof Error ? error.message : 'unknown error'
}`,
);
throw new BadRequestException(
'Video optimization failed. Upload an MP4/WebM video or disable server-side video processing.',
);
return { file };
} finally {
await rm(workingDir, { recursive: true, force: true });
}
@@ -453,7 +451,11 @@ export class VideoProcessingService {
}
private buildHlsRenditions(sourceWidth: number): HlsRendition[] {
const requestedWidths = [Math.min(480, this.getMaxWidth()), Math.min(720, this.getMaxWidth()), this.getMaxWidth()];
const requestedWidths = [
Math.min(480, this.getMaxWidth()),
Math.min(720, this.getMaxWidth()),
this.getMaxWidth(),
];
const widths = Array.from(
new Set(
requestedWidths