Add video optimization and following-first feed

هذا الالتزام موجود في:
2026-05-17 18:37:42 +03:00
الأصل 045c74014c
التزام 4912a99b8d
9 ملفات معدلة مع 416 إضافات و20 حذوفات

عرض الملف

@@ -58,6 +58,15 @@ S3_ENDPOINT=
S3_ACCESS_KEY_ID= S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY= S3_SECRET_ACCESS_KEY=
S3_FORCE_PATH_STYLE=false 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_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret GOOGLE_CLIENT_SECRET=your_google_client_secret

عرض الملف

@@ -13,6 +13,9 @@ FROM node:20-alpine AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV VIDEO_PROCESSING_FFMPEG_PATH=/usr/bin/ffmpeg
RUN apk add --no-cache ffmpeg
COPY package*.json ./ COPY package*.json ./
RUN npm ci --omit=dev RUN npm ci --omit=dev
@@ -21,4 +24,4 @@ COPY --from=builder /app/dist ./dist
EXPOSE 4000 EXPOSE 4000
CMD ["node", "dist/main.js"] CMD ["node", "dist/main.js"]

عرض الملف

@@ -77,6 +77,10 @@ Common conventions:
Supported filters: 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` - `GET /marketplace/home`
- `listingsLimit`, `instrumentsLimit`, `repairShopsLimit`, `onlyActive` - `listingsLimit`, `instrumentsLimit`, `repairShopsLimit`, `onlyActive`
- `GET /users` - `GET /users`
@@ -201,6 +205,17 @@ Array fields may be sent either as repeated form keys or JSON text:
- `imageUrls` - `imageUrls`
- `waveformPeaks` - `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 split
Marketplace is now separated from musical instruments at the API contract level: Marketplace is now separated from musical instruments at the API contract level:

عرض الملف

