- {snapshot.recentActivity.map((item, index) => (
+ {snapshot.recentActivity.slice(0, 5).map((item, index) => (
-
{item.title}
+
+
+
+
+
+ {item.title || "Untitled activity"}
+
+
- {item.type}
+ {getActivityLabel(item.type)}
-
- {item.subtitle} • {formatDateTime(item.createdAt)}
+
+ {item.subtitle || "No additional details"} - {formatDateTime(item.createdAt)}
))}
diff --git a/oudelaa_dashboard/lib/format.ts b/oudelaa_dashboard/lib/format.ts
index 2d2b831..6d2f6bc 100644
--- a/oudelaa_dashboard/lib/format.ts
+++ b/oudelaa_dashboard/lib/format.ts
@@ -1,9 +1,13 @@
export function formatDateTime(value?: string | null) {
if (!value) return "-";
try {
- return new Intl.DateTimeFormat("ar-SA", {
- dateStyle: "medium",
- timeStyle: "short",
+ return new Intl.DateTimeFormat("en-US", {
+ year: "numeric",
+ month: "short",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: true,
}).format(new Date(value));
} catch {
return value;
diff --git a/src/config/configuration.ts b/src/config/configuration.ts
index 09f64db..f753b9e 100644
--- a/src/config/configuration.ts
+++ b/src/config/configuration.ts
@@ -3,8 +3,7 @@ export default () => ({
port: Number(process.env.PORT ?? 4000),
host: process.env.HOST ?? '0.0.0.0',
publicBaseUrl:
- process.env.PUBLIC_BASE_URL ??
- `http://localhost:${Number(process.env.PORT ?? 4000)}`,
+ process.env.PUBLIC_BASE_URL ?? `http://localhost:${Number(process.env.PORT ?? 4000)}`,
responseEnvelopeEnabled:
(process.env.RESPONSE_ENVELOPE_ENABLED ?? 'false').toLowerCase() === 'true',
globalPrefix: process.env.GLOBAL_PREFIX ?? 'api/v1',
@@ -76,8 +75,7 @@ export default () => ({
name: process.env.QUEUE_NAME ?? 'app-jobs',
defaultJobAttempts: Number(process.env.QUEUE_DEFAULT_ATTEMPTS ?? 3),
defaultJobBackoffMs: Number(process.env.QUEUE_DEFAULT_BACKOFF_MS ?? 1000),
- removeOnComplete:
- (process.env.QUEUE_REMOVE_ON_COMPLETE ?? 'true').toLowerCase() === 'true',
+ removeOnComplete: (process.env.QUEUE_REMOVE_ON_COMPLETE ?? 'true').toLowerCase() === 'true',
workerConcurrency: Number(process.env.QUEUE_WORKER_CONCURRENCY ?? 5),
},
storage: {
@@ -90,8 +88,7 @@ export default () => ({
endpoint: process.env.S3_ENDPOINT ?? '',
accessKeyId: process.env.S3_ACCESS_KEY_ID ?? '',
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? '',
- forcePathStyle:
- (process.env.S3_FORCE_PATH_STYLE ?? 'false').toLowerCase() === 'true',
+ forcePathStyle: (process.env.S3_FORCE_PATH_STYLE ?? 'false').toLowerCase() === 'true',
},
},
imageProcessing: {
@@ -125,6 +122,10 @@ export default () => ({
),
thumbnailWidth: Number(process.env.VIDEO_PROCESSING_THUMBNAIL_WIDTH ?? 720),
},
+ audioProcessing: {
+ enabled: (process.env.AUDIO_PROCESSING_ENABLED ?? 'false').toLowerCase() === 'true',
+ waveformPeaks: Number(process.env.AUDIO_WAVEFORM_PEAKS ?? 48),
+ },
logging: {
level: process.env.LOG_LEVEL ?? 'log',
requestEnabled: (process.env.REQUEST_LOGGING_ENABLED ?? 'true').toLowerCase() === 'true',
diff --git a/src/config/validation.schema.ts b/src/config/validation.schema.ts
index 058ce12..f31fd56 100644
--- a/src/config/validation.schema.ts
+++ b/src/config/validation.schema.ts
@@ -81,6 +81,8 @@ export const validationSchema = Joi.object({
VIDEO_PROCESSING_GENERATE_HLS: Joi.boolean().truthy('true').falsy('false').default(true),
VIDEO_PROCESSING_HLS_SEGMENT_DURATION_SECONDS: Joi.number().min(2).max(20).default(4),
VIDEO_PROCESSING_THUMBNAIL_WIDTH: Joi.number().min(160).max(1920).default(720),
+ AUDIO_PROCESSING_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
+ AUDIO_WAVEFORM_PEAKS: Joi.number().min(16).max(256).default(48),
LOG_LEVEL: Joi.string().valid('error', 'warn', 'log', 'debug', 'verbose').default('log'),
REQUEST_LOGGING_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true),
FEED_CACHE_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true),
diff --git a/src/infrastructure/storage/managed-storage.service.ts b/src/infrastructure/storage/managed-storage.service.ts
index d661066..ecec386 100644
--- a/src/infrastructure/storage/managed-storage.service.ts
+++ b/src/infrastructure/storage/managed-storage.service.ts
@@ -9,7 +9,7 @@ import {
import { Upload } from '@aws-sdk/lib-storage';
import { randomUUID } from 'crypto';
import { constants } from 'fs';
-import { access, mkdir, rm, unlink, writeFile } from 'fs/promises';
+import { access, mkdir, rm, stat, unlink, writeFile } from 'fs/promises';
import { dirname, join, posix } from 'path';
@Injectable()
@@ -186,16 +186,25 @@ export class ManagedStorageService implements OnModuleDestroy {
async getHealth(): Promise
> {
const provider = this.getProvider();
const basePath = this.getBasePath();
- const publicBaseUrl =
- (this.configService.get('storage.publicBaseUrl', { infer: true }) ?? '').replace(
- /\/$/,
- '',
- );
+ const storagePublicBaseUrl = (
+ this.configService.get('storage.publicBaseUrl', { infer: true }) ?? ''
+ ).replace(/\/$/, '');
+ const publicBaseUrl = (
+ this.configService.get('publicBaseUrl', { infer: true }) ?? ''
+ ).replace(/\/$/, '');
const health: Record = {
provider,
+ storageProvider: provider,
basePath,
+ storageBasePath: basePath,
publicPath: `/${basePath}`,
+ uploadsPublicPath: `/${basePath}`,
publicBaseUrlConfigured: !!publicBaseUrl,
+ publicBaseUrl,
+ storagePublicBaseUrlConfigured: !!storagePublicBaseUrl,
+ storagePublicBaseUrl,
+ isLocalStorage: provider === 'local',
+ isS3Configured: provider === 's3' ? this.hasS3Configuration() : false,
s3Configured: provider === 's3' ? this.hasS3Configuration() : undefined,
};
@@ -219,12 +228,20 @@ export class ManagedStorageService implements OnModuleDestroy {
}
}
+ const exists = await this.pathExists(uploadDir);
+ const readable = await this.canAccess(uploadDir, constants.R_OK);
health.local = {
runtimePath: uploadDir,
+ absolutePath: uploadDir,
+ exists,
writable,
- readable: await this.canAccess(uploadDir, constants.R_OK),
+ readable,
error,
};
+ health.absolutePath = uploadDir;
+ health.uploadPathExists = exists;
+ health.uploadPathReadable = readable;
+ health.uploadPathWritable = writable;
}
return health;
@@ -235,10 +252,12 @@ export class ManagedStorageService implements OnModuleDestroy {
}
private getProvider(): 'local' | 's3' {
- return (this.configService.get('storage.provider', { infer: true }) as
- | 'local'
- | 's3'
- | undefined) ?? 'local';
+ return (
+ (this.configService.get('storage.provider', { infer: true }) as
+ | 'local'
+ | 's3'
+ | undefined) ?? 'local'
+ );
}
private getBasePath(): string {
@@ -251,9 +270,7 @@ export class ManagedStorageService implements OnModuleDestroy {
const normalized = relativePath.replace(/\\/g, '/').replace(/^\/+/, '');
if (
!normalized ||
- normalized
- .split('/')
- .some((segment) => !segment || segment === '.' || segment === '..')
+ normalized.split('/').some((segment) => !segment || segment === '.' || segment === '..')
) {
throw new BadRequestException('Invalid managed file path');
}
@@ -301,19 +318,16 @@ export class ManagedStorageService implements OnModuleDestroy {
}
private resolvePublicUrl(objectKey: string): string {
- const publicBaseUrl =
- (this.configService.get('storage.publicBaseUrl', { infer: true }) ?? '').replace(
- /\/$/,
- '',
- );
+ const publicBaseUrl = (
+ this.configService.get('storage.publicBaseUrl', { infer: true }) ?? ''
+ ).replace(/\/$/, '');
if (publicBaseUrl) {
return `${publicBaseUrl}/${objectKey}`;
}
- const endpoint = (this.configService.get('storage.s3.endpoint', { infer: true }) ?? '').replace(
- /\/$/,
- '',
- );
+ const endpoint = (
+ this.configService.get('storage.s3.endpoint', { infer: true }) ?? ''
+ ).replace(/\/$/, '');
const bucket = this.getS3Bucket();
const forcePathStyle =
this.configService.get('storage.s3.forcePathStyle', { infer: true }) ?? false;
@@ -341,20 +355,17 @@ export class ManagedStorageService implements OnModuleDestroy {
private resolveS3ObjectKey(fileUrl: string): string | null {
const normalizedUrl = fileUrl.split('?')[0].split('#')[0];
- const publicBaseUrl =
- (this.configService.get('storage.publicBaseUrl', { infer: true }) ?? '').replace(
- /\/$/,
- '',
- );
+ const publicBaseUrl = (
+ this.configService.get('storage.publicBaseUrl', { infer: true }) ?? ''
+ ).replace(/\/$/, '');
if (publicBaseUrl && normalizedUrl.startsWith(`${publicBaseUrl}/`)) {
return normalizedUrl.slice(publicBaseUrl.length + 1);
}
- const endpoint = (this.configService.get('storage.s3.endpoint', { infer: true }) ?? '').replace(
- /\/$/,
- '',
- );
+ const endpoint = (
+ this.configService.get('storage.s3.endpoint', { infer: true }) ?? ''
+ ).replace(/\/$/, '');
const bucket = this.getS3Bucket();
const forcePathStyle =
this.configService.get('storage.s3.forcePathStyle', { infer: true }) ?? false;
@@ -389,6 +400,15 @@ export class ManagedStorageService implements OnModuleDestroy {
}
}
+ private async pathExists(path: string): Promise {
+ try {
+ await stat(path);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
private async deleteS3Prefix(prefix: string): Promise {
const client = this.getS3Client();
const bucket = this.getS3Bucket();
diff --git a/src/infrastructure/storage/media-probe.service.ts b/src/infrastructure/storage/media-probe.service.ts
new file mode 100644
index 0000000..1b85429
--- /dev/null
+++ b/src/infrastructure/storage/media-probe.service.ts
@@ -0,0 +1,208 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { spawn } from 'child_process';
+import { randomUUID } from 'crypto';
+import { mkdtemp, rm, writeFile } from 'fs/promises';
+import { tmpdir } from 'os';
+import { extname, join } from 'path';
+
+type CommandProbeResult = {
+ path: string;
+ available: boolean;
+ version: string;
+ error?: string;
+};
+
+@Injectable()
+export class MediaProbeService {
+ private readonly logger = new Logger(MediaProbeService.name);
+ private readonly commandProbeCache = new Map();
+
+ constructor(private readonly configService: ConfigService) {}
+
+ getFfmpegPath(): string {
+ return (
+ this.configService.get('videoProcessing.ffmpegPath', { infer: true }) ?? 'ffmpeg'
+ ).trim();
+ }
+
+ getFfprobePath(): string {
+ const configured = (
+ this.configService.get('videoProcessing.ffprobePath', { infer: true }) ?? ''
+ ).trim();
+ if (configured) {
+ return configured;
+ }
+
+ const ffmpegPath = this.getFfmpegPath();
+ if (/ffmpeg(?:\.exe)?$/i.test(ffmpegPath)) {
+ return ffmpegPath.replace(/ffmpeg(?:\.exe)?$/i, (match) =>
+ match.toLowerCase().endsWith('.exe') ? 'ffprobe.exe' : 'ffprobe',
+ );
+ }
+
+ return 'ffprobe';
+ }
+
+ async checkFfmpeg(): Promise {
+ return this.checkCommand(this.getFfmpegPath());
+ }
+
+ async checkFfprobe(): Promise {
+ return this.checkCommand(this.getFfprobePath());
+ }
+
+ async extractDurationSecondsFromBuffer(
+ buffer: Buffer,
+ options: { originalname?: string; mimetype?: string } = {},
+ ): Promise {
+ if (!buffer.length) {
+ return null;
+ }
+
+ const workingDir = await mkdtemp(join(tmpdir(), 'oudelaa-media-probe-'));
+ const inputPath = join(
+ workingDir,
+ `input-${randomUUID()}${this.resolveInputExtension(options)}`,
+ );
+
+ try {
+ await writeFile(inputPath, buffer);
+ return await this.extractDurationSeconds(inputPath);
+ } catch (error) {
+ this.logger.warn(
+ `Media duration extraction failed for "${options.originalname ?? 'upload'}": ${
+ error instanceof Error ? error.message : 'unknown error'
+ }`,
+ );
+ return null;
+ } finally {
+ await rm(workingDir, { recursive: true, force: true });
+ }
+ }
+
+ async extractDurationSeconds(filePath: string): Promise {
+ const ffprobe = await this.checkFfprobe();
+ if (!ffprobe.available) {
+ this.logger.warn(
+ `ffprobe is unavailable at "${ffprobe.path}"; media duration extraction skipped`,
+ );
+ return null;
+ }
+
+ try {
+ const stdout = await this.runCommand(
+ ffprobe.path,
+ [
+ '-v',
+ 'error',
+ '-show_entries',
+ 'format=duration',
+ '-of',
+ 'default=noprint_wrappers=1:nokey=1',
+ filePath,
+ ],
+ 5000,
+ );
+ const seconds = Number.parseFloat(stdout.trim());
+ if (!Number.isFinite(seconds) || seconds <= 0) {
+ return null;
+ }
+ return Math.round(seconds);
+ } catch (error) {
+ this.logger.warn(
+ `ffprobe duration check failed: ${error instanceof Error ? error.message : 'unknown error'}`,
+ );
+ return null;
+ }
+ }
+
+ private async checkCommand(command: string): Promise {
+ const cached = this.commandProbeCache.get(command);
+ if (cached) {
+ return cached;
+ }
+
+ try {
+ const stdout = await this.runCommand(command, ['-version'], 3000);
+ const version = stdout.split(/\r?\n/)[0]?.trim() ?? '';
+ const result = { path: command, available: true, version };
+ this.commandProbeCache.set(command, result);
+ return result;
+ } catch (error) {
+ const result = {
+ path: command,
+ available: false,
+ version: '',
+ error: error instanceof Error ? error.message : 'unknown command error',
+ };
+ this.commandProbeCache.set(command, result);
+ return result;
+ }
+ }
+
+ private resolveInputExtension(options: { originalname?: string; mimetype?: string }): string {
+ const extension = extname(options.originalname ?? '').toLowerCase();
+ if (extension) {
+ return extension;
+ }
+
+ switch (options.mimetype) {
+ case 'video/mp4':
+ return '.mp4';
+ case 'video/quicktime':
+ return '.mov';
+ case 'video/webm':
+ return '.webm';
+ case 'audio/mpeg':
+ return '.mp3';
+ case 'audio/mp4':
+ case 'audio/x-m4a':
+ return '.m4a';
+ case 'audio/wav':
+ case 'audio/x-wav':
+ return '.wav';
+ case 'audio/aac':
+ return '.aac';
+ case 'audio/ogg':
+ return '.ogg';
+ default:
+ return '.media';
+ }
+ }
+
+ private async runCommand(command: string, args: string[], timeoutMs: number): Promise {
+ return new Promise((resolve, reject) => {
+ const child = spawn(command, args, { windowsHide: true });
+ let stdout = '';
+ let stderr = '';
+ const timeout = setTimeout(() => {
+ child.kill('SIGKILL');
+ reject(new Error(`${command} timed out after ${timeoutMs}ms`));
+ }, timeoutMs);
+
+ child.stdout.on('data', (chunk) => {
+ stdout += chunk.toString();
+ });
+
+ child.stderr.on('data', (chunk) => {
+ stderr += chunk.toString();
+ });
+
+ child.on('error', (error) => {
+ clearTimeout(timeout);
+ reject(error);
+ });
+
+ child.on('close', (code) => {
+ clearTimeout(timeout);
+ if (code === 0) {
+ resolve(stdout);
+ return;
+ }
+
+ reject(new Error(stderr.trim() || `${command} exited with code ${code ?? 'unknown'}`));
+ });
+ });
+ }
+}
diff --git a/src/infrastructure/storage/storage.module.ts b/src/infrastructure/storage/storage.module.ts
index ff5c516..751c70d 100644
--- a/src/infrastructure/storage/storage.module.ts
+++ b/src/infrastructure/storage/storage.module.ts
@@ -1,11 +1,22 @@
import { Global, Module } from '@nestjs/common';
import { ImageProcessingService } from './image-processing.service';
import { ManagedStorageService } from './managed-storage.service';
+import { MediaProbeService } from './media-probe.service';
import { VideoProcessingService } from './video-processing.service';
@Global()
@Module({
- providers: [ManagedStorageService, VideoProcessingService, ImageProcessingService],
- exports: [ManagedStorageService, VideoProcessingService, ImageProcessingService],
+ providers: [
+ ManagedStorageService,
+ VideoProcessingService,
+ ImageProcessingService,
+ MediaProbeService,
+ ],
+ exports: [
+ ManagedStorageService,
+ VideoProcessingService,
+ ImageProcessingService,
+ MediaProbeService,
+ ],
})
export class StorageModule {}
diff --git a/src/infrastructure/storage/video-processing.service.ts b/src/infrastructure/storage/video-processing.service.ts
index 6cb9977..1a84e0a 100644
--- a/src/infrastructure/storage/video-processing.service.ts
+++ b/src/infrastructure/storage/video-processing.service.ts
@@ -1,4 +1,4 @@
-import { BadRequestException, Injectable, Logger } from '@nestjs/common';
+import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { spawn } from 'child_process';
import { randomUUID } from 'crypto';
@@ -165,9 +165,7 @@ export class VideoProcessingService {
error instanceof Error ? error.message : 'unknown error'
}`,
);
- throw new BadRequestException(
- 'Video optimization failed. Upload an MP4/WebM video or disable server-side video processing.',
- );
+ return { file };
} finally {
await rm(workingDir, { recursive: true, force: true });
}
@@ -453,7 +451,11 @@ export class VideoProcessingService {
}
private buildHlsRenditions(sourceWidth: number): HlsRendition[] {
- const requestedWidths = [Math.min(480, this.getMaxWidth()), Math.min(720, this.getMaxWidth()), this.getMaxWidth()];
+ const requestedWidths = [
+ Math.min(480, this.getMaxWidth()),
+ Math.min(720, this.getMaxWidth()),
+ this.getMaxWidth(),
+ ];
const widths = Array.from(
new Set(
requestedWidths
diff --git a/src/modules/media/media.service.ts b/src/modules/media/media.service.ts
index 30b9259..7aa803c 100644
--- a/src/modules/media/media.service.ts
+++ b/src/modules/media/media.service.ts
@@ -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('storage.provider', { infer: true }) ?? 'local';
+ const publicBaseUrl = this.configService.get('publicBaseUrl', { infer: true }) ?? '';
+ const warnings: string[] = [];
+ const imageProcessingEnabled =
+ this.configService.get('imageProcessing.enabled', { infer: true }) ?? false;
+ const videoProcessingEnabled =
+ this.configService.get('videoProcessing.enabled', { infer: true }) ?? false;
+ const videoHlsGenerationEnabled =
+ this.configService.get('videoProcessing.generateHls', { infer: true }) ?? true;
+ const videoThumbnailGenerationEnabled =
+ this.configService.get('videoProcessing.generateThumbnails', { infer: true }) ??
+ true;
+ const audioProcessingEnabled =
+ this.configService.get('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('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',
+ 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');
diff --git a/src/modules/posts/posts.service.ts b/src/modules/posts/posts.service.ts
index 2c75f94..e7c7a7d 100644
--- a/src/modules/posts/posts.service.ts
+++ b/src/modules/posts/posts.service.ts
@@ -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 {
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 {
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 {
+ async createRepost(
+ userId: string,
+ sourcePostId: string,
+ dto: CreateRepostDto,
+ ): Promise {
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 {
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,