From acd8d0d8cfa7b30b44b6541c2e8c1b5b2a53aeaf Mon Sep 17 00:00:00 2001 From: boutmoun123 Date: Mon, 25 May 2026 23:18:00 +0300 Subject: [PATCH] Improve backend media readiness for mobile clients --- src/common/enums/processing-status.enum.ts | 6 + src/common/utils/post-media-response.util.ts | 105 ++++++++++++++++++ .../storage/managed-storage.service.ts | 68 +++++++++++- src/main.ts | 11 ++ src/modules/media/media.controller.ts | 9 +- src/modules/media/media.service.ts | 24 ++++ src/modules/posts/posts.service.ts | 4 + src/modules/posts/schemas/post.schema.ts | 13 +++ 8 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 src/common/enums/processing-status.enum.ts create mode 100644 src/common/utils/post-media-response.util.ts diff --git a/src/common/enums/processing-status.enum.ts b/src/common/enums/processing-status.enum.ts new file mode 100644 index 0000000..857f8d1 --- /dev/null +++ b/src/common/enums/processing-status.enum.ts @@ -0,0 +1,6 @@ +export enum ProcessingStatus { + PENDING = 'pending', + PROCESSING = 'processing', + READY = 'ready', + FAILED = 'failed', +} diff --git a/src/common/utils/post-media-response.util.ts b/src/common/utils/post-media-response.util.ts new file mode 100644 index 0000000..e7a010a --- /dev/null +++ b/src/common/utils/post-media-response.util.ts @@ -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 => + 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, 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 = 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, + }; +}; diff --git a/src/infrastructure/storage/managed-storage.service.ts b/src/infrastructure/storage/managed-storage.service.ts index d49bc29..d661066 100644 --- a/src/infrastructure/storage/managed-storage.service.ts +++ b/src/infrastructure/storage/managed-storage.service.ts @@ -8,7 +8,8 @@ import { } from '@aws-sdk/client-s3'; import { Upload } from '@aws-sdk/lib-storage'; 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'; @Injectable() @@ -182,6 +183,53 @@ export class ManagedStorageService implements OnModuleDestroy { await rm(join(process.cwd(), ...directoryPath.split('/')), { recursive: true, force: true }); } + async getHealth(): Promise> { + const provider = this.getProvider(); + const basePath = this.getBasePath(); + const publicBaseUrl = + (this.configService.get('storage.publicBaseUrl', { infer: true }) ?? '').replace( + /\/$/, + '', + ); + const health: Record = { + 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 { this.s3Client = null; } @@ -323,6 +371,24 @@ export class ManagedStorageService implements OnModuleDestroy { return null; } + private hasS3Configuration(): boolean { + return !!( + (this.configService.get('storage.s3.bucket', { infer: true }) ?? '') && + (this.configService.get('storage.s3.endpoint', { infer: true }) ?? '') && + (this.configService.get('storage.s3.accessKeyId', { infer: true }) ?? '') && + (this.configService.get('storage.s3.secretAccessKey', { infer: true }) ?? '') + ); + } + + private async canAccess(path: string, mode: number): Promise { + try { + await access(path, mode); + return true; + } catch { + return false; + } + } + private async deleteS3Prefix(prefix: string): Promise { const client = this.getS3Client(); const bucket = this.getS3Bucket(); diff --git a/src/main.ts b/src/main.ts index eece56d..fcf2951 100644 --- a/src/main.ts +++ b/src/main.ts @@ -142,6 +142,15 @@ async function bootstrap(): Promise { app.enableCors({ origin: corsOrigins.length ? corsOrigins : true, credentials: true, + exposedHeaders: [ + 'Accept-Ranges', + 'Content-Length', + 'Content-Range', + 'Content-Type', + 'Cache-Control', + 'ETag', + 'Last-Modified', + ], }); app.setGlobalPrefix(configService.get('globalPrefix', 'api/v1')); @@ -197,6 +206,8 @@ async function bootstrap(): Promise { if (mediaHeaders.acceptRanges) { res.setHeader('Accept-Ranges', 'bytes'); } + res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); + res.setHeader('X-Content-Type-Options', 'nosniff'); }, }), ); diff --git a/src/modules/media/media.controller.ts b/src/modules/media/media.controller.ts index 28f3907..b4c4fe1 100644 --- a/src/modules/media/media.controller.ts +++ b/src/modules/media/media.controller.ts @@ -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 { Throttle } from '../../common/decorators/throttle.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; @@ -12,6 +12,13 @@ import { MediaService } from './media.service'; export class MediaController { constructor(private readonly mediaService: MediaService) {} + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Get('health') + async mediaHealth() { + return this.mediaService.getMediaHealth(); + } + @ApiBearerAuth() @UseGuards(JwtAuthGuard) @Post('ai/text-to-music') diff --git a/src/modules/media/media.service.ts b/src/modules/media/media.service.ts index c9902c2..30b9259 100644 --- a/src/modules/media/media.service.ts +++ b/src/modules/media/media.service.ts @@ -16,6 +16,30 @@ export class MediaService { private readonly storageService: ManagedStorageService, ) {} + async getMediaHealth() { + return { + storage: await this.storageService.getHealth(), + processing: { + imageProcessingEnabled: + this.configService.get('imageProcessing.enabled', { infer: true }) ?? false, + videoProcessingEnabled: + this.configService.get('videoProcessing.enabled', { infer: true }) ?? false, + videoHlsGenerationEnabled: + this.configService.get('videoProcessing.generateHls', { infer: true }) ?? true, + videoThumbnailGenerationEnabled: + this.configService.get('videoProcessing.generateThumbnails', { infer: true }) ?? + true, + ffmpegPath: + this.configService.get('videoProcessing.ffmpegPath', { infer: true }) ?? 'ffmpeg', + }, + serving: { + rangeRequests: true, + immutableCacheSeconds: 31536000, + hlsManifestCacheSeconds: 300, + }, + }; + } + async generateMusicFromText(userId: string, dto: TextToMusicDto) { const enabled = this.configService.get('aiMusic.enabled', { infer: true }); if (!enabled) { diff --git a/src/modules/posts/posts.service.ts b/src/modules/posts/posts.service.ts index 7d533a2..79d3a0a 100644 --- a/src/modules/posts/posts.service.ts +++ b/src/modules/posts/posts.service.ts @@ -4,6 +4,7 @@ import { Types } from 'mongoose'; import { ModerationStatus } from '../../common/enums/moderation-status.enum'; import { PostType } from '../../common/enums/post-type.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 { resolveMongoSortDirection } from '../../common/utils/sort.util'; import { @@ -172,6 +173,7 @@ export class PostsService { latitude, longitude, postType, + processingStatus: ProcessingStatus.READY, visibility: dto.visibility ?? PostVisibility.PUBLIC, commentsDisabled: dto.commentsDisabled ?? false, commentsFollowersOnly: dto.commentsFollowersOnly ?? false, @@ -372,6 +374,7 @@ export class PostsService { latitude: nextLatitude, longitude: nextLongitude, postType: nextPostType, + processingStatus: ProcessingStatus.READY, ...mediaMetadata, }; @@ -1247,6 +1250,7 @@ export class PostsService { const post = await this.postsRepository.create(userId, { content, postType: PostType.TEXT, + processingStatus: ProcessingStatus.READY, visibility: dto.visibility ?? PostVisibility.PUBLIC, repostOfPostId: content ? null : new Types.ObjectId(sourcePostId), quoteOfPostId: content ? new Types.ObjectId(sourcePostId) : null, diff --git a/src/modules/posts/schemas/post.schema.ts b/src/modules/posts/schemas/post.schema.ts index 3e77c34..90adfa4 100644 --- a/src/modules/posts/schemas/post.schema.ts +++ b/src/modules/posts/schemas/post.schema.ts @@ -3,6 +3,8 @@ import { HydratedDocument, Types } from 'mongoose'; import { ModerationStatus } from '../../../common/enums/moderation-status.enum'; import { PostType } from '../../../common/enums/post-type.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 { resolveManagedFileUrl, resolveManagedFileUrlRecord, @@ -115,6 +117,14 @@ export class Post { @Prop({ enum: PostType, default: PostType.TEXT, index: true }) postType!: PostType; + @Prop({ + type: String, + enum: Object.values(ProcessingStatus), + default: ProcessingStatus.READY, + index: true, + }) + processingStatus!: ProcessingStatus; + @Prop({ enum: PostVisibility, default: PostVisibility.PUBLIC, index: true }) visibility!: PostVisibility; @@ -182,6 +192,7 @@ PostSchema.index({ repostOfPostId: 1, createdAt: -1 }); PostSchema.index({ quoteOfPostId: 1, createdAt: -1 }); PostSchema.index({ visibility: 1, createdAt: -1 }); PostSchema.index({ postType: 1, createdAt: -1 }); +PostSchema.index({ processingStatus: 1, createdAt: -1 }); PostSchema.index({ hashtags: 1, createdAt: -1 }); PostSchema.index({ taggedUserIds: 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.thumbnailUrl = resolveManagedFileUrl(ret.thumbnailUrl); ret.thumbnailVariants = resolveManagedFileUrlRecord(ret.thumbnailVariants); + ret.processingStatus = ret.processingStatus ?? ProcessingStatus.READY; + ret.media = buildPostMediaResponse(ret); return ret; };