Add video optimization and following-first feed
هذا الالتزام موجود في:
@@ -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 {}
|
||||||
|
|||||||
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) {
|
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(
|
||||||
|
|||||||
المرجع في مشكلة جديدة
حظر مستخدم