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 حذوفات

عرض الملف

@@ -66,6 +66,8 @@ VIDEO_PROCESSING_CRF=28
VIDEO_PROCESSING_PRESET=veryfast
VIDEO_PROCESSING_AUDIO_BITRATE_KBPS=128
VIDEO_PROCESSING_GENERATE_THUMBNAILS=true
VIDEO_PROCESSING_GENERATE_HLS=true
VIDEO_PROCESSING_HLS_SEGMENT_DURATION_SECONDS=4
VIDEO_PROCESSING_THUMBNAIL_WIDTH=720
GOOGLE_CLIENT_ID=your_google_client_id

عرض الملف

@@ -18,7 +18,7 @@ CORS_ORIGINS=http://192.168.1.12:3000,http://192.168.1.12:5173
GOOGLE_CALLBACK_URL=http://192.168.1.12:4000/api/v1/auth/google/callback
```
With `PUBLIC_BASE_URL` configured, file fields such as `avatar`, `coverImage`, `imageUrls`, `videoUrl`, `audioUrl`, `thumbnailUrl`, `mediaUrl`, and marketplace images are returned as absolute URLs.
With `PUBLIC_BASE_URL` configured, file fields such as `avatar`, `coverImage`, `imageUrls`, `videoUrl`, `hlsUrl`, `audioUrl`, `thumbnailUrl`, `mediaUrl`, and marketplace images are returned as absolute URLs.
## Pagination contract
@@ -211,11 +211,30 @@ When `VIDEO_PROCESSING_ENABLED=true` and `ffmpeg` is available on the server:
- uploaded post/reel videos are converted to optimized `mp4`
- `+faststart` is applied so playback begins faster on mobile/web
- uploaded post/reel videos also produce an HLS playlist at `hlsUrl` when `VIDEO_PROCESSING_GENERATE_HLS=true`
- local storage responses serve `mp4`, `m3u8`, `m4s`, and `ts` files with explicit media `Content-Type` headers and `Accept-Ranges: bytes`
- a thumbnail image is generated automatically if the client does not send `thumbnailUrl`
If `ffmpeg` is not installed or video processing is disabled, uploads still work and the original
video file is stored as-is.
Recommended client behavior:
- use `hlsUrl` first when present for adaptive/streaming playback
- fall back to `videoUrl` for progressive `mp4` playback
## Audio and image delivery
Managed uploads are stored under stable UUID-based paths, so local storage responses now send cache-friendly headers:
- images (`jpg`, `jpeg`, `png`, `webp`, `gif`) are served with explicit `Content-Type` and long-lived immutable `Cache-Control`
- audio (`mp3`, `wav`, `m4a`, `aac`, `ogg`) is served with explicit `Content-Type`, long-lived immutable `Cache-Control`, and `Accept-Ranges: bytes`
Recommended client behavior:
- for audio, stream `audioUrl` directly and allow byte-range playback/resume
- for images, render `imageUrls` directly and rely on URL-based caching for repeat views
## Marketplace split
Marketplace is now separated from musical instruments at the API contract level:

عرض الملف

@@ -104,6 +104,10 @@ export default () => ({
audioBitrateKbps: Number(process.env.VIDEO_PROCESSING_AUDIO_BITRATE_KBPS ?? 128),
generateThumbnails:
(process.env.VIDEO_PROCESSING_GENERATE_THUMBNAILS ?? 'true').toLowerCase() === 'true',
generateHls: (process.env.VIDEO_PROCESSING_GENERATE_HLS ?? 'true').toLowerCase() === 'true',
hlsSegmentDurationSeconds: Number(
process.env.VIDEO_PROCESSING_HLS_SEGMENT_DURATION_SECONDS ?? 4,
),
thumbnailWidth: Number(process.env.VIDEO_PROCESSING_THUMBNAIL_WIDTH ?? 720),
},
logging: {

عرض الملف

@@ -71,6 +71,8 @@ export const validationSchema = Joi.object({
.default('veryfast'),
VIDEO_PROCESSING_AUDIO_BITRATE_KBPS: Joi.number().min(64).max(320).default(128),
VIDEO_PROCESSING_GENERATE_THUMBNAILS: Joi.boolean().truthy('true').falsy('false').default(true),
VIDEO_PROCESSING_GENERATE_HLS: Joi.boolean().truthy('true').falsy('false').default(true),
VIDEO_PROCESSING_HLS_SEGMENT_DURATION_SECONDS: Joi.number().min(2).max(20).default(4),
VIDEO_PROCESSING_THUMBNAIL_WIDTH: Joi.number().min(160).max(1920).default(720),
LOG_LEVEL: Joi.string().valid('error', 'warn', 'log', 'debug', 'verbose').default('log'),
REQUEST_LOGGING_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true),

عرض الملف

@@ -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;

عرض الملف

@@ -6,13 +6,115 @@ import * as express from 'express';
import { randomUUID } from 'crypto';
import { NextFunction, Request, Response } from 'express';
import { existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { NetworkInterfaceInfo, networkInterfaces } from 'os';
import { extname, join } from 'path';
import { AppModule } from './app.module';
import { ResponseEnvelopeInterceptor } from './common/interceptors/response-envelope.interceptor';
import { AppLoggerService } from './infrastructure/logging/app-logger.service';
import { RedisService } from './infrastructure/redis/redis.service';
import { RedisIoAdapter } from './infrastructure/socket/redis-io.adapter';
const getLocalIpv4Addresses = (): string[] =>
Object.values(networkInterfaces())
.flatMap((entries) => entries ?? [])
.filter((entry): entry is NetworkInterfaceInfo => !!entry)
.filter((entry) => entry.family === 'IPv4' && !entry.internal)
.map((entry) => entry.address);
const isPrivateIpv4Host = (host: string): boolean =>
/^10\./.test(host) || /^192\.168\./.test(host) || /^172\.(1[6-9]|2\d|3[0-1])\./.test(host);
const IMMUTABLE_MEDIA_CACHE_CONTROL = 'public, max-age=31536000, immutable';
const SHORT_MANIFEST_CACHE_CONTROL = 'public, max-age=300, stale-while-revalidate=60';
const getStaticMediaHeaders = (
extension: string,
): {
contentType?: string;
cacheControl?: string;
acceptRanges?: boolean;
} => {
switch (extension) {
case '.jpg':
case '.jpeg':
return {
contentType: 'image/jpeg',
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
};
case '.png':
return {
contentType: 'image/png',
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
};
case '.webp':
return {
contentType: 'image/webp',
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
};
case '.gif':
return {
contentType: 'image/gif',
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
};
case '.mp3':
return {
contentType: 'audio/mpeg',
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
acceptRanges: true,
};
case '.wav':
return {
contentType: 'audio/wav',
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
acceptRanges: true,
};
case '.m4a':
return {
contentType: 'audio/mp4',
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
acceptRanges: true,
};
case '.aac':
return {
contentType: 'audio/aac',
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
acceptRanges: true,
};
case '.ogg':
return {
contentType: 'audio/ogg',
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
acceptRanges: true,
};
case '.mp4':
return {
contentType: 'video/mp4',
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
acceptRanges: true,
};
case '.m3u8':
return {
contentType: 'application/vnd.apple.mpegurl',
cacheControl: SHORT_MANIFEST_CACHE_CONTROL,
acceptRanges: true,
};
case '.m4s':
return {
contentType: 'video/iso.segment',
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
acceptRanges: true,
};
case '.ts':
return {
contentType: 'video/mp2t',
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
acceptRanges: true,
};
default:
return {};
}
};
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule, { bufferLogs: true });
const configService = app.get(ConfigService);
@@ -78,7 +180,26 @@ async function bootstrap(): Promise<void> {
}
if (storageProvider === 'local') {
app.use(`/${storageBasePath}`, express.static(uploadsDir));
app.use(
`/${storageBasePath}`,
express.static(uploadsDir, {
acceptRanges: true,
setHeaders: (res, filePath) => {
const extension = extname(filePath).toLowerCase();
const mediaHeaders = getStaticMediaHeaders(extension);
if (mediaHeaders.contentType) {
res.setHeader('Content-Type', mediaHeaders.contentType);
}
if (mediaHeaders.cacheControl) {
res.setHeader('Cache-Control', mediaHeaders.cacheControl);
}
if (mediaHeaders.acceptRanges) {
res.setHeader('Accept-Ranges', 'bytes');
}
},
}),
);
}
const redisEnabled = configService.get<boolean>('redis.enabled', { infer: true }) ?? false;
@@ -104,11 +225,31 @@ async function bootstrap(): Promise<void> {
const port = configService.get<number>('port', 4000);
const host = configService.get<string>('host', '0.0.0.0');
if (host === '0.0.0.0' && publicBaseUrl.includes('localhost')) {
appLogger.warn(
`PUBLIC_BASE_URL is set to "${publicBaseUrl}". Mobile devices on the LAN will not be able to open uploaded files until this is changed to your machine IP, for example http://192.168.x.x:${port}`,
'Bootstrap',
);
if (host === '0.0.0.0') {
const localIpv4Addresses = getLocalIpv4Addresses();
let publicBaseHost = '';
try {
publicBaseHost = new URL(publicBaseUrl).hostname;
} catch {
publicBaseHost = '';
}
if (publicBaseHost === 'localhost') {
appLogger.warn(
`PUBLIC_BASE_URL is set to "${publicBaseUrl}". Mobile devices on the LAN will not be able to open uploaded files until this is changed to your machine IP, for example http://192.168.x.x:${port}`,
'Bootstrap',
);
} else if (
publicBaseHost &&
isPrivateIpv4Host(publicBaseHost) &&
!localIpv4Addresses.includes(publicBaseHost)
) {
appLogger.warn(
`PUBLIC_BASE_URL is set to "${publicBaseUrl}" but this IP is not assigned to the current machine. Detected local IPv4 addresses: ${localIpv4Addresses.join(', ') || 'none'}. Uploaded files and avatars may not load until PUBLIC_BASE_URL is updated.`,
'Bootstrap',
);
}
}
await app.listen(port, host);

