Harden media health checks and duration extraction
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled

هذا الالتزام موجود في:
boutmoun123
2026-05-31 18:53:07 +03:00
الأصل 1973b8b904
التزام 637782aed6
11 ملفات معدلة مع 587 إضافات و138 حذوفات

عرض الملف

@@ -50,6 +50,8 @@ QUEUE_WORKER_CONCURRENCY=5
STORAGE_PROVIDER=local STORAGE_PROVIDER=local
STORAGE_BASE_PATH=uploads STORAGE_BASE_PATH=uploads
# In Docker/production with local storage, mount a persistent volume to /app/uploads
# or to the runtime path resolved from STORAGE_BASE_PATH.
# Leave empty for local storage unless you want a dedicated CDN/base URL. # Leave empty for local storage unless you want a dedicated CDN/base URL.
STORAGE_PUBLIC_BASE_URL= STORAGE_PUBLIC_BASE_URL=
S3_BUCKET= S3_BUCKET=
@@ -76,6 +78,8 @@ VIDEO_PROCESSING_GENERATE_THUMBNAILS=true
VIDEO_PROCESSING_GENERATE_HLS=true VIDEO_PROCESSING_GENERATE_HLS=true
VIDEO_PROCESSING_HLS_SEGMENT_DURATION_SECONDS=4 VIDEO_PROCESSING_HLS_SEGMENT_DURATION_SECONDS=4
VIDEO_PROCESSING_THUMBNAIL_WIDTH=720 VIDEO_PROCESSING_THUMBNAIL_WIDTH=720
AUDIO_PROCESSING_ENABLED=false
AUDIO_WAVEFORM_PEAKS=48
GOOGLE_CLIENT_ID=your_google_client_id GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret GOOGLE_CLIENT_SECRET=your_google_client_secret

عرض الملف

