Harden media health checks and duration extraction
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled
هذا الالتزام موجود في:
@@ -1,12 +1,9 @@
|
||||
import {
|
||||
BadGatewayException,
|
||||
Injectable,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { BadGatewayException, Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { GoogleAuth } from 'google-auth-library';
|
||||
import { generateWaveformPeaksFromBuffer } from '../../common/utils/waveform.util';
|
||||
import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service';
|
||||
import { MediaProbeService } from '../../infrastructure/storage/media-probe.service';
|
||||
import { TextToMusicDto } from './dto/text-to-music.dto';
|
||||
|
||||
@Injectable()
|
||||
@@ -14,29 +11,84 @@ export class MediaService {
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly storageService: ManagedStorageService,
|
||||
private readonly mediaProbeService: MediaProbeService,
|
||||
) {}
|
||||
|
||||
async getMediaHealth() {
|
||||
const [storageHealth, ffmpeg, ffprobe] = await Promise.all([
|
||||
this.storageService.getHealth(),
|
||||
this.mediaProbeService.checkFfmpeg(),
|
||||
this.mediaProbeService.checkFfprobe(),
|
||||
]);
|
||||
const storageProvider =
|
||||
this.configService.get<string>('storage.provider', { infer: true }) ?? 'local';
|
||||
const publicBaseUrl = this.configService.get<string>('publicBaseUrl', { infer: true }) ?? '';
|
||||
const warnings: string[] = [];
|
||||
const imageProcessingEnabled =
|
||||
this.configService.get<boolean>('imageProcessing.enabled', { infer: true }) ?? false;
|
||||
const videoProcessingEnabled =
|
||||
this.configService.get<boolean>('videoProcessing.enabled', { infer: true }) ?? false;
|
||||
const videoHlsGenerationEnabled =
|
||||
this.configService.get<boolean>('videoProcessing.generateHls', { infer: true }) ?? true;
|
||||
const videoThumbnailGenerationEnabled =
|
||||
this.configService.get<boolean>('videoProcessing.generateThumbnails', { infer: true }) ??
|
||||
true;
|
||||
const audioProcessingEnabled =
|
||||
this.configService.get<boolean>('audioProcessing.enabled', { infer: true }) ?? false;
|
||||
|
||||
if (storageProvider === 'local') {
|
||||
warnings.push(
|
||||
'Local storage requires persistent volume mounted to /app/uploads in production',
|
||||
);
|
||||
}
|
||||
if (!publicBaseUrl) {
|
||||
warnings.push('PUBLIC_BASE_URL is not configured');
|
||||
}
|
||||
if ((imageProcessingEnabled || videoProcessingEnabled) && !ffmpeg.available) {
|
||||
warnings.push('ffmpeg is not available; image/video processing may fail');
|
||||
}
|
||||
if (!ffprobe.available) {
|
||||
warnings.push('ffprobe is not available; duration extraction may fail');
|
||||
}
|
||||
if (storageProvider === 's3' && !(storageHealth.isS3Configured ?? storageHealth.s3Configured)) {
|
||||
warnings.push('S3 provider selected but missing required env variables');
|
||||
}
|
||||
|
||||
const storageWritable =
|
||||
storageProvider !== 'local' || storageHealth.uploadPathWritable !== false;
|
||||
const status = !storageWritable ? 'error' : warnings.length ? 'warning' : 'ok';
|
||||
|
||||
return {
|
||||
storage: await this.storageService.getHealth(),
|
||||
status,
|
||||
storage: storageHealth,
|
||||
processing: {
|
||||
imageProcessingEnabled:
|
||||
this.configService.get<boolean>('imageProcessing.enabled', { infer: true }) ?? false,
|
||||
videoProcessingEnabled:
|
||||
this.configService.get<boolean>('videoProcessing.enabled', { infer: true }) ?? false,
|
||||
videoHlsGenerationEnabled:
|
||||
this.configService.get<boolean>('videoProcessing.generateHls', { infer: true }) ?? true,
|
||||
videoThumbnailGenerationEnabled:
|
||||
this.configService.get<boolean>('videoProcessing.generateThumbnails', { infer: true }) ??
|
||||
true,
|
||||
ffmpegPath:
|
||||
this.configService.get<string>('videoProcessing.ffmpegPath', { infer: true }) ?? 'ffmpeg',
|
||||
imageProcessingEnabled,
|
||||
videoProcessingEnabled,
|
||||
videoHlsGenerationEnabled,
|
||||
videoThumbnailGenerationEnabled,
|
||||
audioProcessingEnabled,
|
||||
ffmpegPath: ffmpeg.path,
|
||||
ffmpegAvailable: ffmpeg.available,
|
||||
ffmpegVersion: ffmpeg.version,
|
||||
ffprobePath: ffprobe.path,
|
||||
ffprobeAvailable: ffprobe.available,
|
||||
ffprobeVersion: ffprobe.version,
|
||||
ffmpeg,
|
||||
ffprobe,
|
||||
},
|
||||
serving: {
|
||||
rangeRequests: true,
|
||||
immutableCacheSeconds: 31536000,
|
||||
hlsManifestCacheSeconds: 300,
|
||||
},
|
||||
staticServing: {
|
||||
uploadsPublicPath:
|
||||
storageHealth.uploadsPublicPath ?? storageHealth.publicPath ?? '/uploads',
|
||||
rangeRequestsExpected: true,
|
||||
cacheHeadersExpected: true,
|
||||
hlsMimeExpected: 'application/vnd.apple.mpegurl',
|
||||
},
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,7 +119,7 @@ export class MediaService {
|
||||
const client = await auth.getClient();
|
||||
const accessTokenRaw = await client.getAccessToken();
|
||||
const accessToken =
|
||||
typeof accessTokenRaw === 'string' ? accessTokenRaw : accessTokenRaw?.token ?? '';
|
||||
typeof accessTokenRaw === 'string' ? accessTokenRaw : (accessTokenRaw?.token ?? '');
|
||||
|
||||
if (!accessToken) {
|
||||
throw new ServiceUnavailableException('Failed to authenticate with Google Cloud');
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { extname } from 'path';
|
||||
import { Connection, Types } from 'mongoose';
|
||||
import { InjectConnection } from '@nestjs/mongoose';
|
||||
@@ -23,6 +29,7 @@ import {
|
||||
UploadedVideoFile,
|
||||
VideoProcessingService,
|
||||
} from '../../infrastructure/storage/video-processing.service';
|
||||
import { MediaProbeService } from '../../infrastructure/storage/media-probe.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { UsersRepository } from '../users/users.repository';
|
||||
@@ -39,12 +46,7 @@ import { PostsRepository } from './posts.repository';
|
||||
|
||||
type PostMediaMetadataInput = Pick<
|
||||
CreatePostDto,
|
||||
| 'durationSeconds'
|
||||
| 'thumbnailUrl'
|
||||
| 'style'
|
||||
| 'maqam'
|
||||
| 'rhythmSignature'
|
||||
| 'waveformPeaks'
|
||||
'durationSeconds' | 'thumbnailUrl' | 'style' | 'maqam' | 'rhythmSignature' | 'waveformPeaks'
|
||||
>;
|
||||
|
||||
type NormalizedPostMediaMetadata = {
|
||||
@@ -61,6 +63,7 @@ type SavedVideoUpload = {
|
||||
hlsUrl: string;
|
||||
thumbnailUrl: string;
|
||||
thumbnailVariants: PostMediaVariantSet | null;
|
||||
durationSeconds: number | null;
|
||||
};
|
||||
|
||||
type SavedImageUpload = {
|
||||
@@ -79,6 +82,7 @@ export class PostsService {
|
||||
private readonly storageService: ManagedStorageService,
|
||||
private readonly imageProcessingService: ImageProcessingService,
|
||||
private readonly videoProcessingService: VideoProcessingService,
|
||||
private readonly mediaProbeService: MediaProbeService,
|
||||
private readonly feedVersionService: FeedVersionService,
|
||||
private readonly notificationsService: NotificationsService,
|
||||
private readonly auditService: AuditService,
|
||||
@@ -87,7 +91,12 @@ export class PostsService {
|
||||
async create(
|
||||
userId: string,
|
||||
dto: CreatePostDto,
|
||||
imageFiles: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }> = [],
|
||||
imageFiles: Array<{
|
||||
mimetype?: string;
|
||||
size: number;
|
||||
buffer: Buffer;
|
||||
originalname?: string;
|
||||
}> = [],
|
||||
videoFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
|
||||
audioFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
|
||||
coverImageFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
|
||||
@@ -134,6 +143,9 @@ export class PostsService {
|
||||
const generatedThumbnailVariants = savedVideoUpload?.thumbnailVariants ?? null;
|
||||
const uploadedThumbnailUrl = savedCoverImageUpload?.primaryUrl ?? generatedThumbnailUrl;
|
||||
const uploadedThumbnailVariants = savedCoverImageUpload?.variants ?? generatedThumbnailVariants;
|
||||
const uploadedAudioDurationSeconds = audioFile
|
||||
? await this.mediaProbeService.extractDurationSecondsFromBuffer(audioFile.buffer, audioFile)
|
||||
: null;
|
||||
const uploadedAudioUrl = audioFile ? await this.saveMediaFile('audio', audioFile) : '';
|
||||
const finalImageUrls = uploadedImageUrls.length ? uploadedImageUrls : inputImageUrls;
|
||||
const finalImageVariants = uploadedImageVariants.length ? uploadedImageVariants : [];
|
||||
@@ -141,9 +153,18 @@ export class PostsService {
|
||||
const finalAudioUrl = uploadedAudioUrl || dto.audioUrl || '';
|
||||
const finalContent = dto.content?.trim() ?? '';
|
||||
const taggedUserIds = await this.normalizeTaggedUserIds(dto.taggedUserIds, userId);
|
||||
const collaboratorIds = await this.normalizeUserIdList(dto.collaboratorIds, userId, 5, 'collaborator');
|
||||
const collaboratorIds = await this.normalizeUserIdList(
|
||||
dto.collaboratorIds,
|
||||
userId,
|
||||
5,
|
||||
'collaborator',
|
||||
);
|
||||
const imageItems = this.buildImageItems(finalImageUrls, dto.imageCaptions, dto.imageAltTexts);
|
||||
const mentionResolution = await this.resolveMentionTargets(dto.mentionUsernames, finalContent, userId);
|
||||
const mentionResolution = await this.resolveMentionTargets(
|
||||
dto.mentionUsernames,
|
||||
finalContent,
|
||||
userId,
|
||||
);
|
||||
const { location, latitude, longitude } = this.normalizeLocation(dto);
|
||||
if (!finalContent && !finalImageUrls.length && !finalVideoUrl && !finalAudioUrl) {
|
||||
throw new BadRequestException('Post must contain caption or media');
|
||||
@@ -153,6 +174,7 @@ export class PostsService {
|
||||
const hashtags = this.extractHashtags(finalContent);
|
||||
const mediaMetadata = this.normalizeMediaMetadata(dto, postType, undefined, {
|
||||
audioSourceBuffer: audioFile?.buffer,
|
||||
extractedDurationSeconds: savedVideoUpload?.durationSeconds ?? uploadedAudioDurationSeconds,
|
||||
waveformSeed: finalAudioUrl || finalContent || `${userId}:${Date.now()}`,
|
||||
thumbnailUrl: uploadedThumbnailUrl,
|
||||
});
|
||||
@@ -186,7 +208,9 @@ export class PostsService {
|
||||
await Promise.all([
|
||||
...savedImageUploads.map((item) => this.deleteSavedImageAsset(item)),
|
||||
uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(),
|
||||
uploadedHlsUrl ? this.storageService.deleteContainingDirectory(uploadedHlsUrl) : Promise.resolve(),
|
||||
uploadedHlsUrl
|
||||
? this.storageService.deleteContainingDirectory(uploadedHlsUrl)
|
||||
: Promise.resolve(),
|
||||
uploadedThumbnailUrl
|
||||
? this.deleteThumbnailAsset(uploadedThumbnailUrl, uploadedThumbnailVariants)
|
||||
: Promise.resolve(),
|
||||
@@ -204,7 +228,12 @@ export class PostsService {
|
||||
|
||||
await this.usersRepository.incrementPostsCount(userId, 1);
|
||||
await this.feedVersionService.bumpGlobalVersion();
|
||||
await this.notifyMentionedUsers(userId, post.id, mentionResolution.mentionedUsers, finalContent);
|
||||
await this.notifyMentionedUsers(
|
||||
userId,
|
||||
post.id,
|
||||
mentionResolution.mentionedUsers,
|
||||
finalContent,
|
||||
);
|
||||
return post;
|
||||
}
|
||||
|
||||
@@ -212,7 +241,12 @@ export class PostsService {
|
||||
userId: string,
|
||||
postId: string,
|
||||
dto: UpdatePostDto,
|
||||
imageFiles: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }> = [],
|
||||
imageFiles: Array<{
|
||||
mimetype?: string;
|
||||
size: number;
|
||||
buffer: Buffer;
|
||||
originalname?: string;
|
||||
}> = [],
|
||||
videoFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
|
||||
audioFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
|
||||
coverImageFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
|
||||
@@ -249,7 +283,10 @@ export class PostsService {
|
||||
if (coverImageFile && (imageFiles.length || inputImageUrls.length)) {
|
||||
throw new BadRequestException('coverImageFile is allowed only with video or audio posts');
|
||||
}
|
||||
if (coverImageFile && !(videoFile || audioFile || dto.videoUrl || dto.audioUrl || post.videoUrl || post.audioUrl)) {
|
||||
if (
|
||||
coverImageFile &&
|
||||
!(videoFile || audioFile || dto.videoUrl || dto.audioUrl || post.videoUrl || post.audioUrl)
|
||||
) {
|
||||
throw new BadRequestException('coverImageFile is allowed only with video or audio posts');
|
||||
}
|
||||
if ((videoFile || dto.videoUrl) && (imageFiles.length || inputImageUrls.length)) {
|
||||
@@ -272,6 +309,9 @@ export class PostsService {
|
||||
const generatedThumbnailVariants = savedVideoUpload?.thumbnailVariants ?? null;
|
||||
const uploadedThumbnailUrl = savedCoverImageUpload?.primaryUrl ?? generatedThumbnailUrl;
|
||||
const uploadedThumbnailVariants = savedCoverImageUpload?.variants ?? generatedThumbnailVariants;
|
||||
const uploadedAudioDurationSeconds = audioFile
|
||||
? await this.mediaProbeService.extractDurationSecondsFromBuffer(audioFile.buffer, audioFile)
|
||||
: null;
|
||||
const uploadedAudioUrl = audioFile ? await this.saveMediaFile('audio', audioFile) : '';
|
||||
const hasImageUpdate = typeof dto.imageUrls !== 'undefined' || imageFiles.length > 0;
|
||||
const existingImageVariants = Array.isArray((post as any).imageVariants)
|
||||
@@ -286,7 +326,7 @@ export class PostsService {
|
||||
? imageFiles.length
|
||||
? uploadedImageUrls
|
||||
: inputImageUrls
|
||||
: post.imageUrls ?? [];
|
||||
: (post.imageUrls ?? []);
|
||||
const nextImageVariants = hasImageUpdate
|
||||
? imageFiles.length
|
||||
? uploadedImageVariants
|
||||
@@ -296,34 +336,39 @@ export class PostsService {
|
||||
const nextVideoUrl = hasVideoUpdate
|
||||
? videoFile
|
||||
? uploadedVideoUrl
|
||||
: dto.videoUrl ?? ''
|
||||
: post.videoUrl ?? '';
|
||||
const nextHlsUrl = hasVideoUpdate ? (videoFile ? uploadedHlsUrl : '') : post.hlsUrl ?? '';
|
||||
: (dto.videoUrl ?? '')
|
||||
: (post.videoUrl ?? '');
|
||||
const nextHlsUrl = hasVideoUpdate ? (videoFile ? uploadedHlsUrl : '') : (post.hlsUrl ?? '');
|
||||
const nextAudioUrl = hasAudioUpdate
|
||||
? audioFile
|
||||
? uploadedAudioUrl
|
||||
: dto.audioUrl ?? ''
|
||||
: post.audioUrl ?? '';
|
||||
const nextThumbnailVariants = coverImageFile || videoFile
|
||||
? uploadedThumbnailVariants
|
||||
: typeof dto.thumbnailUrl === 'string'
|
||||
? null
|
||||
: existingThumbnailVariants;
|
||||
: (dto.audioUrl ?? '')
|
||||
: (post.audioUrl ?? '');
|
||||
const nextThumbnailVariants =
|
||||
coverImageFile || videoFile
|
||||
? uploadedThumbnailVariants
|
||||
: typeof dto.thumbnailUrl === 'string'
|
||||
? null
|
||||
: existingThumbnailVariants;
|
||||
const nextPostType = this.resolvePostType(nextImageUrls, nextVideoUrl, nextAudioUrl);
|
||||
const nextContent = typeof dto.content === 'string' ? dto.content.trim() : post.content ?? '';
|
||||
const nextContent = typeof dto.content === 'string' ? dto.content.trim() : (post.content ?? '');
|
||||
const nextTaggedUserIds =
|
||||
typeof dto.taggedUserIds !== 'undefined'
|
||||
? await this.normalizeTaggedUserIds(dto.taggedUserIds, userId)
|
||||
: (post.taggedUserIds ?? []).map((id: Types.ObjectId | string) => new Types.ObjectId(id.toString()));
|
||||
: (post.taggedUserIds ?? []).map(
|
||||
(id: Types.ObjectId | string) => new Types.ObjectId(id.toString()),
|
||||
);
|
||||
const nextCollaboratorIds =
|
||||
typeof dto.collaboratorIds !== 'undefined'
|
||||
? await this.normalizeUserIdList(dto.collaboratorIds, userId, 5, 'collaborator')
|
||||
: (post.collaboratorIds ?? []).map((id: Types.ObjectId | string) => new Types.ObjectId(id.toString()));
|
||||
: (post.collaboratorIds ?? []).map(
|
||||
(id: Types.ObjectId | string) => new Types.ObjectId(id.toString()),
|
||||
);
|
||||
const nextImageItems = this.buildImageItems(
|
||||
nextImageUrls,
|
||||
dto.imageCaptions,
|
||||
dto.imageAltTexts,
|
||||
hasImageUpdate ? [] : post.imageItems ?? [],
|
||||
hasImageUpdate ? [] : (post.imageItems ?? []),
|
||||
);
|
||||
const previousMentionUsernames = this.normalizeMentionUsernames(post.mentionUsernames ?? []);
|
||||
const shouldRecomputeMentions =
|
||||
@@ -334,12 +379,15 @@ export class PostsService {
|
||||
mentionUsernames: previousMentionUsernames,
|
||||
mentionedUsers: [] as Array<{ id: string; username: string }>,
|
||||
};
|
||||
const { location: nextLocation, latitude: nextLatitude, longitude: nextLongitude } =
|
||||
this.normalizeLocation(dto, {
|
||||
location: post.location ?? '',
|
||||
latitude: post.latitude ?? null,
|
||||
longitude: post.longitude ?? null,
|
||||
});
|
||||
const {
|
||||
location: nextLocation,
|
||||
latitude: nextLatitude,
|
||||
longitude: nextLongitude,
|
||||
} = this.normalizeLocation(dto, {
|
||||
location: post.location ?? '',
|
||||
latitude: post.latitude ?? null,
|
||||
longitude: post.longitude ?? null,
|
||||
});
|
||||
if (!nextContent && !nextImageUrls.length && !nextVideoUrl && !nextAudioUrl) {
|
||||
throw new BadRequestException('Post must contain caption or media');
|
||||
}
|
||||
@@ -356,6 +404,7 @@ export class PostsService {
|
||||
},
|
||||
{
|
||||
audioSourceBuffer: audioFile?.buffer,
|
||||
extractedDurationSeconds: savedVideoUpload?.durationSeconds ?? uploadedAudioDurationSeconds,
|
||||
waveformSeed: nextAudioUrl || nextContent || post.id,
|
||||
thumbnailUrl: uploadedThumbnailUrl,
|
||||
},
|
||||
@@ -427,7 +476,9 @@ export class PostsService {
|
||||
await Promise.all([
|
||||
...savedImageUploads.map((item) => this.deleteSavedImageAsset(item)),
|
||||
uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(),
|
||||
uploadedHlsUrl ? this.storageService.deleteContainingDirectory(uploadedHlsUrl) : Promise.resolve(),
|
||||
uploadedHlsUrl
|
||||
? this.storageService.deleteContainingDirectory(uploadedHlsUrl)
|
||||
: Promise.resolve(),
|
||||
uploadedThumbnailUrl
|
||||
? this.deleteThumbnailAsset(uploadedThumbnailUrl, uploadedThumbnailVariants)
|
||||
: Promise.resolve(),
|
||||
@@ -442,7 +493,9 @@ export class PostsService {
|
||||
await Promise.all([
|
||||
...savedImageUploads.map((item) => this.deleteSavedImageAsset(item)),
|
||||
uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(),
|
||||
uploadedHlsUrl ? this.storageService.deleteContainingDirectory(uploadedHlsUrl) : Promise.resolve(),
|
||||
uploadedHlsUrl
|
||||
? this.storageService.deleteContainingDirectory(uploadedHlsUrl)
|
||||
: Promise.resolve(),
|
||||
uploadedThumbnailUrl
|
||||
? this.deleteThumbnailAsset(uploadedThumbnailUrl, uploadedThumbnailVariants)
|
||||
: Promise.resolve(),
|
||||
@@ -471,7 +524,10 @@ export class PostsService {
|
||||
}
|
||||
if (hasImageUpdate) {
|
||||
const nextImageSet = new Set(updated.imageUrls ?? []);
|
||||
const existingImageAssets = this.buildSavedImageAssets(post.imageUrls ?? [], existingImageVariants);
|
||||
const existingImageAssets = this.buildSavedImageAssets(
|
||||
post.imageUrls ?? [],
|
||||
existingImageVariants,
|
||||
);
|
||||
await Promise.all(
|
||||
existingImageAssets
|
||||
.filter((asset) => !nextImageSet.has(asset.primaryUrl))
|
||||
@@ -692,7 +748,10 @@ export class PostsService {
|
||||
});
|
||||
}
|
||||
|
||||
async registerView(userId: string, postId: string): Promise<{ success: true; postId: string; viewCount: number }> {
|
||||
async registerView(
|
||||
userId: string,
|
||||
postId: string,
|
||||
): Promise<{ success: true; postId: string; viewCount: number }> {
|
||||
const post = await this.postsRepository.findById(postId);
|
||||
if (!post) {
|
||||
throw new NotFoundException('Post not found');
|
||||
@@ -771,14 +830,18 @@ export class PostsService {
|
||||
): Promise<PostDocument> {
|
||||
await this.assertPostOwner(userId, postId);
|
||||
const updated = await this.postsRepository.updateById(postId, {
|
||||
...(typeof dto.commentsDisabled === 'boolean' ? { commentsDisabled: dto.commentsDisabled } : {}),
|
||||
...(typeof dto.commentsDisabled === 'boolean'
|
||||
? { commentsDisabled: dto.commentsDisabled }
|
||||
: {}),
|
||||
...(typeof dto.commentsFollowersOnly === 'boolean'
|
||||
? { commentsFollowersOnly: dto.commentsFollowersOnly }
|
||||
: {}),
|
||||
...(Array.isArray(dto.commentFilterKeywords)
|
||||
? {
|
||||
commentFilterKeywords: Array.from(
|
||||
new Set(dto.commentFilterKeywords.map((item) => item.trim().toLowerCase()).filter(Boolean)),
|
||||
new Set(
|
||||
dto.commentFilterKeywords.map((item) => item.trim().toLowerCase()).filter(Boolean),
|
||||
),
|
||||
).slice(0, 50),
|
||||
}
|
||||
: {}),
|
||||
@@ -809,7 +872,10 @@ export class PostsService {
|
||||
|
||||
async archive(userId: string, postId: string): Promise<PostDocument> {
|
||||
await this.assertPostOwner(userId, postId);
|
||||
const updated = await this.postsRepository.updateById(postId, { isArchived: true, pinnedToProfile: false });
|
||||
const updated = await this.postsRepository.updateById(postId, {
|
||||
isArchived: true,
|
||||
pinnedToProfile: false,
|
||||
});
|
||||
if (!updated) {
|
||||
throw new NotFoundException('Post not found');
|
||||
}
|
||||
@@ -896,7 +962,11 @@ export class PostsService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
private resolvePostType(imageUrls: string[] = [], videoUrl?: string, audioUrl?: string): PostType {
|
||||
private resolvePostType(
|
||||
imageUrls: string[] = [],
|
||||
videoUrl?: string,
|
||||
audioUrl?: string,
|
||||
): PostType {
|
||||
const hasImages = imageUrls.length > 0;
|
||||
const hasVideo = !!videoUrl?.trim();
|
||||
const hasAudio = !!audioUrl?.trim();
|
||||
@@ -933,6 +1003,7 @@ export class PostsService {
|
||||
},
|
||||
options: {
|
||||
audioSourceBuffer?: Buffer;
|
||||
extractedDurationSeconds?: number | null;
|
||||
waveformSeed?: string;
|
||||
thumbnailUrl?: string;
|
||||
} = {},
|
||||
@@ -942,7 +1013,9 @@ export class PostsService {
|
||||
|
||||
if (!supportsMediaMetadata) {
|
||||
if (this.hasMediaMetadataInput(dto)) {
|
||||
throw new BadRequestException('Audio/video metadata is allowed only for audio or video posts');
|
||||
throw new BadRequestException(
|
||||
'Audio/video metadata is allowed only for audio or video posts',
|
||||
);
|
||||
}
|
||||
return {
|
||||
durationSeconds: null,
|
||||
@@ -960,7 +1033,11 @@ export class PostsService {
|
||||
|
||||
return {
|
||||
durationSeconds:
|
||||
typeof dto.durationSeconds === 'number' ? dto.durationSeconds : fallback.durationSeconds,
|
||||
typeof options.extractedDurationSeconds === 'number'
|
||||
? options.extractedDurationSeconds
|
||||
: typeof dto.durationSeconds === 'number'
|
||||
? dto.durationSeconds
|
||||
: fallback.durationSeconds,
|
||||
thumbnailUrl:
|
||||
typeof dto.thumbnailUrl === 'string'
|
||||
? dto.thumbnailUrl.trim()
|
||||
@@ -1036,7 +1113,10 @@ export class PostsService {
|
||||
|
||||
const users = await this.usersRepository.findByUsernames(mergedMentionUsernames);
|
||||
const userByUsername = new Map(
|
||||
users.map((user) => [user.username.toLowerCase(), { id: user.id, username: user.username.toLowerCase() }]),
|
||||
users.map((user) => [
|
||||
user.username.toLowerCase(),
|
||||
{ id: user.id, username: user.username.toLowerCase() },
|
||||
]),
|
||||
);
|
||||
|
||||
const mentionedUsers = mergedMentionUsernames
|
||||
@@ -1088,8 +1168,14 @@ export class PostsService {
|
||||
): PostImageItem[] {
|
||||
return imageUrls.map((url, index) => ({
|
||||
url,
|
||||
caption: typeof captions?.[index] === 'string' ? captions[index].trim() : fallback[index]?.caption ?? '',
|
||||
altText: typeof altTexts?.[index] === 'string' ? altTexts[index].trim() : fallback[index]?.altText ?? '',
|
||||
caption:
|
||||
typeof captions?.[index] === 'string'
|
||||
? captions[index].trim()
|
||||
: (fallback[index]?.caption ?? ''),
|
||||
altText:
|
||||
typeof altTexts?.[index] === 'string'
|
||||
? altTexts[index].trim()
|
||||
: (fallback[index]?.altText ?? ''),
|
||||
order: index,
|
||||
}));
|
||||
}
|
||||
@@ -1187,10 +1273,15 @@ export class PostsService {
|
||||
await Promise.all(
|
||||
mentionedUsers.map(async (mentionedUser) => {
|
||||
try {
|
||||
await this.notificationsService.createMentionNotification(actorId, mentionedUser.id, postId, {
|
||||
resourceType: 'post',
|
||||
previewText: content.slice(0, 140),
|
||||
});
|
||||
await this.notificationsService.createMentionNotification(
|
||||
actorId,
|
||||
mentionedUser.id,
|
||||
postId,
|
||||
{
|
||||
resourceType: 'post',
|
||||
previewText: content.slice(0, 140),
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Mention notification failed for actor=${actorId} recipient=${mentionedUser.id}: ${
|
||||
@@ -1206,6 +1297,10 @@ export class PostsService {
|
||||
this.validateMediaFile('video', file);
|
||||
const optimized = await this.videoProcessingService.optimizeForPlayback(file);
|
||||
const extension = this.validateMediaFile('video', optimized.file);
|
||||
const durationSeconds = await this.mediaProbeService.extractDurationSecondsFromBuffer(
|
||||
optimized.file.buffer,
|
||||
optimized.file,
|
||||
);
|
||||
|
||||
let videoUrl = '';
|
||||
let hlsUrl = '';
|
||||
@@ -1241,18 +1336,24 @@ export class PostsService {
|
||||
thumbnailVariants = savedThumbnail.variants;
|
||||
}
|
||||
|
||||
return { videoUrl, hlsUrl, thumbnailUrl, thumbnailVariants };
|
||||
return { videoUrl, hlsUrl, thumbnailUrl, thumbnailVariants, durationSeconds };
|
||||
} catch (error) {
|
||||
await Promise.all([
|
||||
videoUrl ? this.deleteManagedPostMedia(videoUrl) : Promise.resolve(),
|
||||
hlsUrl ? this.storageService.deleteContainingDirectory(hlsUrl) : Promise.resolve(),
|
||||
thumbnailUrl ? this.deleteThumbnailAsset(thumbnailUrl, thumbnailVariants) : Promise.resolve(),
|
||||
thumbnailUrl
|
||||
? this.deleteThumbnailAsset(thumbnailUrl, thumbnailVariants)
|
||||
: Promise.resolve(),
|
||||
]);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createRepost(userId: string, sourcePostId: string, dto: CreateRepostDto): Promise<PostDocument> {
|
||||
async createRepost(
|
||||
userId: string,
|
||||
sourcePostId: string,
|
||||
dto: CreateRepostDto,
|
||||
): Promise<PostDocument> {
|
||||
const sourcePost = await this.postsRepository.findById(sourcePostId);
|
||||
if (!sourcePost) {
|
||||
throw new NotFoundException('Source post not found');
|
||||
@@ -1289,8 +1390,7 @@ export class PostsService {
|
||||
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';
|
||||
const folder = mediaType === 'image' ? 'images' : mediaType === 'video' ? 'videos' : 'audios';
|
||||
return this.storageService.saveFile({
|
||||
folderSegments: ['posts', folder],
|
||||
extension,
|
||||
@@ -1310,8 +1410,8 @@ export class PostsService {
|
||||
mediaType === 'image'
|
||||
? 'imageFiles must be jpg, jpeg, png, webp, or gif'
|
||||
: mediaType === 'video'
|
||||
? 'videoFile must be mp4, mov, webm, mkv, or avi'
|
||||
: 'audioFile must be mp3, wav, m4a, aac, ogg, or webm',
|
||||
? 'videoFile must be mp4, mov, webm, mkv, or avi'
|
||||
: 'audioFile must be mp3, wav, m4a, aac, ogg, or webm',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1326,8 +1426,8 @@ export class PostsService {
|
||||
mediaType === 'image'
|
||||
? 'Each image must be 10MB or less'
|
||||
: mediaType === 'video'
|
||||
? 'videoFile size must be 100MB or less'
|
||||
: 'audioFile size must be 20MB or less',
|
||||
? 'videoFile size must be 100MB or less'
|
||||
: 'audioFile size must be 20MB or less',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1539,7 +1639,8 @@ export class PostsService {
|
||||
thumbnailVariants?.originalUrl ||
|
||||
'';
|
||||
|
||||
const hasManagedVariantGroup = !!thumbnailVariants &&
|
||||
const hasManagedVariantGroup =
|
||||
!!thumbnailVariants &&
|
||||
[
|
||||
thumbnailVariants.originalUrl,
|
||||
thumbnailVariants.lowUrl,
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم