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