diff --git a/.env.example b/.env.example index 850b020..915ab62 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,15 @@ S3_ENDPOINT= S3_ACCESS_KEY_ID= S3_SECRET_ACCESS_KEY= S3_FORCE_PATH_STYLE=false +VIDEO_PROCESSING_ENABLED=false +VIDEO_PROCESSING_FFMPEG_PATH=ffmpeg +VIDEO_PROCESSING_MAX_WIDTH=1280 +VIDEO_PROCESSING_MAX_FPS=30 +VIDEO_PROCESSING_CRF=28 +VIDEO_PROCESSING_PRESET=veryfast +VIDEO_PROCESSING_AUDIO_BITRATE_KBPS=128 +VIDEO_PROCESSING_GENERATE_THUMBNAILS=true +VIDEO_PROCESSING_THUMBNAIL_WIDTH=720 GOOGLE_CLIENT_ID=your_google_client_id GOOGLE_CLIENT_SECRET=your_google_client_secret diff --git a/Dockerfile b/Dockerfile index 7605dd3..9ffb4d3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,9 @@ FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production +ENV VIDEO_PROCESSING_FFMPEG_PATH=/usr/bin/ffmpeg + +RUN apk add --no-cache ffmpeg COPY package*.json ./ RUN npm ci --omit=dev @@ -21,4 +24,4 @@ COPY --from=builder /app/dist ./dist EXPOSE 4000 -CMD ["node", "dist/main.js"] \ No newline at end of file +CMD ["node", "dist/main.js"] diff --git a/docs/FRONTEND_INTEGRATION.md b/docs/FRONTEND_INTEGRATION.md index ff9d49b..e836553 100644 --- a/docs/FRONTEND_INTEGRATION.md +++ b/docs/FRONTEND_INTEGRATION.md @@ -77,6 +77,10 @@ Common conventions: Supported filters: +- `GET /feed/me` + - defaults to followed accounts and the viewer's own posts + - use `followingOnly=false` to widen the home feed to public discovery posts + - `preferredPostType`, `followingOnly`, `radiusKm`, `includeSuggestions`, `suggestionInterval` - `GET /marketplace/home` - `listingsLimit`, `instrumentsLimit`, `repairShopsLimit`, `onlyActive` - `GET /users` @@ -201,6 +205,17 @@ Array fields may be sent either as repeated form keys or JSON text: - `imageUrls` - `waveformPeaks` +## Video optimization + +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 +- 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. + ## Marketplace split Marketplace is now separated from musical instruments at the API contract level: diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 521bbe9..4041471 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -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', diff --git a/src/config/validation.schema.ts b/src/config/validation.schema.ts index e694dca..2725beb 100644 --- a/src/config/validation.schema.ts +++ b/src/config/validation.schema.ts @@ -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), diff --git a/src/infrastructure/storage/storage.module.ts b/src/infrastructure/storage/storage.module.ts index dfdd5f1..3fa40c7 100644 --- a/src/infrastructure/storage/storage.module.ts +++ b/src/infrastructure/storage/storage.module.ts @@ -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 {} diff --git a/src/infrastructure/storage/video-processing.service.ts b/src/infrastructure/storage/video-processing.service.ts new file mode 100644 index 0000000..5b8ba3d --- /dev/null +++ b/src/infrastructure/storage/video-processing.service.ts @@ -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 { + 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('videoProcessing.enabled', { infer: true }) ?? false; + } + + private shouldGenerateThumbnails(): boolean { + return ( + this.configService.get('videoProcessing.generateThumbnails', { infer: true }) ?? true + ); + } + + private getFfmpegPath(): string { + return ( + this.configService.get('videoProcessing.ffmpegPath', { infer: true }) ?? 'ffmpeg' + ).trim(); + } + + private getMaxWidth(): number { + return this.configService.get('videoProcessing.maxWidth', { infer: true }) ?? 1280; + } + + private getThumbnailWidth(): number { + return this.configService.get('videoProcessing.thumbnailWidth', { infer: true }) ?? 720; + } + + private getMaxFps(): number { + return this.configService.get('videoProcessing.maxFps', { infer: true }) ?? 30; + } + + private getCrf(): number { + return this.configService.get('videoProcessing.crf', { infer: true }) ?? 28; + } + + private getPreset(): string { + return ( + this.configService.get('videoProcessing.preset', { infer: true }) ?? 'veryfast' + ).trim(); + } + + private getAudioBitrateKbps(): number { + return ( + this.configService.get('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 { + 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 { + await new Promise((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'}`)); + }); + }); + } +} diff --git a/src/modules/feed/feed.service.ts b/src/modules/feed/feed.service.ts index 9818747..c6e4ac0 100644 --- a/src/modules/feed/feed.service.ts +++ b/src/modules/feed/feed.service.ts @@ -69,6 +69,7 @@ export class FeedService { ) {} async getMyFeed(currentUserId: string, query: FeedQueryDto) { + const followingOnly = query.followingOnly ?? true; const cacheEnabled = this.configService.get('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) => { diff --git a/src/modules/posts/posts.service.ts b/src/modules/posts/posts.service.ts index 682f397..8a67f15 100644 --- a/src/modules/posts/posts.service.ts +++ b/src/modules/posts/posts.service.ts @@ -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 { + 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 { + 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(