Add video optimization and following-first feed
هذا الالتزام موجود في:
@@ -94,6 +94,18 @@ export default () => ({
|
||||
(process.env.S3_FORCE_PATH_STYLE ?? 'false').toLowerCase() === 'true',
|
||||
},
|
||||
},
|
||||
videoProcessing: {
|
||||
enabled: (process.env.VIDEO_PROCESSING_ENABLED ?? 'false').toLowerCase() === 'true',
|
||||
ffmpegPath: process.env.VIDEO_PROCESSING_FFMPEG_PATH ?? 'ffmpeg',
|
||||
maxWidth: Number(process.env.VIDEO_PROCESSING_MAX_WIDTH ?? 1280),
|
||||
maxFps: Number(process.env.VIDEO_PROCESSING_MAX_FPS ?? 30),
|
||||
crf: Number(process.env.VIDEO_PROCESSING_CRF ?? 28),
|
||||
preset: process.env.VIDEO_PROCESSING_PRESET ?? 'veryfast',
|
||||
audioBitrateKbps: Number(process.env.VIDEO_PROCESSING_AUDIO_BITRATE_KBPS ?? 128),
|
||||
generateThumbnails:
|
||||
(process.env.VIDEO_PROCESSING_GENERATE_THUMBNAILS ?? 'true').toLowerCase() === 'true',
|
||||
thumbnailWidth: Number(process.env.VIDEO_PROCESSING_THUMBNAIL_WIDTH ?? 720),
|
||||
},
|
||||
logging: {
|
||||
level: process.env.LOG_LEVEL ?? 'log',
|
||||
requestEnabled: (process.env.REQUEST_LOGGING_ENABLED ?? 'true').toLowerCase() === 'true',
|
||||
|
||||
@@ -61,6 +61,17 @@ export const validationSchema = Joi.object({
|
||||
S3_ACCESS_KEY_ID: Joi.string().allow('').optional(),
|
||||
S3_SECRET_ACCESS_KEY: Joi.string().allow('').optional(),
|
||||
S3_FORCE_PATH_STYLE: Joi.boolean().truthy('true').falsy('false').default(false),
|
||||
VIDEO_PROCESSING_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
|
||||
VIDEO_PROCESSING_FFMPEG_PATH: Joi.string().default('ffmpeg'),
|
||||
VIDEO_PROCESSING_MAX_WIDTH: Joi.number().min(320).max(3840).default(1280),
|
||||
VIDEO_PROCESSING_MAX_FPS: Joi.number().min(12).max(60).default(30),
|
||||
VIDEO_PROCESSING_CRF: Joi.number().min(18).max(35).default(28),
|
||||
VIDEO_PROCESSING_PRESET: Joi.string()
|
||||
.valid('ultrafast', 'superfast', 'veryfast', 'faster', 'fast', 'medium', 'slow')
|
||||
.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_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),
|
||||
FEED_CACHE_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true),
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ManagedStorageService } from './managed-storage.service';
|
||||
import { VideoProcessingService } from './video-processing.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [ManagedStorageService],
|
||||
exports: [ManagedStorageService],
|
||||
providers: [ManagedStorageService, VideoProcessingService],
|
||||
exports: [ManagedStorageService, VideoProcessingService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
|
||||
265
src/infrastructure/storage/video-processing.service.ts
Normal file
265
src/infrastructure/storage/video-processing.service.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
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 { tmpdir } from 'os';
|
||||
import { extname, join } from 'path';
|
||||
|
||||
export type UploadedVideoFile = {
|
||||
mimetype?: string;
|
||||
size: number;
|
||||
buffer: Buffer;
|
||||
originalname?: string;
|
||||
};
|
||||
|
||||
export type OptimizedVideoResult = {
|
||||
file: UploadedVideoFile;
|
||||
generatedThumbnail?: {
|
||||
buffer: Buffer;
|
||||
extension: '.jpg';
|
||||
contentType: 'image/jpeg';
|
||||
};
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class VideoProcessingService {
|
||||
private readonly logger = new Logger(VideoProcessingService.name);
|
||||
private ffmpegAvailabilityResolved = false;
|
||||
private ffmpegAvailable = false;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
async optimizeForPlayback(file: UploadedVideoFile): Promise<OptimizedVideoResult> {
|
||||
if (!this.isEnabled()) {
|
||||
return { file };
|
||||
}
|
||||
|
||||
const ffmpegReady = await this.ensureFfmpegAvailable();
|
||||
if (!ffmpegReady) {
|
||||
return { file };
|
||||
}
|
||||
|
||||
const workingDir = await mkdtemp(join(tmpdir(), 'oudelaa-video-'));
|
||||
const inputPath = join(workingDir, `input-${randomUUID()}${this.resolveInputExtension(file)}`);
|
||||
const outputPath = join(workingDir, `optimized-${randomUUID()}.mp4`);
|
||||
const thumbnailPath = join(workingDir, `thumbnail-${randomUUID()}.jpg`);
|
||||
|
||||
try {
|
||||
await writeFile(inputPath, file.buffer);
|
||||
await this.runFfmpeg([
|
||||
'-y',
|
||||
'-i',
|
||||
inputPath,
|
||||
'-map',
|
||||
'0:v:0',
|
||||
'-map',
|
||||
'0:a:0?',
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-preset',
|
||||
this.getPreset(),
|
||||
'-crf',
|
||||
String(this.getCrf()),
|
||||
'-pix_fmt',
|
||||
'yuv420p',
|
||||
'-profile:v',
|
||||
'main',
|
||||
'-level',
|
||||
'4.0',
|
||||
'-vf',
|
||||
this.buildVideoFilter(),
|
||||
'-movflags',
|
||||
'+faststart',
|
||||
'-c:a',
|
||||
'aac',
|
||||
'-b:a',
|
||||
`${this.getAudioBitrateKbps()}k`,
|
||||
'-ac',
|
||||
'2',
|
||||
'-ar',
|
||||
'44100',
|
||||
outputPath,
|
||||
]);
|
||||
|
||||
const optimizedBuffer = await readFile(outputPath);
|
||||
const optimizedFile: UploadedVideoFile = {
|
||||
buffer: optimizedBuffer,
|
||||
size: optimizedBuffer.length,
|
||||
mimetype: 'video/mp4',
|
||||
originalname: this.buildOptimizedFileName(file.originalname),
|
||||
};
|
||||
|
||||
let generatedThumbnail: OptimizedVideoResult['generatedThumbnail'];
|
||||
if (this.shouldGenerateThumbnails()) {
|
||||
try {
|
||||
await this.runFfmpeg([
|
||||
'-y',
|
||||
'-ss',
|
||||
'00:00:00.100',
|
||||
'-i',
|
||||
outputPath,
|
||||
'-frames:v',
|
||||
'1',
|
||||
'-vf',
|
||||
this.buildThumbnailFilter(),
|
||||
'-q:v',
|
||||
'3',
|
||||
thumbnailPath,
|
||||
]);
|
||||
|
||||
const thumbnailBuffer = await readFile(thumbnailPath);
|
||||
generatedThumbnail = {
|
||||
buffer: thumbnailBuffer,
|
||||
extension: '.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Thumbnail generation failed: ${error instanceof Error ? error.message : 'unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
file: optimizedFile,
|
||||
generatedThumbnail,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Video optimization failed for "${file.originalname ?? 'upload'}": ${
|
||||
error instanceof Error ? error.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
'Video optimization failed. Upload an MP4/WebM video or disable server-side video processing.',
|
||||
);
|
||||
} finally {
|
||||
await rm(workingDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
private isEnabled(): boolean {
|
||||
return this.configService.get<boolean>('videoProcessing.enabled', { infer: true }) ?? false;
|
||||
}
|
||||
|
||||
private shouldGenerateThumbnails(): boolean {
|
||||
return (
|
||||
this.configService.get<boolean>('videoProcessing.generateThumbnails', { infer: true }) ?? true
|
||||
);
|
||||
}
|
||||
|
||||
private getFfmpegPath(): string {
|
||||
return (
|
||||
this.configService.get<string>('videoProcessing.ffmpegPath', { infer: true }) ?? 'ffmpeg'
|
||||
).trim();
|
||||
}
|
||||
|
||||
private getMaxWidth(): number {
|
||||
return this.configService.get<number>('videoProcessing.maxWidth', { infer: true }) ?? 1280;
|
||||
}
|
||||
|
||||
private getThumbnailWidth(): number {
|
||||
return this.configService.get<number>('videoProcessing.thumbnailWidth', { infer: true }) ?? 720;
|
||||
}
|
||||
|
||||
private getMaxFps(): number {
|
||||
return this.configService.get<number>('videoProcessing.maxFps', { infer: true }) ?? 30;
|
||||
}
|
||||
|
||||
private getCrf(): number {
|
||||
return this.configService.get<number>('videoProcessing.crf', { infer: true }) ?? 28;
|
||||
}
|
||||
|
||||
private getPreset(): string {
|
||||
return (
|
||||
this.configService.get<string>('videoProcessing.preset', { infer: true }) ?? 'veryfast'
|
||||
).trim();
|
||||
}
|
||||
|
||||
private getAudioBitrateKbps(): number {
|
||||
return (
|
||||
this.configService.get<number>('videoProcessing.audioBitrateKbps', { infer: true }) ?? 128
|
||||
);
|
||||
}
|
||||
|
||||
private buildVideoFilter(): string {
|
||||
return `scale='min(${this.getMaxWidth()},iw)':-2:force_original_aspect_ratio=decrease,fps=${this.getMaxFps()},format=yuv420p`;
|
||||
}
|
||||
|
||||
private buildThumbnailFilter(): string {
|
||||
return `scale='min(${this.getThumbnailWidth()},iw)':-2:force_original_aspect_ratio=decrease`;
|
||||
}
|
||||
|
||||
private buildOptimizedFileName(originalname?: string): string {
|
||||
const baseName = (originalname ?? 'video').replace(/\.[^.]+$/, '');
|
||||
return `${baseName}-optimized.mp4`;
|
||||
}
|
||||
|
||||
private resolveInputExtension(file: UploadedVideoFile): string {
|
||||
const extension = extname(file.originalname ?? '').toLowerCase();
|
||||
if (extension) {
|
||||
return extension;
|
||||
}
|
||||
|
||||
switch (file.mimetype) {
|
||||
case 'video/mp4':
|
||||
return '.mp4';
|
||||
case 'video/quicktime':
|
||||
return '.mov';
|
||||
case 'video/webm':
|
||||
return '.webm';
|
||||
case 'video/x-matroska':
|
||||
return '.mkv';
|
||||
case 'video/x-msvideo':
|
||||
return '.avi';
|
||||
default:
|
||||
return '.mp4';
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureFfmpegAvailable(): Promise<boolean> {
|
||||
if (this.ffmpegAvailabilityResolved) {
|
||||
return this.ffmpegAvailable;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.runFfmpeg(['-version']);
|
||||
this.ffmpegAvailable = true;
|
||||
} catch (error) {
|
||||
this.ffmpegAvailable = false;
|
||||
this.logger.warn(
|
||||
`VIDEO_PROCESSING_ENABLED is on, but ffmpeg is unavailable at "${this.getFfmpegPath()}": ${
|
||||
error instanceof Error ? error.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.ffmpegAvailabilityResolved = true;
|
||||
return this.ffmpegAvailable;
|
||||
}
|
||||
|
||||
private async runFfmpeg(args: string[]): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(this.getFfmpegPath(), args, { windowsHide: true });
|
||||
let stderr = '';
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(stderr.trim() || `ffmpeg exited with code ${code ?? 'unknown'}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,7 @@ export class FeedService {
|
||||
) {}
|
||||
|
||||
async getMyFeed(currentUserId: string, query: FeedQueryDto) {
|
||||
const followingOnly = query.followingOnly ?? true;
|
||||
const cacheEnabled =
|
||||
this.configService.get<boolean>('feedCache.enabled', { infer: true }) ?? true;
|
||||
const globalVersion = cacheEnabled ? await this.feedVersionService.getGlobalVersion() : 0;
|
||||
@@ -79,7 +80,7 @@ export class FeedService {
|
||||
page: query.page ?? 1,
|
||||
limit: query.limit ?? 20,
|
||||
cursor: query.cursor ?? '',
|
||||
followingOnly: query.followingOnly ?? false,
|
||||
followingOnly,
|
||||
radiusKm: query.radiusKm ?? 30,
|
||||
preferredPostType: query.preferredPostType ?? '',
|
||||
includeSuggestions,
|
||||
@@ -100,14 +101,23 @@ export class FeedService {
|
||||
const limit = query.limit ?? 20;
|
||||
const cursorOffset = decodeOffsetCursor(query.cursor);
|
||||
const page = query.page ?? 1;
|
||||
const followingOnly = query.followingOnly ?? false;
|
||||
const radiusKm = query.radiusKm ?? 30;
|
||||
const skip = cursorOffset ?? (page - 1) * limit;
|
||||
|
||||
const followingIds = await this.feedRepository.findFollowingIds(currentUserId);
|
||||
const filter = this.buildVisiblePostsFilter(currentUserId, followingIds, followingOnly);
|
||||
let filter = this.buildVisiblePostsFilter(currentUserId, followingIds, followingOnly);
|
||||
let candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300));
|
||||
|
||||
const candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300));
|
||||
// Keep the default home feed focused on followed accounts, but avoid an empty screen
|
||||
// for new users or when followed accounts have not posted yet.
|
||||
if (
|
||||
candidates.length === 0 &&
|
||||
typeof query.followingOnly === 'undefined' &&
|
||||
followingOnly
|
||||
) {
|
||||
filter = this.buildVisiblePostsFilter(currentUserId, followingIds, false);
|
||||
candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300));
|
||||
}
|
||||
|
||||
const scored = candidates
|
||||
.filter((post) => {
|
||||
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
} from '../../common/utils/waveform.util';
|
||||
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
|
||||
import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service';
|
||||
import {
|
||||
UploadedVideoFile,
|
||||
VideoProcessingService,
|
||||
} from '../../infrastructure/storage/video-processing.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { UsersRepository } from '../users/users.repository';
|
||||
@@ -44,6 +48,11 @@ type NormalizedPostMediaMetadata = {
|
||||
waveformPeaks: number[];
|
||||
};
|
||||
|
||||
type SavedVideoUpload = {
|
||||
videoUrl: string;
|
||||
thumbnailUrl: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PostsService {
|
||||
private readonly logger = new Logger(PostsService.name);
|
||||
@@ -52,6 +61,7 @@ export class PostsService {
|
||||
private readonly postsRepository: PostsRepository,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly storageService: ManagedStorageService,
|
||||
private readonly videoProcessingService: VideoProcessingService,
|
||||
private readonly feedVersionService: FeedVersionService,
|
||||
private readonly notificationsService: NotificationsService,
|
||||
private readonly auditService: AuditService,
|
||||
@@ -88,7 +98,9 @@ export class PostsService {
|
||||
}
|
||||
|
||||
const uploadedImageUrls = await this.saveImageFiles(imageFiles);
|
||||
const uploadedVideoUrl = videoFile ? await this.saveMediaFile('video', videoFile) : '';
|
||||
const savedVideoUpload = videoFile ? await this.saveVideoUpload(videoFile) : null;
|
||||
const uploadedVideoUrl = savedVideoUpload?.videoUrl ?? '';
|
||||
const uploadedThumbnailUrl = savedVideoUpload?.thumbnailUrl ?? '';
|
||||
const uploadedAudioUrl = audioFile ? await this.saveMediaFile('audio', audioFile) : '';
|
||||
const finalImageUrls = uploadedImageUrls.length ? uploadedImageUrls : inputImageUrls;
|
||||
const finalVideoUrl = uploadedVideoUrl || dto.videoUrl || '';
|
||||
@@ -106,6 +118,7 @@ export class PostsService {
|
||||
const mediaMetadata = this.normalizeMediaMetadata(dto, postType, undefined, {
|
||||
audioSourceBuffer: audioFile?.buffer,
|
||||
waveformSeed: finalAudioUrl || finalContent || `${userId}:${Date.now()}`,
|
||||
thumbnailUrl: uploadedThumbnailUrl,
|
||||
});
|
||||
|
||||
let post: PostDocument;
|
||||
@@ -129,6 +142,7 @@ export class PostsService {
|
||||
await Promise.all([
|
||||
...uploadedImageUrls.map((url) => this.deleteManagedPostMedia(url)),
|
||||
uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(),
|
||||
uploadedThumbnailUrl ? this.deleteManagedPostMedia(uploadedThumbnailUrl) : Promise.resolve(),
|
||||
uploadedAudioUrl ? this.deleteManagedPostMedia(uploadedAudioUrl) : Promise.resolve(),
|
||||
]);
|
||||
throw error;
|
||||
@@ -182,7 +196,9 @@ export class PostsService {
|
||||
}
|
||||
|
||||
const uploadedImageUrls = await this.saveImageFiles(imageFiles);
|
||||
const uploadedVideoUrl = videoFile ? await this.saveMediaFile('video', videoFile) : '';
|
||||
const savedVideoUpload = videoFile ? await this.saveVideoUpload(videoFile) : null;
|
||||
const uploadedVideoUrl = savedVideoUpload?.videoUrl ?? '';
|
||||
const uploadedThumbnailUrl = savedVideoUpload?.thumbnailUrl ?? '';
|
||||
const uploadedAudioUrl = audioFile ? await this.saveMediaFile('audio', audioFile) : '';
|
||||
const hasImageUpdate = typeof dto.imageUrls !== 'undefined' || imageFiles.length > 0;
|
||||
|
||||
@@ -233,7 +249,7 @@ export class PostsService {
|
||||
nextPostType,
|
||||
{
|
||||
durationSeconds: post.durationSeconds ?? null,
|
||||
thumbnailUrl: post.thumbnailUrl ?? '',
|
||||
thumbnailUrl: uploadedThumbnailUrl || (post.thumbnailUrl ?? ''),
|
||||
style: post.style ?? '',
|
||||
maqam: post.maqam ?? '',
|
||||
rhythmSignature: post.rhythmSignature ?? '',
|
||||
@@ -242,6 +258,7 @@ export class PostsService {
|
||||
{
|
||||
audioSourceBuffer: audioFile?.buffer,
|
||||
waveformSeed: nextAudioUrl || nextContent || post.id,
|
||||
thumbnailUrl: uploadedThumbnailUrl,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -294,6 +311,7 @@ export class PostsService {
|
||||
await Promise.all([
|
||||
...uploadedImageUrls.map((url) => this.deleteManagedPostMedia(url)),
|
||||
uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(),
|
||||
uploadedThumbnailUrl ? this.deleteManagedPostMedia(uploadedThumbnailUrl) : Promise.resolve(),
|
||||
uploadedAudioUrl ? this.deleteManagedPostMedia(uploadedAudioUrl) : Promise.resolve(),
|
||||
]);
|
||||
throw error;
|
||||
@@ -302,6 +320,7 @@ export class PostsService {
|
||||
await Promise.all([
|
||||
...uploadedImageUrls.map((url) => this.deleteManagedPostMedia(url)),
|
||||
uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(),
|
||||
uploadedThumbnailUrl ? this.deleteManagedPostMedia(uploadedThumbnailUrl) : Promise.resolve(),
|
||||
uploadedAudioUrl ? this.deleteManagedPostMedia(uploadedAudioUrl) : Promise.resolve(),
|
||||
]);
|
||||
throw new NotFoundException('Post not found');
|
||||
@@ -310,6 +329,9 @@ export class PostsService {
|
||||
if (hasVideoUpdate && (post.videoUrl ?? '') !== (updated.videoUrl ?? '')) {
|
||||
await this.deleteManagedPostMedia(post.videoUrl ?? '');
|
||||
}
|
||||
if ((post.thumbnailUrl ?? '') !== (updated.thumbnailUrl ?? '')) {
|
||||
await this.deleteManagedPostMedia(post.thumbnailUrl ?? '');
|
||||
}
|
||||
if (hasAudioUpdate && (post.audioUrl ?? '') !== (updated.audioUrl ?? '')) {
|
||||
await this.deleteManagedPostMedia(post.audioUrl ?? '');
|
||||
}
|
||||
@@ -676,6 +698,7 @@ export class PostsService {
|
||||
options: {
|
||||
audioSourceBuffer?: Buffer;
|
||||
waveformSeed?: string;
|
||||
thumbnailUrl?: string;
|
||||
} = {},
|
||||
): NormalizedPostMediaMetadata {
|
||||
const supportsMediaMetadata = postType === PostType.AUDIO || postType === PostType.VIDEO;
|
||||
@@ -703,7 +726,9 @@ export class PostsService {
|
||||
durationSeconds:
|
||||
typeof dto.durationSeconds === 'number' ? dto.durationSeconds : fallback.durationSeconds,
|
||||
thumbnailUrl:
|
||||
typeof dto.thumbnailUrl === 'string' ? dto.thumbnailUrl.trim() : fallback.thumbnailUrl,
|
||||
typeof dto.thumbnailUrl === 'string'
|
||||
? dto.thumbnailUrl.trim()
|
||||
: options.thumbnailUrl || fallback.thumbnailUrl,
|
||||
style: typeof dto.style === 'string' ? dto.style.trim() : fallback.style,
|
||||
maqam: typeof dto.maqam === 'string' ? dto.maqam.trim() : fallback.maqam,
|
||||
rhythmSignature:
|
||||
@@ -887,10 +912,63 @@ export class PostsService {
|
||||
);
|
||||
}
|
||||
|
||||
private async saveVideoUpload(file: UploadedVideoFile): Promise<SavedVideoUpload> {
|
||||
this.validateMediaFile('video', file);
|
||||
const optimized = await this.videoProcessingService.optimizeForPlayback(file);
|
||||
const extension = this.validateMediaFile('video', optimized.file);
|
||||
|
||||
let videoUrl = '';
|
||||
let thumbnailUrl = '';
|
||||
|
||||
try {
|
||||
videoUrl = await this.storageService.saveFile({
|
||||
folderSegments: ['posts', 'videos'],
|
||||
extension,
|
||||
buffer: optimized.file.buffer,
|
||||
contentType: optimized.file.mimetype,
|
||||
fileNamePrefix: 'video',
|
||||
});
|
||||
|
||||
if (optimized.generatedThumbnail) {
|
||||
thumbnailUrl = await this.storageService.saveFile({
|
||||
folderSegments: ['posts', 'thumbnails'],
|
||||
extension: optimized.generatedThumbnail.extension,
|
||||
buffer: optimized.generatedThumbnail.buffer,
|
||||
contentType: optimized.generatedThumbnail.contentType,
|
||||
fileNamePrefix: 'thumbnail',
|
||||
});
|
||||
}
|
||||
|
||||
return { videoUrl, thumbnailUrl };
|
||||
} catch (error) {
|
||||
await Promise.all([
|
||||
videoUrl ? this.deleteManagedPostMedia(videoUrl) : Promise.resolve(),
|
||||
thumbnailUrl ? this.deleteManagedPostMedia(thumbnailUrl) : Promise.resolve(),
|
||||
]);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async saveMediaFile(
|
||||
mediaType: 'image' | 'video' | 'audio',
|
||||
file: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
|
||||
): Promise<string> {
|
||||
const extension = this.validateMediaFile(mediaType, file);
|
||||
const folder =
|
||||
mediaType === 'image' ? 'images' : mediaType === 'video' ? 'videos' : 'audios';
|
||||
return this.storageService.saveFile({
|
||||
folderSegments: ['posts', folder],
|
||||
extension,
|
||||
buffer: file.buffer,
|
||||
contentType: file.mimetype,
|
||||
fileNamePrefix: mediaType,
|
||||
});
|
||||
}
|
||||
|
||||
private validateMediaFile(
|
||||
mediaType: 'image' | 'video' | 'audio',
|
||||
file: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
|
||||
): string {
|
||||
const extension = this.resolveMediaExtension(mediaType, file);
|
||||
if (!extension) {
|
||||
throw new BadRequestException(
|
||||
@@ -918,15 +996,7 @@ export class PostsService {
|
||||
);
|
||||
}
|
||||
|
||||
const folder =
|
||||
mediaType === 'image' ? 'images' : mediaType === 'video' ? 'videos' : 'audios';
|
||||
return this.storageService.saveFile({
|
||||
folderSegments: ['posts', folder],
|
||||
extension,
|
||||
buffer: file.buffer,
|
||||
contentType: file.mimetype,
|
||||
fileNamePrefix: mediaType,
|
||||
});
|
||||
return extension;
|
||||
}
|
||||
|
||||
private resolveMediaExtension(
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم