Improve backend media readiness for mobile clients
بعض الفحوصات معلقة
Deploy To Ghaymah / deploy (push) Waiting to run
بعض الفحوصات معلقة
Deploy To Ghaymah / deploy (push) Waiting to run
هذا الالتزام موجود في:
6
src/common/enums/processing-status.enum.ts
Normal file
6
src/common/enums/processing-status.enum.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export enum ProcessingStatus {
|
||||||
|
PENDING = 'pending',
|
||||||
|
PROCESSING = 'processing',
|
||||||
|
READY = 'ready',
|
||||||
|
FAILED = 'failed',
|
||||||
|
}
|
||||||
105
src/common/utils/post-media-response.util.ts
Normal file
105
src/common/utils/post-media-response.util.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { PostType } from '../enums/post-type.enum';
|
||||||
|
|
||||||
|
type VariantSet = {
|
||||||
|
originalUrl?: string;
|
||||||
|
lowUrl?: string;
|
||||||
|
mediumUrl?: string;
|
||||||
|
highUrl?: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
type PostMediaInput = {
|
||||||
|
postType?: PostType | string;
|
||||||
|
imageUrls?: string[];
|
||||||
|
imageItems?: Array<{ url?: string; caption?: string; altText?: string; order?: number }>;
|
||||||
|
imageVariants?: VariantSet[];
|
||||||
|
videoUrl?: string;
|
||||||
|
hlsUrl?: string;
|
||||||
|
audioUrl?: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
thumbnailVariants?: VariantSet;
|
||||||
|
waveformPeaks?: number[];
|
||||||
|
durationSeconds?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstNonEmpty = (...values: Array<string | undefined | null>): string =>
|
||||||
|
values.find((value): value is string => typeof value === 'string' && value.trim().length > 0) ?? '';
|
||||||
|
|
||||||
|
const normalizeVariantSet = (variants: VariantSet) => {
|
||||||
|
if (!variants) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
originalUrl: variants.originalUrl ?? '',
|
||||||
|
lowUrl: variants.lowUrl ?? variants.mediumUrl ?? variants.highUrl ?? variants.originalUrl ?? '',
|
||||||
|
mediumUrl: variants.mediumUrl ?? variants.highUrl ?? variants.lowUrl ?? variants.originalUrl ?? '',
|
||||||
|
highUrl: variants.highUrl ?? variants.mediumUrl ?? variants.originalUrl ?? variants.lowUrl ?? '',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const preferredImageUrl = (variants: ReturnType<typeof normalizeVariantSet>, fallback = ''): string =>
|
||||||
|
firstNonEmpty(variants?.mediumUrl, variants?.highUrl, variants?.lowUrl, variants?.originalUrl, fallback);
|
||||||
|
|
||||||
|
export const buildPostMediaResponse = (post: PostMediaInput) => {
|
||||||
|
const imageUrls = Array.isArray(post.imageUrls) ? post.imageUrls : [];
|
||||||
|
const imageItems = Array.isArray(post.imageItems) ? post.imageItems : [];
|
||||||
|
const imageVariants = Array.isArray(post.imageVariants) ? post.imageVariants : [];
|
||||||
|
const thumbnailVariants = normalizeVariantSet(post.thumbnailVariants ?? null);
|
||||||
|
const fallbackImageItems: NonNullable<PostMediaInput['imageItems']> = imageUrls.map((url, index) => ({
|
||||||
|
url,
|
||||||
|
caption: '',
|
||||||
|
altText: '',
|
||||||
|
order: index,
|
||||||
|
}));
|
||||||
|
const images = (imageItems.length ? imageItems : fallbackImageItems).map(
|
||||||
|
(item, index) => {
|
||||||
|
const variants = normalizeVariantSet(imageVariants[index] ?? null);
|
||||||
|
const url = item.url || imageUrls[index] || '';
|
||||||
|
return {
|
||||||
|
url: preferredImageUrl(variants, url),
|
||||||
|
originalUrl: firstNonEmpty(variants?.originalUrl, url),
|
||||||
|
variants,
|
||||||
|
caption: item.caption ?? '',
|
||||||
|
altText: item.altText ?? '',
|
||||||
|
order: typeof item.order === 'number' ? item.order : index,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const mediaType =
|
||||||
|
post.postType === PostType.IMAGE && images.length
|
||||||
|
? PostType.IMAGE
|
||||||
|
: post.postType === PostType.VIDEO || post.videoUrl || post.hlsUrl
|
||||||
|
? PostType.VIDEO
|
||||||
|
: post.postType === PostType.AUDIO || post.audioUrl
|
||||||
|
? PostType.AUDIO
|
||||||
|
: PostType.TEXT;
|
||||||
|
const thumbnailUrl = preferredImageUrl(thumbnailVariants, post.thumbnailUrl ?? '');
|
||||||
|
const preferredPlaybackUrl =
|
||||||
|
mediaType === PostType.VIDEO
|
||||||
|
? firstNonEmpty(post.hlsUrl, post.videoUrl)
|
||||||
|
: mediaType === PostType.AUDIO
|
||||||
|
? post.audioUrl ?? ''
|
||||||
|
: '';
|
||||||
|
const displayUrl =
|
||||||
|
mediaType === PostType.IMAGE
|
||||||
|
? images[0]?.url ?? ''
|
||||||
|
: mediaType === PostType.VIDEO || mediaType === PostType.AUDIO
|
||||||
|
? thumbnailUrl
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
mediaType,
|
||||||
|
displayUrl,
|
||||||
|
thumbnailUrl,
|
||||||
|
thumbnailVariants,
|
||||||
|
preferredPlaybackUrl,
|
||||||
|
hlsUrl: post.hlsUrl ?? '',
|
||||||
|
videoUrl: post.videoUrl ?? '',
|
||||||
|
audioUrl: post.audioUrl ?? '',
|
||||||
|
images,
|
||||||
|
durationSeconds: post.durationSeconds ?? null,
|
||||||
|
waveformPeaks: Array.isArray(post.waveformPeaks) ? post.waveformPeaks : [],
|
||||||
|
isPlayable: mediaType === PostType.VIDEO ? !!preferredPlaybackUrl : mediaType === PostType.AUDIO ? !!post.audioUrl : false,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
} from '@aws-sdk/client-s3';
|
} from '@aws-sdk/client-s3';
|
||||||
import { Upload } from '@aws-sdk/lib-storage';
|
import { Upload } from '@aws-sdk/lib-storage';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { mkdir, rm, unlink, writeFile } from 'fs/promises';
|
import { constants } from 'fs';
|
||||||
|
import { access, mkdir, rm, unlink, writeFile } from 'fs/promises';
|
||||||
import { dirname, join, posix } from 'path';
|
import { dirname, join, posix } from 'path';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -182,6 +183,53 @@ export class ManagedStorageService implements OnModuleDestroy {
|
|||||||
await rm(join(process.cwd(), ...directoryPath.split('/')), { recursive: true, force: true });
|
await rm(join(process.cwd(), ...directoryPath.split('/')), { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getHealth(): Promise<Record<string, unknown>> {
|
||||||
|
const provider = this.getProvider();
|
||||||
|
const basePath = this.getBasePath();
|
||||||
|
const publicBaseUrl =
|
||||||
|
(this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? '').replace(
|
||||||
|
/\/$/,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
const health: Record<string, unknown> = {
|
||||||
|
provider,
|
||||||
|
basePath,
|
||||||
|
publicPath: `/${basePath}`,
|
||||||
|
publicBaseUrlConfigured: !!publicBaseUrl,
|
||||||
|
s3Configured: provider === 's3' ? this.hasS3Configuration() : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (provider === 'local') {
|
||||||
|
const uploadDir = join(process.cwd(), ...basePath.split('/'));
|
||||||
|
const probePath = join(uploadDir, `.media-health-${randomUUID()}.tmp`);
|
||||||
|
let writable = false;
|
||||||
|
let error = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(uploadDir, { recursive: true });
|
||||||
|
await writeFile(probePath, 'ok');
|
||||||
|
await unlink(probePath);
|
||||||
|
writable = true;
|
||||||
|
} catch (probeError) {
|
||||||
|
error = probeError instanceof Error ? probeError.message : 'unknown local storage error';
|
||||||
|
try {
|
||||||
|
await unlink(probePath);
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup failure for a failed probe.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
health.local = {
|
||||||
|
runtimePath: uploadDir,
|
||||||
|
writable,
|
||||||
|
readable: await this.canAccess(uploadDir, constants.R_OK),
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return health;
|
||||||
|
}
|
||||||
|
|
||||||
onModuleDestroy(): void {
|
onModuleDestroy(): void {
|
||||||
this.s3Client = null;
|
this.s3Client = null;
|
||||||
}
|
}
|
||||||
@@ -323,6 +371,24 @@ export class ManagedStorageService implements OnModuleDestroy {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private hasS3Configuration(): boolean {
|
||||||
|
return !!(
|
||||||
|
(this.configService.get<string>('storage.s3.bucket', { infer: true }) ?? '') &&
|
||||||
|
(this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? '') &&
|
||||||
|
(this.configService.get<string>('storage.s3.accessKeyId', { infer: true }) ?? '') &&
|
||||||
|
(this.configService.get<string>('storage.s3.secretAccessKey', { infer: true }) ?? '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async canAccess(path: string, mode: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await access(path, mode);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async deleteS3Prefix(prefix: string): Promise<void> {
|
private async deleteS3Prefix(prefix: string): Promise<void> {
|
||||||
const client = this.getS3Client();
|
const client = this.getS3Client();
|
||||||
const bucket = this.getS3Bucket();
|
const bucket = this.getS3Bucket();
|
||||||
|
|||||||
11
src/main.ts
11
src/main.ts
@@ -142,6 +142,15 @@ async function bootstrap(): Promise<void> {
|
|||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: corsOrigins.length ? corsOrigins : true,
|
origin: corsOrigins.length ? corsOrigins : true,
|
||||||
credentials: true,
|
credentials: true,
|
||||||
|
exposedHeaders: [
|
||||||
|
'Accept-Ranges',
|
||||||
|
'Content-Length',
|
||||||
|
'Content-Range',
|
||||||
|
'Content-Type',
|
||||||
|
'Cache-Control',
|
||||||
|
'ETag',
|
||||||
|
'Last-Modified',
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
app.setGlobalPrefix(configService.get<string>('globalPrefix', 'api/v1'));
|
app.setGlobalPrefix(configService.get<string>('globalPrefix', 'api/v1'));
|
||||||
@@ -197,6 +206,8 @@ async function bootstrap(): Promise<void> {
|
|||||||
if (mediaHeaders.acceptRanges) {
|
if (mediaHeaders.acceptRanges) {
|
||||||
res.setHeader('Accept-Ranges', 'bytes');
|
res.setHeader('Accept-Ranges', 'bytes');
|
||||||
}
|
}
|
||||||
|
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
import { Throttle } from '../../common/decorators/throttle.decorator';
|
import { Throttle } from '../../common/decorators/throttle.decorator';
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
@@ -12,6 +12,13 @@ import { MediaService } from './media.service';
|
|||||||
export class MediaController {
|
export class MediaController {
|
||||||
constructor(private readonly mediaService: MediaService) {}
|
constructor(private readonly mediaService: MediaService) {}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Get('health')
|
||||||
|
async mediaHealth() {
|
||||||
|
return this.mediaService.getMediaHealth();
|
||||||
|
}
|
||||||
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Post('ai/text-to-music')
|
@Post('ai/text-to-music')
|
||||||
|
|||||||
@@ -16,6 +16,30 @@ export class MediaService {
|
|||||||
private readonly storageService: ManagedStorageService,
|
private readonly storageService: ManagedStorageService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
async getMediaHealth() {
|
||||||
|
return {
|
||||||
|
storage: await this.storageService.getHealth(),
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
serving: {
|
||||||
|
rangeRequests: true,
|
||||||
|
immutableCacheSeconds: 31536000,
|
||||||
|
hlsManifestCacheSeconds: 300,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async generateMusicFromText(userId: string, dto: TextToMusicDto) {
|
async generateMusicFromText(userId: string, dto: TextToMusicDto) {
|
||||||
const enabled = this.configService.get<boolean>('aiMusic.enabled', { infer: true });
|
const enabled = this.configService.get<boolean>('aiMusic.enabled', { infer: true });
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Types } from 'mongoose';
|
|||||||
import { ModerationStatus } from '../../common/enums/moderation-status.enum';
|
import { ModerationStatus } from '../../common/enums/moderation-status.enum';
|
||||||
import { PostType } from '../../common/enums/post-type.enum';
|
import { PostType } from '../../common/enums/post-type.enum';
|
||||||
import { PostVisibility } from '../../common/enums/post-visibility.enum';
|
import { PostVisibility } from '../../common/enums/post-visibility.enum';
|
||||||
|
import { ProcessingStatus } from '../../common/enums/processing-status.enum';
|
||||||
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
|
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
|
||||||
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
|
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
|
||||||
import {
|
import {
|
||||||
@@ -172,6 +173,7 @@ export class PostsService {
|
|||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
postType,
|
postType,
|
||||||
|
processingStatus: ProcessingStatus.READY,
|
||||||
visibility: dto.visibility ?? PostVisibility.PUBLIC,
|
visibility: dto.visibility ?? PostVisibility.PUBLIC,
|
||||||
commentsDisabled: dto.commentsDisabled ?? false,
|
commentsDisabled: dto.commentsDisabled ?? false,
|
||||||
commentsFollowersOnly: dto.commentsFollowersOnly ?? false,
|
commentsFollowersOnly: dto.commentsFollowersOnly ?? false,
|
||||||
@@ -372,6 +374,7 @@ export class PostsService {
|
|||||||
latitude: nextLatitude,
|
latitude: nextLatitude,
|
||||||
longitude: nextLongitude,
|
longitude: nextLongitude,
|
||||||
postType: nextPostType,
|
postType: nextPostType,
|
||||||
|
processingStatus: ProcessingStatus.READY,
|
||||||
...mediaMetadata,
|
...mediaMetadata,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1247,6 +1250,7 @@ export class PostsService {
|
|||||||
const post = await this.postsRepository.create(userId, {
|
const post = await this.postsRepository.create(userId, {
|
||||||
content,
|
content,
|
||||||
postType: PostType.TEXT,
|
postType: PostType.TEXT,
|
||||||
|
processingStatus: ProcessingStatus.READY,
|
||||||
visibility: dto.visibility ?? PostVisibility.PUBLIC,
|
visibility: dto.visibility ?? PostVisibility.PUBLIC,
|
||||||
repostOfPostId: content ? null : new Types.ObjectId(sourcePostId),
|
repostOfPostId: content ? null : new Types.ObjectId(sourcePostId),
|
||||||
quoteOfPostId: content ? new Types.ObjectId(sourcePostId) : null,
|
quoteOfPostId: content ? new Types.ObjectId(sourcePostId) : null,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { HydratedDocument, Types } from 'mongoose';
|
|||||||
import { ModerationStatus } from '../../../common/enums/moderation-status.enum';
|
import { ModerationStatus } from '../../../common/enums/moderation-status.enum';
|
||||||
import { PostType } from '../../../common/enums/post-type.enum';
|
import { PostType } from '../../../common/enums/post-type.enum';
|
||||||
import { PostVisibility } from '../../../common/enums/post-visibility.enum';
|
import { PostVisibility } from '../../../common/enums/post-visibility.enum';
|
||||||
|
import { ProcessingStatus } from '../../../common/enums/processing-status.enum';
|
||||||
|
import { buildPostMediaResponse } from '../../../common/utils/post-media-response.util';
|
||||||
import {
|
import {
|
||||||
resolveManagedFileUrl,
|
resolveManagedFileUrl,
|
||||||
resolveManagedFileUrlRecord,
|
resolveManagedFileUrlRecord,
|
||||||
@@ -115,6 +117,14 @@ export class Post {
|
|||||||
@Prop({ enum: PostType, default: PostType.TEXT, index: true })
|
@Prop({ enum: PostType, default: PostType.TEXT, index: true })
|
||||||
postType!: PostType;
|
postType!: PostType;
|
||||||
|
|
||||||
|
@Prop({
|
||||||
|
type: String,
|
||||||
|
enum: Object.values(ProcessingStatus),
|
||||||
|
default: ProcessingStatus.READY,
|
||||||
|
index: true,
|
||||||
|
})
|
||||||
|
processingStatus!: ProcessingStatus;
|
||||||
|
|
||||||
@Prop({ enum: PostVisibility, default: PostVisibility.PUBLIC, index: true })
|
@Prop({ enum: PostVisibility, default: PostVisibility.PUBLIC, index: true })
|
||||||
visibility!: PostVisibility;
|
visibility!: PostVisibility;
|
||||||
|
|
||||||
@@ -182,6 +192,7 @@ PostSchema.index({ repostOfPostId: 1, createdAt: -1 });
|
|||||||
PostSchema.index({ quoteOfPostId: 1, createdAt: -1 });
|
PostSchema.index({ quoteOfPostId: 1, createdAt: -1 });
|
||||||
PostSchema.index({ visibility: 1, createdAt: -1 });
|
PostSchema.index({ visibility: 1, createdAt: -1 });
|
||||||
PostSchema.index({ postType: 1, createdAt: -1 });
|
PostSchema.index({ postType: 1, createdAt: -1 });
|
||||||
|
PostSchema.index({ processingStatus: 1, createdAt: -1 });
|
||||||
PostSchema.index({ hashtags: 1, createdAt: -1 });
|
PostSchema.index({ hashtags: 1, createdAt: -1 });
|
||||||
PostSchema.index({ taggedUserIds: 1, createdAt: -1 });
|
PostSchema.index({ taggedUserIds: 1, createdAt: -1 });
|
||||||
PostSchema.index({ collaboratorIds: 1, createdAt: -1 });
|
PostSchema.index({ collaboratorIds: 1, createdAt: -1 });
|
||||||
@@ -216,6 +227,8 @@ const transformManagedPostFiles = (_doc: unknown, ret: any) => {
|
|||||||
ret.audioUrl = resolveManagedFileUrl(ret.audioUrl);
|
ret.audioUrl = resolveManagedFileUrl(ret.audioUrl);
|
||||||
ret.thumbnailUrl = resolveManagedFileUrl(ret.thumbnailUrl);
|
ret.thumbnailUrl = resolveManagedFileUrl(ret.thumbnailUrl);
|
||||||
ret.thumbnailVariants = resolveManagedFileUrlRecord(ret.thumbnailVariants);
|
ret.thumbnailVariants = resolveManagedFileUrlRecord(ret.thumbnailVariants);
|
||||||
|
ret.processingStatus = ret.processingStatus ?? ProcessingStatus.READY;
|
||||||
|
ret.media = buildPostMediaResponse(ret);
|
||||||
return ret;
|
return ret;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
المرجع في مشكلة جديدة
حظر مستخدم