عرض الملف

@@ -50,6 +50,7 @@ type NormalizedPostMediaMetadata = {
type SavedVideoUpload = {
videoUrl: string;
hlsUrl: string;
thumbnailUrl: string;
};
@@ -100,6 +101,7 @@ export class PostsService {
const uploadedImageUrls = await this.saveImageFiles(imageFiles);
const savedVideoUpload = videoFile ? await this.saveVideoUpload(videoFile) : null;
const uploadedVideoUrl = savedVideoUpload?.videoUrl ?? '';
const uploadedHlsUrl = savedVideoUpload?.hlsUrl ?? '';
const uploadedThumbnailUrl = savedVideoUpload?.thumbnailUrl ?? '';
const uploadedAudioUrl = audioFile ? await this.saveMediaFile('audio', audioFile) : '';
const finalImageUrls = uploadedImageUrls.length ? uploadedImageUrls : inputImageUrls;
@@ -127,6 +129,7 @@ export class PostsService {
content: finalContent,
imageUrls: finalImageUrls,
videoUrl: finalVideoUrl,
hlsUrl: uploadedHlsUrl,
audioUrl: finalAudioUrl,
taggedUserIds,
mentionUsernames: mentionResolution.mentionUsernames,
@@ -142,6 +145,7 @@ export class PostsService {
await Promise.all([
...uploadedImageUrls.map((url) => this.deleteManagedPostMedia(url)),
uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(),
uploadedHlsUrl ? this.storageService.deleteContainingDirectory(uploadedHlsUrl) : Promise.resolve(),
uploadedThumbnailUrl ? this.deleteManagedPostMedia(uploadedThumbnailUrl) : Promise.resolve(),
uploadedAudioUrl ? this.deleteManagedPostMedia(uploadedAudioUrl) : Promise.resolve(),
]);
@@ -198,6 +202,7 @@ export class PostsService {
const uploadedImageUrls = await this.saveImageFiles(imageFiles);
const savedVideoUpload = videoFile ? await this.saveVideoUpload(videoFile) : null;
const uploadedVideoUrl = savedVideoUpload?.videoUrl ?? '';
const uploadedHlsUrl = savedVideoUpload?.hlsUrl ?? '';
const uploadedThumbnailUrl = savedVideoUpload?.thumbnailUrl ?? '';
const uploadedAudioUrl = audioFile ? await this.saveMediaFile('audio', audioFile) : '';
const hasImageUpdate = typeof dto.imageUrls !== 'undefined' || imageFiles.length > 0;
@@ -215,6 +220,7 @@ export class PostsService {
? uploadedVideoUrl
: dto.videoUrl ?? ''
: post.videoUrl ?? '';
const nextHlsUrl = hasVideoUpdate ? (videoFile ? uploadedHlsUrl : '') : post.hlsUrl ?? '';
const nextAudioUrl = hasAudioUpdate
? audioFile
? uploadedAudioUrl
@@ -266,6 +272,7 @@ export class PostsService {
...dto,
content: nextContent,
imageUrls: nextImageUrls,
hlsUrl: nextHlsUrl,
taggedUserIds: nextTaggedUserIds,
mentionUsernames: mentionResolution.mentionUsernames,
location: nextLocation,
@@ -287,14 +294,17 @@ export class PostsService {
}
if (hasAudioUpdate && !hasVideoUpdate) {
payload.videoUrl = '';
payload.hlsUrl = '';
}
if (hasImageUpdate) {
payload.videoUrl = '';
payload.audioUrl = '';
payload.hlsUrl = '';
}
if (videoFile) {
payload.videoUrl = uploadedVideoUrl;
payload.hlsUrl = uploadedHlsUrl;
payload.imageUrls = [];
payload.audioUrl = '';
}
@@ -302,6 +312,7 @@ export class PostsService {
payload.audioUrl = uploadedAudioUrl;
payload.imageUrls = [];
payload.videoUrl = '';
payload.hlsUrl = '';
}
let updated: PostDocument | null;
@@ -311,6 +322,7 @@ export class PostsService {
await Promise.all([
...uploadedImageUrls.map((url) => this.deleteManagedPostMedia(url)),
uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(),
uploadedHlsUrl ? this.storageService.deleteContainingDirectory(uploadedHlsUrl) : Promise.resolve(),
uploadedThumbnailUrl ? this.deleteManagedPostMedia(uploadedThumbnailUrl) : Promise.resolve(),
uploadedAudioUrl ? this.deleteManagedPostMedia(uploadedAudioUrl) : Promise.resolve(),
]);
@@ -320,6 +332,7 @@ export class PostsService {
await Promise.all([
...uploadedImageUrls.map((url) => this.deleteManagedPostMedia(url)),
uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(),
uploadedHlsUrl ? this.storageService.deleteContainingDirectory(uploadedHlsUrl) : Promise.resolve(),
uploadedThumbnailUrl ? this.deleteManagedPostMedia(uploadedThumbnailUrl) : Promise.resolve(),
uploadedAudioUrl ? this.deleteManagedPostMedia(uploadedAudioUrl) : Promise.resolve(),
]);
@@ -329,6 +342,9 @@ export class PostsService {
if (hasVideoUpdate && (post.videoUrl ?? '') !== (updated.videoUrl ?? '')) {
await this.deleteManagedPostMedia(post.videoUrl ?? '');
}
if ((post.hlsUrl ?? '') !== (updated.hlsUrl ?? '')) {
await this.storageService.deleteContainingDirectory(post.hlsUrl ?? '');
}
if ((post.thumbnailUrl ?? '') !== (updated.thumbnailUrl ?? '')) {
await this.deleteManagedPostMedia(post.thumbnailUrl ?? '');
}
@@ -369,6 +385,7 @@ export class PostsService {
await Promise.all([
...(post.imageUrls ?? []).map((url) => this.deleteManagedPostMedia(url)),
this.deleteManagedPostMedia(post.videoUrl ?? ''),
this.storageService.deleteContainingDirectory(post.hlsUrl ?? ''),
this.deleteManagedPostMedia(post.audioUrl ?? ''),
]);
await this.usersRepository.incrementPostsCount(userId, -1);
@@ -610,6 +627,7 @@ export class PostsService {
await Promise.all([
...(post.imageUrls ?? []).map((url) => this.deleteManagedPostMedia(url)),
this.deleteManagedPostMedia(post.videoUrl ?? ''),
this.storageService.deleteContainingDirectory(post.hlsUrl ?? ''),
this.deleteManagedPostMedia(post.audioUrl ?? ''),
]);
const authorId = this.extractEntityId(post.authorId);
@@ -918,7 +936,9 @@ export class PostsService {
const extension = this.validateMediaFile('video', optimized.file);
let videoUrl = '';
let hlsUrl = '';
let thumbnailUrl = '';
const hlsFolderName = `stream-${new Types.ObjectId().toString()}`;
try {
videoUrl = await this.storageService.saveFile({
@@ -929,6 +949,14 @@ export class PostsService {
fileNamePrefix: 'video',
});
if (optimized.generatedHls?.files.length) {
const savedHlsFiles = await this.storageService.saveFiles({
folderSegments: ['posts', 'hls', hlsFolderName],
files: optimized.generatedHls.files,
});
hlsUrl = savedHlsFiles[optimized.generatedHls.playlistRelativePath] ?? '';
}
if (optimized.generatedThumbnail) {
thumbnailUrl = await this.storageService.saveFile({
folderSegments: ['posts', 'thumbnails'],
@@ -939,10 +967,11 @@ export class PostsService {
});
}
return { videoUrl, thumbnailUrl };
return { videoUrl, hlsUrl, thumbnailUrl };
} catch (error) {
await Promise.all([
videoUrl ? this.deleteManagedPostMedia(videoUrl) : Promise.resolve(),
hlsUrl ? this.storageService.deleteContainingDirectory(hlsUrl) : Promise.resolve(),
thumbnailUrl ? this.deleteManagedPostMedia(thumbnailUrl) : Promise.resolve(),
]);
throw error;

عرض الملف

@@ -22,6 +22,9 @@ export class Post {
@Prop({ default: '' })
videoUrl!: string;
@Prop({ default: '' })
hlsUrl!: string;
@Prop({ default: '' })
audioUrl!: string;
@@ -134,6 +137,7 @@ PostSchema.index({
const transformManagedPostFiles = (_doc: unknown, ret: any) => {
ret.imageUrls = resolveManagedFileUrls(ret.imageUrls);
ret.videoUrl = resolveManagedFileUrl(ret.videoUrl);
ret.hlsUrl = resolveManagedFileUrl(ret.hlsUrl);
ret.audioUrl = resolveManagedFileUrl(ret.audioUrl);
ret.thumbnailUrl = resolveManagedFileUrl(ret.thumbnailUrl);
return ret;