@@ -2,7 +2,16 @@
import Link from "next/link"; import Link from "next/link";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Boxes, RefreshCcw, ShieldAlert, Store, Users2 } from "lucide-react"; import {
Boxes,
FileText,
MessageSquareText,
RefreshCcw,
ShieldAlert,
Store,
UserRound,
Users2,
} from "lucide-react";
import { NoPermissionState } from "@/components/auth/no-permission-state"; import { NoPermissionState } from "@/components/auth/no-permission-state";
import { PostPreviewCard } from "@/components/dashboard/post-preview-card"; import { PostPreviewCard } from "@/components/dashboard/post-preview-card";
@@ -69,6 +78,34 @@ function ShortcutLink({
); );
} }
function getActivityIcon(type: string) {
const normalized = type.toLowerCase();
if (normalized.includes("user")) return UserRound;
if (normalized.includes("comment")) return MessageSquareText;
if (normalized.includes("listing") || normalized.includes("shop")) return Store;
if (normalized.includes("case") || normalized.includes("audit") || normalized.includes("report")) {
return ShieldAlert;
}
return FileText;
}
function ActivityTypeIcon({ type }: { type: string }) {
const Icon = getActivityIcon(type);
return <Icon className="h-4 w-4" />;
}
function getActivityLabel(type: string) {
const normalized = type.toLowerCase();
if (normalized === "post") return "New post";
if (normalized === "user") return "New user";
if (normalized === "comment") return "New comment";
if (normalized === "listing") return "Marketplace listing";
if (normalized === "repair_shop") return "Repair shop";
if (normalized === "case") return "Moderation case";
if (normalized === "audit") return "Audit event";
return type.replace(/_/g, " ");
}
export default function DashboardPage() { export default function DashboardPage() {
const { permissions } = useSuperAdminSession(); const { permissions } = useSuperAdminSession();
const [snapshot, setSnapshot] = useState<DashboardSnapshot>({ const [snapshot, setSnapshot] = useState<DashboardSnapshot>({
@@ -417,26 +454,33 @@ export default function DashboardPage() {
} }
> >
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Recent activity</CardTitle> <CardTitle>Platform activity</CardTitle>
<Link href="/analytics" className="text-sm text-primary"> <Link href="/analytics" className="text-sm text-primary">
Activity feeds View analytics
</Link> </Link>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{!snapshot.recentActivity.length ? ( {!snapshot.recentActivity.length ? (
<EmptyState <EmptyState
title="No recent activity" title="No platform activity"
description="Operational and moderation activity will appear here." description="Operational and moderation activity will appear here."
/> />
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{snapshot.recentActivity.map((item, index) => ( {snapshot.recentActivity.slice(0, 5).map((item, index) => (
<div <div
key={`${item.type}-${index}`} key={`${item.type}-${index}`}
className="rounded-xl border border-border/70 bg-secondary/20 p-3" className="rounded-lg border border-border/70 bg-secondary/20 p-3 transition hover:border-primary/40 hover:bg-secondary/30"
> >
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium text-foreground">{item.title}</div> <div className="flex min-w-0 items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-background/50 text-primary">
<ActivityTypeIcon type={item.type} />
</span>
<div dir="auto" className="truncate text-sm font-semibold text-foreground">
{item.title || "Untitled activity"}
</div>
</div>
<Badge <Badge
variant={ variant={
item.status === "flagged" || item.status === "disabled" item.status === "flagged" || item.status === "disabled"
@@ -444,11 +488,11 @@ export default function DashboardPage() {
: "muted" : "muted"
} }
> >
{item.type} {getActivityLabel(item.type)}
</Badge> </Badge>
</div> </div>
<div className="mt-1 text-xs text-muted-foreground"> <div dir="auto" className="mt-2 truncate text-xs text-muted-foreground">
{item.subtitle} {formatDateTime(item.createdAt)} {item.subtitle || "No additional details"} - {formatDateTime(item.createdAt)}
</div> </div>
</div> </div>
))} ))}

عرض الملف

@@ -1,9 +1,13 @@
export function formatDateTime(value?: string | null) { export function formatDateTime(value?: string | null) {
if (!value) return "-"; if (!value) return "-";
try { try {
return new Intl.DateTimeFormat("ar-SA", { return new Intl.DateTimeFormat("en-US", {
dateStyle: "medium", year: "numeric",
timeStyle: "short", month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: true,
}).format(new Date(value)); }).format(new Date(value));
} catch { } catch {
return value; return value;

عرض الملف

@@ -3,8 +3,7 @@ export default () => ({
port: Number(process.env.PORT ?? 4000), port: Number(process.env.PORT ?? 4000),
host: process.env.HOST ?? '0.0.0.0', host: process.env.HOST ?? '0.0.0.0',
publicBaseUrl: publicBaseUrl:
process.env.PUBLIC_BASE_URL ?? process.env.PUBLIC_BASE_URL ?? `http://localhost:${Number(process.env.PORT ?? 4000)}`,
`http://localhost:${Number(process.env.PORT ?? 4000)}`,
responseEnvelopeEnabled: responseEnvelopeEnabled:
(process.env.RESPONSE_ENVELOPE_ENABLED ?? 'false').toLowerCase() === 'true', (process.env.RESPONSE_ENVELOPE_ENABLED ?? 'false').toLowerCase() === 'true',
globalPrefix: process.env.GLOBAL_PREFIX ?? 'api/v1', globalPrefix: process.env.GLOBAL_PREFIX ?? 'api/v1',
@@ -76,8 +75,7 @@ export default () => ({
name: process.env.QUEUE_NAME ?? 'app-jobs', name: process.env.QUEUE_NAME ?? 'app-jobs',
defaultJobAttempts: Number(process.env.QUEUE_DEFAULT_ATTEMPTS ?? 3), defaultJobAttempts: Number(process.env.QUEUE_DEFAULT_ATTEMPTS ?? 3),
defaultJobBackoffMs: Number(process.env.QUEUE_DEFAULT_BACKOFF_MS ?? 1000), defaultJobBackoffMs: Number(process.env.QUEUE_DEFAULT_BACKOFF_MS ?? 1000),
removeOnComplete: removeOnComplete: (process.env.QUEUE_REMOVE_ON_COMPLETE ?? 'true').toLowerCase() === 'true',
(process.env.QUEUE_REMOVE_ON_COMPLETE ?? 'true').toLowerCase() === 'true',
workerConcurrency: Number(process.env.QUEUE_WORKER_CONCURRENCY ?? 5), workerConcurrency: Number(process.env.QUEUE_WORKER_CONCURRENCY ?? 5),
}, },
storage: { storage: {
@@ -90,8 +88,7 @@ export default () => ({
endpoint: process.env.S3_ENDPOINT ?? '', endpoint: process.env.S3_ENDPOINT ?? '',
accessKeyId: process.env.S3_ACCESS_KEY_ID ?? '', accessKeyId: process.env.S3_ACCESS_KEY_ID ?? '',
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? '', secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? '',
forcePathStyle: forcePathStyle: (process.env.S3_FORCE_PATH_STYLE ?? 'false').toLowerCase() === 'true',
(process.env.S3_FORCE_PATH_STYLE ?? 'false').toLowerCase() === 'true',
}, },
}, },
imageProcessing: { imageProcessing: {
@@ -125,6 +122,10 @@ export default () => ({
), ),
thumbnailWidth: Number(process.env.VIDEO_PROCESSING_THUMBNAIL_WIDTH ?? 720), 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: { logging: {
level: process.env.LOG_LEVEL ?? 'log', level: process.env.LOG_LEVEL ?? 'log',
requestEnabled: (process.env.REQUEST_LOGGING_ENABLED ?? 'true').toLowerCase() === 'true', requestEnabled: (process.env.REQUEST_LOGGING_ENABLED ?? 'true').toLowerCase() === 'true',

عرض الملف

@@ -81,6 +81,8 @@ export const validationSchema = Joi.object({
VIDEO_PROCESSING_GENERATE_HLS: Joi.boolean().truthy('true').falsy('false').default(true), 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_HLS_SEGMENT_DURATION_SECONDS: Joi.number().min(2).max(20).default(4),
VIDEO_PROCESSING_THUMBNAIL_WIDTH: Joi.number().min(160).max(1920).default(720), 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'), LOG_LEVEL: Joi.string().valid('error', 'warn', 'log', 'debug', 'verbose').default('log'),
REQUEST_LOGGING_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true), REQUEST_LOGGING_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true),
FEED_CACHE_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true), FEED_CACHE_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true),

عرض الملف

@@ -9,7 +9,7 @@ import {
import { Upload } from '@aws-sdk/lib-storage'; import { Upload } from '@aws-sdk/lib-storage';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { constants } from 'fs'; 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'; import { dirname, join, posix } from 'path';
@Injectable() @Injectable()
@@ -186,16 +186,25 @@ export class ManagedStorageService implements OnModuleDestroy {
async getHealth(): Promise<Record<string, unknown>> { async getHealth(): Promise<Record<string, unknown>> {
const provider = this.getProvider(); const provider = this.getProvider();
const basePath = this.getBasePath(); const basePath = this.getBasePath();
const publicBaseUrl = const storagePublicBaseUrl = (
(this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? '').replace( this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? ''
/\/$/, ).replace(/\/$/, '');
'', const publicBaseUrl = (
); this.configService.get<string>('publicBaseUrl', { infer: true }) ?? ''
).replace(/\/$/, '');
const health: Record<string, unknown> = { const health: Record<string, unknown> = {
provider, provider,
storageProvider: provider,
basePath, basePath,
storageBasePath: basePath,
publicPath: `/${basePath}`, publicPath: `/${basePath}`,
uploadsPublicPath: `/${basePath}`,
publicBaseUrlConfigured: !!publicBaseUrl, publicBaseUrlConfigured: !!publicBaseUrl,
publicBaseUrl,
storagePublicBaseUrlConfigured: !!storagePublicBaseUrl,
storagePublicBaseUrl,
isLocalStorage: provider === 'local',
isS3Configured: provider === 's3' ? this.hasS3Configuration() : false,
s3Configured: provider === 's3' ? this.hasS3Configuration() : undefined, 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 = { health.local = {
runtimePath: uploadDir, runtimePath: uploadDir,
absolutePath: uploadDir,
exists,
writable, writable,
readable: await this.canAccess(uploadDir, constants.R_OK), readable,
error, error,
}; };
health.absolutePath = uploadDir;
health.uploadPathExists = exists;
health.uploadPathReadable = readable;
health.uploadPathWritable = writable;
} }
return health; return health;
@@ -235,10 +252,12 @@ export class ManagedStorageService implements OnModuleDestroy {
} }
private getProvider(): 'local' | 's3' { private getProvider(): 'local' | 's3' {
return (this.configService.get<string>('storage.provider', { infer: true }) as return (
(this.configService.get<string>('storage.provider', { infer: true }) as
| 'local' | 'local'
| 's3' | 's3'
| undefined) ?? 'local'; | undefined) ?? 'local'
);
} }
private getBasePath(): string { private getBasePath(): string {
@@ -251,9 +270,7 @@ export class ManagedStorageService implements OnModuleDestroy {
const normalized = relativePath.replace(/\\/g, '/').replace(/^\/+/, ''); const normalized = relativePath.replace(/\\/g, '/').replace(/^\/+/, '');
if ( if (
!normalized || !normalized ||
normalized normalized.split('/').some((segment) => !segment || segment === '.' || segment === '..')
.split('/')
.some((segment) => !segment || segment === '.' || segment === '..')
) { ) {
throw new BadRequestException('Invalid managed file path'); throw new BadRequestException('Invalid managed file path');
} }
@@ -301,19 +318,16 @@ export class ManagedStorageService implements OnModuleDestroy {
} }
private resolvePublicUrl(objectKey: string): string { private resolvePublicUrl(objectKey: string): string {
const publicBaseUrl = const publicBaseUrl = (
(this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? '').replace( this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? ''
/\/$/, ).replace(/\/$/, '');
'',
);
if (publicBaseUrl) { if (publicBaseUrl) {
return `${publicBaseUrl}/${objectKey}`; return `${publicBaseUrl}/${objectKey}`;
} }
const endpoint = (this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? '').replace( const endpoint = (
/\/$/, this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? ''
'', ).replace(/\/$/, '');
);
const bucket = this.getS3Bucket(); const bucket = this.getS3Bucket();
const forcePathStyle = const forcePathStyle =
this.configService.get<boolean>('storage.s3.forcePathStyle', { infer: true }) ?? false; this.configService.get<boolean>('storage.s3.forcePathStyle', { infer: true }) ?? false;
@@ -341,20 +355,17 @@ export class ManagedStorageService implements OnModuleDestroy {
private resolveS3ObjectKey(fileUrl: string): string | null { private resolveS3ObjectKey(fileUrl: string): string | null {
const normalizedUrl = fileUrl.split('?')[0].split('#')[0]; const normalizedUrl = fileUrl.split('?')[0].split('#')[0];
const publicBaseUrl = const publicBaseUrl = (
(this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? '').replace( this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? ''
/\/$/, ).replace(/\/$/, '');
'',
);
if (publicBaseUrl && normalizedUrl.startsWith(`${publicBaseUrl}/`)) { if (publicBaseUrl && normalizedUrl.startsWith(`${publicBaseUrl}/`)) {
return normalizedUrl.slice(publicBaseUrl.length + 1); return normalizedUrl.slice(publicBaseUrl.length + 1);
} }
const endpoint = (this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? '').replace( const endpoint = (
/\/$/, this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? ''
'', ).replace(/\/$/, '');
);
const bucket = this.getS3Bucket(); const bucket = this.getS3Bucket();
const forcePathStyle = const forcePathStyle =
this.configService.get<boolean>('storage.s3.forcePathStyle', { infer: true }) ?? false; this.configService.get<boolean>('storage.s3.forcePathStyle', { infer: true }) ?? false;
@@ -389,6 +400,15 @@ export class ManagedStorageService implements OnModuleDestroy {
} }
} }
private async pathExists(path: string): Promise<boolean> {
try {
await stat(path);
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();

عرض الملف

@@ -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<string, CommandProbeResult>();
constructor(private readonly configService: ConfigService) {}
getFfmpegPath(): string {
return (
this.configService.get<string>('videoProcessing.ffmpegPath', { infer: true }) ?? 'ffmpeg'
).trim();
}
getFfprobePath(): string {
const configured = (
this.configService.get<string>('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<CommandProbeResult> {
return this.checkCommand(this.getFfmpegPath());
}
async checkFfprobe(): Promise<CommandProbeResult> {
return this.checkCommand(this.getFfprobePath());
}
async extractDurationSecondsFromBuffer(
buffer: Buffer,
options: { originalname?: string; mimetype?: string } = {},
): Promise<number | null> {
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<number | null> {
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<CommandProbeResult> {
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<string> {
return new Promise<string>((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'}`));
});
});
}
}

عرض الملف

@@ -1,11 +1,22 @@
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { ImageProcessingService } from './image-processing.service'; import { ImageProcessingService } from './image-processing.service';
import { ManagedStorageService } from './managed-storage.service'; import { ManagedStorageService } from './managed-storage.service';
import { MediaProbeService } from './media-probe.service';
import { VideoProcessingService } from './video-processing.service'; import { VideoProcessingService } from './video-processing.service';
@Global() @Global()
@Module({ @Module({
providers: [ManagedStorageService, VideoProcessingService, ImageProcessingService], providers: [
exports: [ManagedStorageService, VideoProcessingService, ImageProcessingService], ManagedStorageService,
VideoProcessingService,
ImageProcessingService,
MediaProbeService,
],
exports: [
ManagedStorageService,
VideoProcessingService,
ImageProcessingService,
MediaProbeService,
],
}) })
export class StorageModule {} export class StorageModule {}

عرض الملف

@@ -1,4 +1,4 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
@@ -165,9 +165,7 @@ export class VideoProcessingService {
error instanceof Error ? error.message : 'unknown error' error instanceof Error ? error.message : 'unknown error'
}`, }`,
); );
throw new BadRequestException( return { file };
'Video optimization failed. Upload an MP4/WebM video or disable server-side video processing.',
);
} finally { } finally {
await rm(workingDir, { recursive: true, force: true }); await rm(workingDir, { recursive: true, force: true });
} }
@@ -453,7 +451,11 @@ export class VideoProcessingService {
} }
private buildHlsRenditions(sourceWidth: number): HlsRendition[] { 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( const widths = Array.from(
new Set( new Set(
requestedWidths requestedWidths

عرض الملف

@@ -1,12 +1,9 @@
import { import { BadGatewayException, Injectable, ServiceUnavailableException } from '@nestjs/common';
BadGatewayException,
Injectable,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { GoogleAuth } from 'google-auth-library'; import { GoogleAuth } from 'google-auth-library';
import { generateWaveformPeaksFromBuffer } from '../../common/utils/waveform.util'; import { generateWaveformPeaksFromBuffer } from '../../common/utils/waveform.util';
import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service'; import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service';
import { MediaProbeService } from '../../infrastructure/storage/media-probe.service';
import { TextToMusicDto } from './dto/text-to-music.dto'; import { TextToMusicDto } from './dto/text-to-music.dto';
@Injectable() @Injectable()
@@ -14,29 +11,84 @@ export class MediaService {
constructor( constructor(
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly storageService: ManagedStorageService, private readonly storageService: ManagedStorageService,
private readonly mediaProbeService: MediaProbeService,
) {} ) {}
async getMediaHealth() { async getMediaHealth() {
return { const [storageHealth, ffmpeg, ffprobe] = await Promise.all([
storage: await this.storageService.getHealth(), this.storageService.getHealth(),
processing: { this.mediaProbeService.checkFfmpeg(),
imageProcessingEnabled: this.mediaProbeService.checkFfprobe(),
this.configService.get<boolean>('imageProcessing.enabled', { infer: true }) ?? false, ]);
videoProcessingEnabled: const storageProvider =
this.configService.get<boolean>('videoProcessing.enabled', { infer: true }) ?? false, this.configService.get<string>('storage.provider', { infer: true }) ?? 'local';
videoHlsGenerationEnabled: const publicBaseUrl = this.configService.get<string>('publicBaseUrl', { infer: true }) ?? '';
this.configService.get<boolean>('videoProcessing.generateHls', { infer: true }) ?? true, const warnings: string[] = [];
videoThumbnailGenerationEnabled: 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 }) ?? this.configService.get<boolean>('videoProcessing.generateThumbnails', { infer: true }) ??
true, true;
ffmpegPath: const audioProcessingEnabled =
this.configService.get<string>('videoProcessing.ffmpegPath', { infer: true }) ?? 'ffmpeg', 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 {
status,
storage: storageHealth,
processing: {
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: { serving: {
rangeRequests: true, rangeRequests: true,
immutableCacheSeconds: 31536000, immutableCacheSeconds: 31536000,
hlsManifestCacheSeconds: 300, 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 client = await auth.getClient();
const accessTokenRaw = await client.getAccessToken(); const accessTokenRaw = await client.getAccessToken();
const accessToken = const accessToken =
typeof accessTokenRaw === 'string' ? accessTokenRaw : accessTokenRaw?.token ?? ''; typeof accessTokenRaw === 'string' ? accessTokenRaw : (accessTokenRaw?.token ?? '');
if (!accessToken) { if (!accessToken) {
throw new ServiceUnavailableException('Failed to authenticate with Google Cloud'); 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 { extname } from 'path';
import { Connection, Types } from 'mongoose'; import { Connection, Types } from 'mongoose';
import { InjectConnection } from '@nestjs/mongoose'; import { InjectConnection } from '@nestjs/mongoose';
@@ -23,6 +29,7 @@ import {
UploadedVideoFile, UploadedVideoFile,
VideoProcessingService, VideoProcessingService,
} from '../../infrastructure/storage/video-processing.service'; } from '../../infrastructure/storage/video-processing.service';
import { MediaProbeService } from '../../infrastructure/storage/media-probe.service';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
import { UsersRepository } from '../users/users.repository'; import { UsersRepository } from '../users/users.repository';
@@ -39,12 +46,7 @@ import { PostsRepository } from './posts.repository';
type PostMediaMetadataInput = Pick< type PostMediaMetadataInput = Pick<
CreatePostDto, CreatePostDto,
| 'durationSeconds' 'durationSeconds' | 'thumbnailUrl' | 'style' | 'maqam' | 'rhythmSignature' | 'waveformPeaks'
| 'thumbnailUrl'
| 'style'
| 'maqam'
| 'rhythmSignature'
| 'waveformPeaks'
>; >;
type NormalizedPostMediaMetadata = { type NormalizedPostMediaMetadata = {
@@ -61,6 +63,7 @@ type SavedVideoUpload = {
hlsUrl: string; hlsUrl: string;
thumbnailUrl: string; thumbnailUrl: string;
thumbnailVariants: PostMediaVariantSet | null; thumbnailVariants: PostMediaVariantSet | null;
durationSeconds: number | null;
}; };
type SavedImageUpload = { type SavedImageUpload = {
@@ -79,6 +82,7 @@ export class PostsService {
private readonly storageService: ManagedStorageService, private readonly storageService: ManagedStorageService,
private readonly imageProcessingService: ImageProcessingService, private readonly imageProcessingService: ImageProcessingService,
private readonly videoProcessingService: VideoProcessingService, private readonly videoProcessingService: VideoProcessingService,
private readonly mediaProbeService: MediaProbeService,
private readonly feedVersionService: FeedVersionService, private readonly feedVersionService: FeedVersionService,
private readonly notificationsService: NotificationsService, private readonly notificationsService: NotificationsService,
private readonly auditService: AuditService, private readonly auditService: AuditService,
@@ -87,7 +91,12 @@ export class PostsService {
async create( async create(
userId: string, userId: string,
dto: CreatePostDto, 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 }, videoFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
audioFile?: { 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 }, coverImageFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
@@ -134,6 +143,9 @@ export class PostsService {
const generatedThumbnailVariants = savedVideoUpload?.thumbnailVariants ?? null; const generatedThumbnailVariants = savedVideoUpload?.thumbnailVariants ?? null;
const uploadedThumbnailUrl = savedCoverImageUpload?.primaryUrl ?? generatedThumbnailUrl; const uploadedThumbnailUrl = savedCoverImageUpload?.primaryUrl ?? generatedThumbnailUrl;
const uploadedThumbnailVariants = savedCoverImageUpload?.variants ?? generatedThumbnailVariants; 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 uploadedAudioUrl = audioFile ? await this.saveMediaFile('audio', audioFile) : '';
const finalImageUrls = uploadedImageUrls.length ? uploadedImageUrls : inputImageUrls; const finalImageUrls = uploadedImageUrls.length ? uploadedImageUrls : inputImageUrls;
const finalImageVariants = uploadedImageVariants.length ? uploadedImageVariants : []; const finalImageVariants = uploadedImageVariants.length ? uploadedImageVariants : [];
@@ -141,9 +153,18 @@ export class PostsService {
const finalAudioUrl = uploadedAudioUrl || dto.audioUrl || ''; const finalAudioUrl = uploadedAudioUrl || dto.audioUrl || '';
const finalContent = dto.content?.trim() ?? ''; const finalContent = dto.content?.trim() ?? '';
const taggedUserIds = await this.normalizeTaggedUserIds(dto.taggedUserIds, userId); 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 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); const { location, latitude, longitude } = this.normalizeLocation(dto);
if (!finalContent && !finalImageUrls.length && !finalVideoUrl && !finalAudioUrl) { if (!finalContent && !finalImageUrls.length && !finalVideoUrl && !finalAudioUrl) {
throw new BadRequestException('Post must contain caption or media'); throw new BadRequestException('Post must contain caption or media');
@@ -153,6 +174,7 @@ export class PostsService {
const hashtags = this.extractHashtags(finalContent); const hashtags = this.extractHashtags(finalContent);
const mediaMetadata = this.normalizeMediaMetadata(dto, postType, undefined, { const mediaMetadata = this.normalizeMediaMetadata(dto, postType, undefined, {
audioSourceBuffer: audioFile?.buffer, audioSourceBuffer: audioFile?.buffer,
extractedDurationSeconds: savedVideoUpload?.durationSeconds ?? uploadedAudioDurationSeconds,
waveformSeed: finalAudioUrl || finalContent || `${userId}:${Date.now()}`, waveformSeed: finalAudioUrl || finalContent || `${userId}:${Date.now()}`,
thumbnailUrl: uploadedThumbnailUrl, thumbnailUrl: uploadedThumbnailUrl,
}); });
@@ -186,7 +208,9 @@ export class PostsService {
await Promise.all([ await Promise.all([
...savedImageUploads.map((item) => this.deleteSavedImageAsset(item)), ...savedImageUploads.map((item) => this.deleteSavedImageAsset(item)),
uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(), uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(),
uploadedHlsUrl ? this.storageService.deleteContainingDirectory(uploadedHlsUrl) : Promise.resolve(), uploadedHlsUrl
? this.storageService.deleteContainingDirectory(uploadedHlsUrl)
: Promise.resolve(),
uploadedThumbnailUrl uploadedThumbnailUrl
? this.deleteThumbnailAsset(uploadedThumbnailUrl, uploadedThumbnailVariants) ? this.deleteThumbnailAsset(uploadedThumbnailUrl, uploadedThumbnailVariants)
: Promise.resolve(), : Promise.resolve(),
@@ -204,7 +228,12 @@ export class PostsService {
await this.usersRepository.incrementPostsCount(userId, 1); await this.usersRepository.incrementPostsCount(userId, 1);
await this.feedVersionService.bumpGlobalVersion(); await this.feedVersionService.bumpGlobalVersion();
await this.notifyMentionedUsers(userId, post.id, mentionResolution.mentionedUsers, finalContent); await this.notifyMentionedUsers(
userId,
post.id,
mentionResolution.mentionedUsers,
finalContent,
);
return post; return post;
} }
@@ -212,7 +241,12 @@ export class PostsService {
userId: string, userId: string,
postId: string, postId: string,
dto: UpdatePostDto, 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 }, videoFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
audioFile?: { 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 }, coverImageFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
@@ -249,7 +283,10 @@ export class PostsService {
if (coverImageFile && (imageFiles.length || inputImageUrls.length)) { if (coverImageFile && (imageFiles.length || inputImageUrls.length)) {
throw new BadRequestException('coverImageFile is allowed only with video or audio posts'); 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'); throw new BadRequestException('coverImageFile is allowed only with video or audio posts');
} }
if ((videoFile || dto.videoUrl) && (imageFiles.length || inputImageUrls.length)) { if ((videoFile || dto.videoUrl) && (imageFiles.length || inputImageUrls.length)) {
@@ -272,6 +309,9 @@ export class PostsService {
const generatedThumbnailVariants = savedVideoUpload?.thumbnailVariants ?? null; const generatedThumbnailVariants = savedVideoUpload?.thumbnailVariants ?? null;
const uploadedThumbnailUrl = savedCoverImageUpload?.primaryUrl ?? generatedThumbnailUrl; const uploadedThumbnailUrl = savedCoverImageUpload?.primaryUrl ?? generatedThumbnailUrl;
const uploadedThumbnailVariants = savedCoverImageUpload?.variants ?? generatedThumbnailVariants; 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 uploadedAudioUrl = audioFile ? await this.saveMediaFile('audio', audioFile) : '';
const hasImageUpdate = typeof dto.imageUrls !== 'undefined' || imageFiles.length > 0; const hasImageUpdate = typeof dto.imageUrls !== 'undefined' || imageFiles.length > 0;
const existingImageVariants = Array.isArray((post as any).imageVariants) const existingImageVariants = Array.isArray((post as any).imageVariants)
@@ -286,7 +326,7 @@ export class PostsService {
? imageFiles.length ? imageFiles.length
? uploadedImageUrls ? uploadedImageUrls
: inputImageUrls : inputImageUrls
: post.imageUrls ?? []; : (post.imageUrls ?? []);
const nextImageVariants = hasImageUpdate const nextImageVariants = hasImageUpdate
? imageFiles.length ? imageFiles.length
? uploadedImageVariants ? uploadedImageVariants
@@ -296,34 +336,39 @@ export class PostsService {
const nextVideoUrl = hasVideoUpdate const nextVideoUrl = hasVideoUpdate
? videoFile ? videoFile
? uploadedVideoUrl ? uploadedVideoUrl
: dto.videoUrl ?? '' : (dto.videoUrl ?? '')
: post.videoUrl ?? ''; : (post.videoUrl ?? '');
const nextHlsUrl = hasVideoUpdate ? (videoFile ? uploadedHlsUrl : '') : post.hlsUrl ?? ''; const nextHlsUrl = hasVideoUpdate ? (videoFile ? uploadedHlsUrl : '') : (post.hlsUrl ?? '');
const nextAudioUrl = hasAudioUpdate const nextAudioUrl = hasAudioUpdate
? audioFile ? audioFile
? uploadedAudioUrl ? uploadedAudioUrl
: dto.audioUrl ?? '' : (dto.audioUrl ?? '')
: post.audioUrl ?? ''; : (post.audioUrl ?? '');
const nextThumbnailVariants = coverImageFile || videoFile const nextThumbnailVariants =
coverImageFile || videoFile
? uploadedThumbnailVariants ? uploadedThumbnailVariants
: typeof dto.thumbnailUrl === 'string' : typeof dto.thumbnailUrl === 'string'
? null ? null
: existingThumbnailVariants; : existingThumbnailVariants;
const nextPostType = this.resolvePostType(nextImageUrls, nextVideoUrl, nextAudioUrl); 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 = const nextTaggedUserIds =
typeof dto.taggedUserIds !== 'undefined' typeof dto.taggedUserIds !== 'undefined'
? await this.normalizeTaggedUserIds(dto.taggedUserIds, userId) ? 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 = const nextCollaboratorIds =
typeof dto.collaboratorIds !== 'undefined' typeof dto.collaboratorIds !== 'undefined'
? await this.normalizeUserIdList(dto.collaboratorIds, userId, 5, 'collaborator') ? 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( const nextImageItems = this.buildImageItems(
nextImageUrls, nextImageUrls,
dto.imageCaptions, dto.imageCaptions,
dto.imageAltTexts, dto.imageAltTexts,
hasImageUpdate ? [] : post.imageItems ?? [], hasImageUpdate ? [] : (post.imageItems ?? []),
); );
const previousMentionUsernames = this.normalizeMentionUsernames(post.mentionUsernames ?? []); const previousMentionUsernames = this.normalizeMentionUsernames(post.mentionUsernames ?? []);
const shouldRecomputeMentions = const shouldRecomputeMentions =
@@ -334,8 +379,11 @@ export class PostsService {
mentionUsernames: previousMentionUsernames, mentionUsernames: previousMentionUsernames,
mentionedUsers: [] as Array<{ id: string; username: string }>, mentionedUsers: [] as Array<{ id: string; username: string }>,
}; };
const { location: nextLocation, latitude: nextLatitude, longitude: nextLongitude } = const {
this.normalizeLocation(dto, { location: nextLocation,
latitude: nextLatitude,
longitude: nextLongitude,
} = this.normalizeLocation(dto, {
location: post.location ?? '', location: post.location ?? '',
latitude: post.latitude ?? null, latitude: post.latitude ?? null,
longitude: post.longitude ?? null, longitude: post.longitude ?? null,
@@ -356,6 +404,7 @@ export class PostsService {
}, },
{ {
audioSourceBuffer: audioFile?.buffer, audioSourceBuffer: audioFile?.buffer,
extractedDurationSeconds: savedVideoUpload?.durationSeconds ?? uploadedAudioDurationSeconds,
waveformSeed: nextAudioUrl || nextContent || post.id, waveformSeed: nextAudioUrl || nextContent || post.id,
thumbnailUrl: uploadedThumbnailUrl, thumbnailUrl: uploadedThumbnailUrl,
}, },
@@ -427,7 +476,9 @@ export class PostsService {
await Promise.all([ await Promise.all([
...savedImageUploads.map((item) => this.deleteSavedImageAsset(item)), ...savedImageUploads.map((item) => this.deleteSavedImageAsset(item)),
uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(), uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(),
uploadedHlsUrl ? this.storageService.deleteContainingDirectory(uploadedHlsUrl) : Promise.resolve(), uploadedHlsUrl
? this.storageService.deleteContainingDirectory(uploadedHlsUrl)
: Promise.resolve(),
uploadedThumbnailUrl uploadedThumbnailUrl
? this.deleteThumbnailAsset(uploadedThumbnailUrl, uploadedThumbnailVariants) ? this.deleteThumbnailAsset(uploadedThumbnailUrl, uploadedThumbnailVariants)
: Promise.resolve(), : Promise.resolve(),
@@ -442,7 +493,9 @@ export class PostsService {
await Promise.all([ await Promise.all([
...savedImageUploads.map((item) => this.deleteSavedImageAsset(item)), ...savedImageUploads.map((item) => this.deleteSavedImageAsset(item)),
uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(), uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(),
uploadedHlsUrl ? this.storageService.deleteContainingDirectory(uploadedHlsUrl) : Promise.resolve(), uploadedHlsUrl
? this.storageService.deleteContainingDirectory(uploadedHlsUrl)
: Promise.resolve(),
uploadedThumbnailUrl uploadedThumbnailUrl
? this.deleteThumbnailAsset(uploadedThumbnailUrl, uploadedThumbnailVariants) ? this.deleteThumbnailAsset(uploadedThumbnailUrl, uploadedThumbnailVariants)
: Promise.resolve(), : Promise.resolve(),
@@ -471,7 +524,10 @@ export class PostsService {
} }
if (hasImageUpdate) { if (hasImageUpdate) {
const nextImageSet = new Set(updated.imageUrls ?? []); const nextImageSet = new Set(updated.imageUrls ?? []);
const existingImageAssets = this.buildSavedImageAssets(post.imageUrls ?? [], existingImageVariants); const existingImageAssets = this.buildSavedImageAssets(
post.imageUrls ?? [],
existingImageVariants,
);
await Promise.all( await Promise.all(
existingImageAssets existingImageAssets
.filter((asset) => !nextImageSet.has(asset.primaryUrl)) .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); const post = await this.postsRepository.findById(postId);
if (!post) { if (!post) {
throw new NotFoundException('Post not found'); throw new NotFoundException('Post not found');
@@ -771,14 +830,18 @@ export class PostsService {
): Promise<PostDocument> { ): Promise<PostDocument> {
await this.assertPostOwner(userId, postId); await this.assertPostOwner(userId, postId);
const updated = await this.postsRepository.updateById(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' ...(typeof dto.commentsFollowersOnly === 'boolean'
? { commentsFollowersOnly: dto.commentsFollowersOnly } ? { commentsFollowersOnly: dto.commentsFollowersOnly }
: {}), : {}),
...(Array.isArray(dto.commentFilterKeywords) ...(Array.isArray(dto.commentFilterKeywords)
? { ? {
commentFilterKeywords: Array.from( 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), ).slice(0, 50),
} }
: {}), : {}),
@@ -809,7 +872,10 @@ export class PostsService {
async archive(userId: string, postId: string): Promise<PostDocument> { async archive(userId: string, postId: string): Promise<PostDocument> {
await this.assertPostOwner(userId, postId); 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) { if (!updated) {
throw new NotFoundException('Post not found'); throw new NotFoundException('Post not found');
} }
@@ -896,7 +962,11 @@ export class PostsService {
return updated; 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 hasImages = imageUrls.length > 0;
const hasVideo = !!videoUrl?.trim(); const hasVideo = !!videoUrl?.trim();
const hasAudio = !!audioUrl?.trim(); const hasAudio = !!audioUrl?.trim();
@@ -933,6 +1003,7 @@ export class PostsService {
}, },
options: { options: {
audioSourceBuffer?: Buffer; audioSourceBuffer?: Buffer;
extractedDurationSeconds?: number | null;
waveformSeed?: string; waveformSeed?: string;
thumbnailUrl?: string; thumbnailUrl?: string;
} = {}, } = {},
@@ -942,7 +1013,9 @@ export class PostsService {
if (!supportsMediaMetadata) { if (!supportsMediaMetadata) {
if (this.hasMediaMetadataInput(dto)) { 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 { return {
durationSeconds: null, durationSeconds: null,
@@ -960,7 +1033,11 @@ export class PostsService {
return { return {
durationSeconds: durationSeconds:
typeof dto.durationSeconds === 'number' ? dto.durationSeconds : fallback.durationSeconds, typeof options.extractedDurationSeconds === 'number'
? options.extractedDurationSeconds
: typeof dto.durationSeconds === 'number'
? dto.durationSeconds
: fallback.durationSeconds,
thumbnailUrl: thumbnailUrl:
typeof dto.thumbnailUrl === 'string' typeof dto.thumbnailUrl === 'string'
? dto.thumbnailUrl.trim() ? dto.thumbnailUrl.trim()
@@ -1036,7 +1113,10 @@ export class PostsService {
const users = await this.usersRepository.findByUsernames(mergedMentionUsernames); const users = await this.usersRepository.findByUsernames(mergedMentionUsernames);
const userByUsername = new Map( 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 const mentionedUsers = mergedMentionUsernames
@@ -1088,8 +1168,14 @@ export class PostsService {
): PostImageItem[] { ): PostImageItem[] {
return imageUrls.map((url, index) => ({ return imageUrls.map((url, index) => ({
url, url,
caption: typeof captions?.[index] === 'string' ? captions[index].trim() : fallback[index]?.caption ?? '', caption:
altText: typeof altTexts?.[index] === 'string' ? altTexts[index].trim() : fallback[index]?.altText ?? '', typeof captions?.[index] === 'string'
? captions[index].trim()
: (fallback[index]?.caption ?? ''),
altText:
typeof altTexts?.[index] === 'string'
? altTexts[index].trim()
: (fallback[index]?.altText ?? ''),
order: index, order: index,
})); }));
} }
@@ -1187,10 +1273,15 @@ export class PostsService {
await Promise.all( await Promise.all(
mentionedUsers.map(async (mentionedUser) => { mentionedUsers.map(async (mentionedUser) => {
try { try {
await this.notificationsService.createMentionNotification(actorId, mentionedUser.id, postId, { await this.notificationsService.createMentionNotification(
actorId,
mentionedUser.id,
postId,
{
resourceType: 'post', resourceType: 'post',
previewText: content.slice(0, 140), previewText: content.slice(0, 140),
}); },
);
} catch (error) { } catch (error) {
this.logger.warn( this.logger.warn(
`Mention notification failed for actor=${actorId} recipient=${mentionedUser.id}: ${ `Mention notification failed for actor=${actorId} recipient=${mentionedUser.id}: ${
@@ -1206,6 +1297,10 @@ export class PostsService {
this.validateMediaFile('video', file); this.validateMediaFile('video', file);
const optimized = await this.videoProcessingService.optimizeForPlayback(file); const optimized = await this.videoProcessingService.optimizeForPlayback(file);
const extension = this.validateMediaFile('video', optimized.file); const extension = this.validateMediaFile('video', optimized.file);
const durationSeconds = await this.mediaProbeService.extractDurationSecondsFromBuffer(
optimized.file.buffer,
optimized.file,
);
let videoUrl = ''; let videoUrl = '';
let hlsUrl = ''; let hlsUrl = '';
@@ -1241,18 +1336,24 @@ export class PostsService {
thumbnailVariants = savedThumbnail.variants; thumbnailVariants = savedThumbnail.variants;
} }
return { videoUrl, hlsUrl, thumbnailUrl, thumbnailVariants }; return { videoUrl, hlsUrl, thumbnailUrl, thumbnailVariants, durationSeconds };
} catch (error) { } catch (error) {
await Promise.all([ await Promise.all([
videoUrl ? this.deleteManagedPostMedia(videoUrl) : Promise.resolve(), videoUrl ? this.deleteManagedPostMedia(videoUrl) : Promise.resolve(),
hlsUrl ? this.storageService.deleteContainingDirectory(hlsUrl) : 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; 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); const sourcePost = await this.postsRepository.findById(sourcePostId);
if (!sourcePost) { if (!sourcePost) {
throw new NotFoundException('Source post not found'); throw new NotFoundException('Source post not found');
@@ -1289,8 +1390,7 @@ export class PostsService {
file: { mimetype?: string; size: number; buffer: Buffer; originalname?: string }, file: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
): Promise<string> { ): Promise<string> {
const extension = this.validateMediaFile(mediaType, file); const extension = this.validateMediaFile(mediaType, file);
const folder = const folder = mediaType === 'image' ? 'images' : mediaType === 'video' ? 'videos' : 'audios';
mediaType === 'image' ? 'images' : mediaType === 'video' ? 'videos' : 'audios';
return this.storageService.saveFile({ return this.storageService.saveFile({
folderSegments: ['posts', folder], folderSegments: ['posts', folder],
extension, extension,
@@ -1539,7 +1639,8 @@ export class PostsService {
thumbnailVariants?.originalUrl || thumbnailVariants?.originalUrl ||
''; '';
const hasManagedVariantGroup = !!thumbnailVariants && const hasManagedVariantGroup =
!!thumbnailVariants &&
[ [
thumbnailVariants.originalUrl, thumbnailVariants.originalUrl,
thumbnailVariants.lowUrl, thumbnailVariants.lowUrl,