@@ -94,6 +94,18 @@ export default () => ({
(process.env.S3_FORCE_PATH_STYLE ?? 'false').toLowerCase() === 'true', (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: { logging: {
level: process.env.LOG_LEVEL ?? 'log', level: process.env.LOG_LEVEL ?? 'log',
requestEnabled: (process.env.REQUEST_LOGGING_ENABLED ?? 'true').toLowerCase() === 'true', 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_ACCESS_KEY_ID: Joi.string().allow('').optional(),
S3_SECRET_ACCESS_KEY: Joi.string().allow('').optional(), S3_SECRET_ACCESS_KEY: Joi.string().allow('').optional(),
S3_FORCE_PATH_STYLE: Joi.boolean().truthy('true').falsy('false').default(false), 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'), LOG_LEVEL: Joi.string().valid('error', 'warn', 'log', 'debug', 'verbose').default('log'),
REQUEST_LOGGING_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true), REQUEST_LOGGING_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true),
FEED_CACHE_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 { Global, Module } from '@nestjs/common';
import { ManagedStorageService } from './managed-storage.service'; import { ManagedStorageService } from './managed-storage.service';
import { VideoProcessingService } from './video-processing.service';
@Global() @Global()
@Module({ @Module({
providers: [ManagedStorageService], providers: [ManagedStorageService, VideoProcessingService],
exports: [ManagedStorageService], exports: [ManagedStorageService, VideoProcessingService],
}) })
export class StorageModule {} export class StorageModule {}

عرض الملف

@@ -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) { async getMyFeed(currentUserId: string, query: FeedQueryDto) {
const followingOnly = query.followingOnly ?? true;
const cacheEnabled = const cacheEnabled =
this.configService.get<boolean>('feedCache.enabled', { infer: true }) ?? true; this.configService.get<boolean>('feedCache.enabled', { infer: true }) ?? true;
const globalVersion = cacheEnabled ? await this.feedVersionService.getGlobalVersion() : 0; const globalVersion = cacheEnabled ? await this.feedVersionService.getGlobalVersion() : 0;
@@ -79,7 +80,7 @@ export class FeedService {
page: query.page ?? 1, page: query.page ?? 1,
limit: query.limit ?? 20, limit: query.limit ?? 20,
cursor: query.cursor ?? '', cursor: query.cursor ?? '',
followingOnly: query.followingOnly ?? false, followingOnly,
radiusKm: query.radiusKm ?? 30, radiusKm: query.radiusKm ?? 30,
preferredPostType: query.preferredPostType ?? '', preferredPostType: query.preferredPostType ?? '',
includeSuggestions, includeSuggestions,
@@ -100,14 +101,23 @@ export class FeedService {
const limit = query.limit ?? 20; const limit = query.limit ?? 20;
const cursorOffset = decodeOffsetCursor(query.cursor); const cursorOffset = decodeOffsetCursor(query.cursor);
const page = query.page ?? 1; const page = query.page ?? 1;
const followingOnly = query.followingOnly ?? false;
const radiusKm = query.radiusKm ?? 30; const radiusKm = query.radiusKm ?? 30;
const skip = cursorOffset ?? (page - 1) * limit; const skip = cursorOffset ?? (page - 1) * limit;
const followingIds = await this.feedRepository.findFollowingIds(currentUserId); 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 const scored = candidates
.filter((post) => { .filter((post) => {

عرض الملف

@@ -13,6 +13,10 @@ import {
} from '../../common/utils/waveform.util'; } from '../../common/utils/waveform.util';
import { FeedVersionService } from '../../infrastructure/cache/feed-version.service'; import { FeedVersionService } from '../../infrastructure/cache/feed-version.service';
import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.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 { NotificationsService } from '../notifications/notifications.service';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
import { UsersRepository } from '../users/users.repository'; import { UsersRepository } from '../users/users.repository';
@@ -44,6 +48,11 @@ type NormalizedPostMediaMetadata = {
waveformPeaks: number[]; waveformPeaks: number[];
}; };
type SavedVideoUpload = {
videoUrl: string;
thumbnailUrl: string;
};
@Injectable() @Injectable()
export class PostsService { export class PostsService {
private readonly logger = new Logger(PostsService.name); private readonly logger = new Logger(PostsService.name);
@@ -52,6 +61,7 @@ export class PostsService {
private readonly postsRepository: PostsRepository, private readonly postsRepository: PostsRepository,
private readonly usersRepository: UsersRepository, private readonly usersRepository: UsersRepository,
private readonly storageService: ManagedStorageService, private readonly storageService: ManagedStorageService,
private readonly videoProcessingService: VideoProcessingService,
private readonly feedVersionService: FeedVersionService, private readonly feedVersionService: FeedVersionService,
private readonly notificationsService: NotificationsService, private readonly notificationsService: NotificationsService,
private readonly auditService: AuditService, private readonly auditService: AuditService,
@@ -88,7 +98,9 @@ export class PostsService {
} }
const uploadedImageUrls = await this.saveImageFiles(imageFiles); 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 uploadedAudioUrl = audioFile ? await this.saveMediaFile('audio', audioFile) : '';
const finalImageUrls = uploadedImageUrls.length ? uploadedImageUrls : inputImageUrls; const finalImageUrls = uploadedImageUrls.length ? uploadedImageUrls : inputImageUrls;
const finalVideoUrl = uploadedVideoUrl || dto.videoUrl || ''; const finalVideoUrl = uploadedVideoUrl || dto.videoUrl || '';
@@ -106,6 +118,7 @@ export class PostsService {
const mediaMetadata = this.normalizeMediaMetadata(dto, postType, undefined, { const mediaMetadata = this.normalizeMediaMetadata(dto, postType, undefined, {
audioSourceBuffer: audioFile?.buffer, audioSourceBuffer: audioFile?.buffer,
waveformSeed: finalAudioUrl || finalContent || `${userId}:${Date.now()}`, waveformSeed: finalAudioUrl || finalContent || `${userId}:${Date.now()}`,
thumbnailUrl: uploadedThumbnailUrl,
}); });
let post: PostDocument; let post: PostDocument;
@@ -129,6 +142,7 @@ export class PostsService {
await Promise.all([ await Promise.all([
...uploadedImageUrls.map((url) => this.deleteManagedPostMedia(url)), ...uploadedImageUrls.map((url) => this.deleteManagedPostMedia(url)),
uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(), uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(),
uploadedThumbnailUrl ? this.deleteManagedPostMedia(uploadedThumbnailUrl) : Promise.resolve(),
uploadedAudioUrl ? this.deleteManagedPostMedia(uploadedAudioUrl) : Promise.resolve(), uploadedAudioUrl ? this.deleteManagedPostMedia(uploadedAudioUrl) : Promise.resolve(),
]); ]);
throw error; throw error;
@@ -182,7 +196,9 @@ export class PostsService {
} }
const uploadedImageUrls = await this.saveImageFiles(imageFiles); 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 uploadedAudioUrl = audioFile ? await this.saveMediaFile('audio', audioFile) : '';
const hasImageUpdate = typeof dto.imageUrls !== 'undefined' || imageFiles.length > 0; const hasImageUpdate = typeof dto.imageUrls !== 'undefined' || imageFiles.length > 0;
@@ -233,7 +249,7 @@ export class PostsService {
nextPostType, nextPostType,
{ {
durationSeconds: post.durationSeconds ?? null, durationSeconds: post.durationSeconds ?? null,
thumbnailUrl: post.thumbnailUrl ?? '', thumbnailUrl: uploadedThumbnailUrl || (post.thumbnailUrl ?? ''),
style: post.style ?? '', style: post.style ?? '',
maqam: post.maqam ?? '', maqam: post.maqam ?? '',
rhythmSignature: post.rhythmSignature ?? '', rhythmSignature: post.rhythmSignature ?? '',
@@ -242,6 +258,7 @@ export class PostsService {
{ {
audioSourceBuffer: audioFile?.buffer, audioSourceBuffer: audioFile?.buffer,
waveformSeed: nextAudioUrl || nextContent || post.id, waveformSeed: nextAudioUrl || nextContent || post.id,
thumbnailUrl: uploadedThumbnailUrl,
}, },
); );
@@ -294,6 +311,7 @@ export class PostsService {
await Promise.all([ await Promise.all([
...uploadedImageUrls.map((url) => this.deleteManagedPostMedia(url)), ...uploadedImageUrls.map((url) => this.deleteManagedPostMedia(url)),
uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(), uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(),
uploadedThumbnailUrl ? this.deleteManagedPostMedia(uploadedThumbnailUrl) : Promise.resolve(),
uploadedAudioUrl ? this.deleteManagedPostMedia(uploadedAudioUrl) : Promise.resolve(), uploadedAudioUrl ? this.deleteManagedPostMedia(uploadedAudioUrl) : Promise.resolve(),
]); ]);
throw error; throw error;
@@ -302,6 +320,7 @@ export class PostsService {
await Promise.all([ await Promise.all([
...uploadedImageUrls.map((url) => this.deleteManagedPostMedia(url)), ...uploadedImageUrls.map((url) => this.deleteManagedPostMedia(url)),
uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(), uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(),
uploadedThumbnailUrl ? this.deleteManagedPostMedia(uploadedThumbnailUrl) : Promise.resolve(),
uploadedAudioUrl ? this.deleteManagedPostMedia(uploadedAudioUrl) : Promise.resolve(), uploadedAudioUrl ? this.deleteManagedPostMedia(uploadedAudioUrl) : Promise.resolve(),
]); ]);
throw new NotFoundException('Post not found'); throw new NotFoundException('Post not found');
@@ -310,6 +329,9 @@ export class PostsService {
if (hasVideoUpdate && (post.videoUrl ?? '') !== (updated.videoUrl ?? '')) { if (hasVideoUpdate && (post.videoUrl ?? '') !== (updated.videoUrl ?? '')) {
await this.deleteManagedPostMedia(post.videoUrl ?? ''); await this.deleteManagedPostMedia(post.videoUrl ?? '');
} }
if ((post.thumbnailUrl ?? '') !== (updated.thumbnailUrl ?? '')) {
await this.deleteManagedPostMedia(post.thumbnailUrl ?? '');
}
if (hasAudioUpdate && (post.audioUrl ?? '') !== (updated.audioUrl ?? '')) { if (hasAudioUpdate && (post.audioUrl ?? '') !== (updated.audioUrl ?? '')) {
await this.deleteManagedPostMedia(post.audioUrl ?? ''); await this.deleteManagedPostMedia(post.audioUrl ?? '');
} }
@@ -676,6 +698,7 @@ export class PostsService {
options: { options: {
audioSourceBuffer?: Buffer; audioSourceBuffer?: Buffer;
waveformSeed?: string; waveformSeed?: string;
thumbnailUrl?: string;
} = {}, } = {},
): NormalizedPostMediaMetadata { ): NormalizedPostMediaMetadata {
const supportsMediaMetadata = postType === PostType.AUDIO || postType === PostType.VIDEO; const supportsMediaMetadata = postType === PostType.AUDIO || postType === PostType.VIDEO;
@@ -703,7 +726,9 @@ export class PostsService {
durationSeconds: durationSeconds:
typeof dto.durationSeconds === 'number' ? dto.durationSeconds : fallback.durationSeconds, typeof dto.durationSeconds === 'number' ? dto.durationSeconds : fallback.durationSeconds,
thumbnailUrl: 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, style: typeof dto.style === 'string' ? dto.style.trim() : fallback.style,
maqam: typeof dto.maqam === 'string' ? dto.maqam.trim() : fallback.maqam, maqam: typeof dto.maqam === 'string' ? dto.maqam.trim() : fallback.maqam,
rhythmSignature: 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( private async saveMediaFile(
mediaType: 'image' | 'video' | 'audio', mediaType: 'image' | 'video' | 'audio',
file: { mimetype?: string; size: number; buffer: Buffer; originalname?: string }, file: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
): Promise<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); const extension = this.resolveMediaExtension(mediaType, file);
if (!extension) { if (!extension) {
throw new BadRequestException( throw new BadRequestException(
@@ -918,15 +996,7 @@ export class PostsService {
); );
} }
const folder = return extension;
mediaType === 'image' ? 'images' : mediaType === 'video' ? 'videos' : 'audios';
return this.storageService.saveFile({
folderSegments: ['posts', folder],
extension,
buffer: file.buffer,
contentType: file.mimetype,
fileNamePrefix: mediaType,
});
} }
private resolveMediaExtension( private resolveMediaExtension(