Add video optimization and following-first feed

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

عرض الملف

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