Harden media health checks and duration extraction
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled
هذا الالتزام موجود في:
@@ -50,6 +50,8 @@ QUEUE_WORKER_CONCURRENCY=5
|
||||
|
||||
STORAGE_PROVIDER=local
|
||||
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.
|
||||
STORAGE_PUBLIC_BASE_URL=
|
||||
S3_BUCKET=
|
||||
@@ -76,6 +78,8 @@ VIDEO_PROCESSING_GENERATE_THUMBNAILS=true
|
||||
VIDEO_PROCESSING_GENERATE_HLS=true
|
||||
VIDEO_PROCESSING_HLS_SEGMENT_DURATION_SECONDS=4
|
||||
VIDEO_PROCESSING_THUMBNAIL_WIDTH=720
|
||||
AUDIO_PROCESSING_ENABLED=false
|
||||
AUDIO_WAVEFORM_PEAKS=48
|
||||
|
||||
GOOGLE_CLIENT_ID=your_google_client_id
|
||||
GOOGLE_CLIENT_SECRET=your_google_client_secret
|
||||
|
||||
@@ -2,7 +2,16 @@
|
||||
|
||||
import Link from "next/link";
|
||||
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 { 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() {
|
||||
const { permissions } = useSuperAdminSession();
|
||||
const [snapshot, setSnapshot] = useState<DashboardSnapshot>({
|
||||
@@ -417,26 +454,33 @@ export default function DashboardPage() {
|
||||
}
|
||||
>
|
||||
<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">
|
||||
Activity feeds
|
||||
View analytics
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!snapshot.recentActivity.length ? (
|
||||
<EmptyState
|
||||
title="No recent activity"
|
||||
title="No platform activity"
|
||||
description="Operational and moderation activity will appear here."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{snapshot.recentActivity.map((item, index) => (
|
||||
{snapshot.recentActivity.slice(0, 5).map((item, index) => (
|
||||
<div
|
||||
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="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
|
||||
variant={
|
||||
item.status === "flagged" || item.status === "disabled"
|
||||
@@ -444,11 +488,11 @@ export default function DashboardPage() {
|
||||
: "muted"
|
||||
}
|
||||
>
|
||||
{item.type}
|
||||
{getActivityLabel(item.type)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{item.subtitle} • {formatDateTime(item.createdAt)}
|
||||
<div dir="auto" className="mt-2 truncate text-xs text-muted-foreground">
|
||||
{item.subtitle || "No additional details"} - {formatDateTime(item.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<Record<string, unknown>> {
|
||||
const provider = this.getProvider();
|
||||
const basePath = this.getBasePath();
|
||||
const publicBaseUrl =
|
||||
(this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? '').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
);
|
||||
const storagePublicBaseUrl = (
|
||||
this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? ''
|
||||
).replace(/\/$/, '');
|
||||
const publicBaseUrl = (
|
||||
this.configService.get<string>('publicBaseUrl', { infer: true }) ?? ''
|
||||
).replace(/\/$/, '');
|
||||
const health: Record<string, unknown> = {
|
||||
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<string>('storage.provider', { infer: true }) as
|
||||
return (
|
||||
(this.configService.get<string>('storage.provider', { infer: true }) as
|
||||
| 'local'
|
||||
| 's3'
|
||||
| undefined) ?? 'local';
|
||||
| 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<string>('storage.publicBaseUrl', { infer: true }) ?? '').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
);
|
||||
const publicBaseUrl = (
|
||||
this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? ''
|
||||
).replace(/\/$/, '');
|
||||
if (publicBaseUrl) {
|
||||
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 forcePathStyle =
|
||||
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 {
|
||||
const normalizedUrl = fileUrl.split('?')[0].split('#')[0];
|
||||
const publicBaseUrl =
|
||||
(this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? '').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
);
|
||||
const publicBaseUrl = (
|
||||
this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? ''
|
||||
).replace(/\/$/, '');
|
||||
|
||||
if (publicBaseUrl && normalizedUrl.startsWith(`${publicBaseUrl}/`)) {
|
||||
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 forcePathStyle =
|
||||
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> {
|
||||
const client = this.getS3Client();
|
||||
const bucket = this.getS3Bucket();
|
||||
|
||||
208
src/infrastructure/storage/media-probe.service.ts
Normal file
208
src/infrastructure/storage/media-probe.service.ts
Normal file
@@ -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 { 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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
return {
|
||||
storage: await this.storageService.getHealth(),
|
||||
processing: {
|
||||
imageProcessingEnabled:
|
||||
this.configService.get<boolean>('imageProcessing.enabled', { infer: true }) ?? false,
|
||||
videoProcessingEnabled:
|
||||
this.configService.get<boolean>('videoProcessing.enabled', { infer: true }) ?? false,
|
||||
videoHlsGenerationEnabled:
|
||||
this.configService.get<boolean>('videoProcessing.generateHls', { infer: true }) ?? true,
|
||||
videoThumbnailGenerationEnabled:
|
||||
const [storageHealth, ffmpeg, ffprobe] = await Promise.all([
|
||||
this.storageService.getHealth(),
|
||||
this.mediaProbeService.checkFfmpeg(),
|
||||
this.mediaProbeService.checkFfprobe(),
|
||||
]);
|
||||
const storageProvider =
|
||||
this.configService.get<string>('storage.provider', { infer: true }) ?? 'local';
|
||||
const publicBaseUrl = this.configService.get<string>('publicBaseUrl', { infer: true }) ?? '';
|
||||
const warnings: string[] = [];
|
||||
const imageProcessingEnabled =
|
||||
this.configService.get<boolean>('imageProcessing.enabled', { infer: true }) ?? false;
|
||||
const videoProcessingEnabled =
|
||||
this.configService.get<boolean>('videoProcessing.enabled', { infer: true }) ?? false;
|
||||
const videoHlsGenerationEnabled =
|
||||
this.configService.get<boolean>('videoProcessing.generateHls', { infer: true }) ?? true;
|
||||
const videoThumbnailGenerationEnabled =
|
||||
this.configService.get<boolean>('videoProcessing.generateThumbnails', { infer: true }) ??
|
||||
true,
|
||||
ffmpegPath:
|
||||
this.configService.get<string>('videoProcessing.ffmpegPath', { infer: true }) ?? 'ffmpeg',
|
||||
true;
|
||||
const audioProcessingEnabled =
|
||||
this.configService.get<boolean>('audioProcessing.enabled', { infer: true }) ?? false;
|
||||
|
||||
if (storageProvider === 'local') {
|
||||
warnings.push(
|
||||
'Local storage requires persistent volume mounted to /app/uploads in production',
|
||||
);
|
||||
}
|
||||
if (!publicBaseUrl) {
|
||||
warnings.push('PUBLIC_BASE_URL is not configured');
|
||||
}
|
||||
if ((imageProcessingEnabled || videoProcessingEnabled) && !ffmpeg.available) {
|
||||
warnings.push('ffmpeg is not available; image/video processing may fail');
|
||||
}
|
||||
if (!ffprobe.available) {
|
||||
warnings.push('ffprobe is not available; duration extraction may fail');
|
||||
}
|
||||
if (storageProvider === 's3' && !(storageHealth.isS3Configured ?? storageHealth.s3Configured)) {
|
||||
warnings.push('S3 provider selected but missing required env variables');
|
||||
}
|
||||
|
||||
const storageWritable =
|
||||
storageProvider !== 'local' || storageHealth.uploadPathWritable !== false;
|
||||
const status = !storageWritable ? 'error' : warnings.length ? 'warning' : 'ok';
|
||||
|
||||
return {
|
||||
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: {
|
||||
rangeRequests: true,
|
||||
immutableCacheSeconds: 31536000,
|
||||
hlsManifestCacheSeconds: 300,
|
||||
},
|
||||
staticServing: {
|
||||
uploadsPublicPath:
|
||||
storageHealth.uploadsPublicPath ?? storageHealth.publicPath ?? '/uploads',
|
||||
rangeRequestsExpected: true,
|
||||
cacheHeadersExpected: true,
|
||||
hlsMimeExpected: 'application/vnd.apple.mpegurl',
|
||||
},
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,7 +119,7 @@ export class MediaService {
|
||||
const client = await auth.getClient();
|
||||
const accessTokenRaw = await client.getAccessToken();
|
||||
const accessToken =
|
||||
typeof accessTokenRaw === 'string' ? accessTokenRaw : accessTokenRaw?.token ?? '';
|
||||
typeof accessTokenRaw === 'string' ? accessTokenRaw : (accessTokenRaw?.token ?? '');
|
||||
|
||||
if (!accessToken) {
|
||||
throw new ServiceUnavailableException('Failed to authenticate with Google Cloud');
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { extname } from 'path';
|
||||
import { Connection, Types } from 'mongoose';
|
||||
import { InjectConnection } from '@nestjs/mongoose';
|
||||
@@ -23,6 +29,7 @@ import {
|
||||
UploadedVideoFile,
|
||||
VideoProcessingService,
|
||||
} from '../../infrastructure/storage/video-processing.service';
|
||||
import { MediaProbeService } from '../../infrastructure/storage/media-probe.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { UsersRepository } from '../users/users.repository';
|
||||
@@ -39,12 +46,7 @@ import { PostsRepository } from './posts.repository';
|
||||
|
||||
type PostMediaMetadataInput = Pick<
|
||||
CreatePostDto,
|
||||
| 'durationSeconds'
|
||||
| 'thumbnailUrl'
|
||||
| 'style'
|
||||
| 'maqam'
|
||||
| 'rhythmSignature'
|
||||
| 'waveformPeaks'
|
||||
'durationSeconds' | 'thumbnailUrl' | 'style' | 'maqam' | 'rhythmSignature' | 'waveformPeaks'
|
||||
>;
|
||||
|
||||
type NormalizedPostMediaMetadata = {
|
||||
@@ -61,6 +63,7 @@ type SavedVideoUpload = {
|
||||
hlsUrl: string;
|
||||
thumbnailUrl: string;
|
||||
thumbnailVariants: PostMediaVariantSet | null;
|
||||
durationSeconds: number | null;
|
||||
};
|
||||
|
||||
type SavedImageUpload = {
|
||||
@@ -79,6 +82,7 @@ export class PostsService {
|
||||
private readonly storageService: ManagedStorageService,
|
||||
private readonly imageProcessingService: ImageProcessingService,
|
||||
private readonly videoProcessingService: VideoProcessingService,
|
||||
private readonly mediaProbeService: MediaProbeService,
|
||||
private readonly feedVersionService: FeedVersionService,
|
||||
private readonly notificationsService: NotificationsService,
|
||||
private readonly auditService: AuditService,
|
||||
@@ -87,7 +91,12 @@ export class PostsService {
|
||||
async create(
|
||||
userId: string,
|
||||
dto: CreatePostDto,
|
||||
imageFiles: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }> = [],
|
||||
imageFiles: Array<{
|
||||
mimetype?: string;
|
||||
size: number;
|
||||
buffer: Buffer;
|
||||
originalname?: string;
|
||||
}> = [],
|
||||
videoFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
|
||||
audioFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
|
||||
coverImageFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
|
||||
@@ -134,6 +143,9 @@ export class PostsService {
|
||||
const generatedThumbnailVariants = savedVideoUpload?.thumbnailVariants ?? null;
|
||||
const uploadedThumbnailUrl = savedCoverImageUpload?.primaryUrl ?? generatedThumbnailUrl;
|
||||
const uploadedThumbnailVariants = savedCoverImageUpload?.variants ?? generatedThumbnailVariants;
|
||||
const uploadedAudioDurationSeconds = audioFile
|
||||
? await this.mediaProbeService.extractDurationSecondsFromBuffer(audioFile.buffer, audioFile)
|
||||
: null;
|
||||
const uploadedAudioUrl = audioFile ? await this.saveMediaFile('audio', audioFile) : '';
|
||||
const finalImageUrls = uploadedImageUrls.length ? uploadedImageUrls : inputImageUrls;
|
||||
const finalImageVariants = uploadedImageVariants.length ? uploadedImageVariants : [];
|
||||
@@ -141,9 +153,18 @@ export class PostsService {
|
||||
const finalAudioUrl = uploadedAudioUrl || dto.audioUrl || '';
|
||||
const finalContent = dto.content?.trim() ?? '';
|
||||
const taggedUserIds = await this.normalizeTaggedUserIds(dto.taggedUserIds, userId);
|
||||
const collaboratorIds = await this.normalizeUserIdList(dto.collaboratorIds, userId, 5, 'collaborator');
|
||||
const collaboratorIds = await this.normalizeUserIdList(
|
||||
dto.collaboratorIds,
|
||||
userId,
|
||||
5,
|
||||
'collaborator',
|
||||
);
|
||||
const imageItems = this.buildImageItems(finalImageUrls, dto.imageCaptions, dto.imageAltTexts);
|
||||
const mentionResolution = await this.resolveMentionTargets(dto.mentionUsernames, finalContent, userId);
|
||||
const mentionResolution = await this.resolveMentionTargets(
|
||||
dto.mentionUsernames,
|
||||
finalContent,
|
||||
userId,
|
||||
);
|
||||
const { location, latitude, longitude } = this.normalizeLocation(dto);
|
||||
if (!finalContent && !finalImageUrls.length && !finalVideoUrl && !finalAudioUrl) {
|
||||
throw new BadRequestException('Post must contain caption or media');
|
||||
@@ -153,6 +174,7 @@ export class PostsService {
|
||||
const hashtags = this.extractHashtags(finalContent);
|
||||
const mediaMetadata = this.normalizeMediaMetadata(dto, postType, undefined, {
|
||||
audioSourceBuffer: audioFile?.buffer,
|
||||
extractedDurationSeconds: savedVideoUpload?.durationSeconds ?? uploadedAudioDurationSeconds,
|
||||
waveformSeed: finalAudioUrl || finalContent || `${userId}:${Date.now()}`,
|
||||
thumbnailUrl: uploadedThumbnailUrl,
|
||||
});
|
||||
@@ -186,7 +208,9 @@ export class PostsService {
|
||||
await Promise.all([
|
||||
...savedImageUploads.map((item) => this.deleteSavedImageAsset(item)),
|
||||
uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(),
|
||||
uploadedHlsUrl ? this.storageService.deleteContainingDirectory(uploadedHlsUrl) : Promise.resolve(),
|
||||
uploadedHlsUrl
|
||||
? this.storageService.deleteContainingDirectory(uploadedHlsUrl)
|
||||
: Promise.resolve(),
|
||||
uploadedThumbnailUrl
|
||||
? this.deleteThumbnailAsset(uploadedThumbnailUrl, uploadedThumbnailVariants)
|
||||
: Promise.resolve(),
|
||||
@@ -204,7 +228,12 @@ export class PostsService {
|
||||
|
||||
await this.usersRepository.incrementPostsCount(userId, 1);
|
||||
await this.feedVersionService.bumpGlobalVersion();
|
||||
await this.notifyMentionedUsers(userId, post.id, mentionResolution.mentionedUsers, finalContent);
|
||||
await this.notifyMentionedUsers(
|
||||
userId,
|
||||
post.id,
|
||||
mentionResolution.mentionedUsers,
|
||||
finalContent,
|
||||
);
|
||||
return post;
|
||||
}
|
||||
|
||||
@@ -212,7 +241,12 @@ export class PostsService {
|
||||
userId: string,
|
||||
postId: string,
|
||||
dto: UpdatePostDto,
|
||||
imageFiles: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }> = [],
|
||||
imageFiles: Array<{
|
||||
mimetype?: string;
|
||||
size: number;
|
||||
buffer: Buffer;
|
||||
originalname?: string;
|
||||
}> = [],
|
||||
videoFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
|
||||
audioFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
|
||||
coverImageFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
|
||||
@@ -249,7 +283,10 @@ export class PostsService {
|
||||
if (coverImageFile && (imageFiles.length || inputImageUrls.length)) {
|
||||
throw new BadRequestException('coverImageFile is allowed only with video or audio posts');
|
||||
}
|
||||
if (coverImageFile && !(videoFile || audioFile || dto.videoUrl || dto.audioUrl || post.videoUrl || post.audioUrl)) {
|
||||
if (
|
||||
coverImageFile &&
|
||||
!(videoFile || audioFile || dto.videoUrl || dto.audioUrl || post.videoUrl || post.audioUrl)
|
||||
) {
|
||||
throw new BadRequestException('coverImageFile is allowed only with video or audio posts');
|
||||
}
|
||||
if ((videoFile || dto.videoUrl) && (imageFiles.length || inputImageUrls.length)) {
|
||||
@@ -272,6 +309,9 @@ export class PostsService {
|
||||
const generatedThumbnailVariants = savedVideoUpload?.thumbnailVariants ?? null;
|
||||
const uploadedThumbnailUrl = savedCoverImageUpload?.primaryUrl ?? generatedThumbnailUrl;
|
||||
const uploadedThumbnailVariants = savedCoverImageUpload?.variants ?? generatedThumbnailVariants;
|
||||
const uploadedAudioDurationSeconds = audioFile
|
||||
? await this.mediaProbeService.extractDurationSecondsFromBuffer(audioFile.buffer, audioFile)
|
||||
: null;
|
||||
const uploadedAudioUrl = audioFile ? await this.saveMediaFile('audio', audioFile) : '';
|
||||
const hasImageUpdate = typeof dto.imageUrls !== 'undefined' || imageFiles.length > 0;
|
||||
const existingImageVariants = Array.isArray((post as any).imageVariants)
|
||||
@@ -286,7 +326,7 @@ export class PostsService {
|
||||
? imageFiles.length
|
||||
? uploadedImageUrls
|
||||
: inputImageUrls
|
||||
: post.imageUrls ?? [];
|
||||
: (post.imageUrls ?? []);
|
||||
const nextImageVariants = hasImageUpdate
|
||||
? imageFiles.length
|
||||
? uploadedImageVariants
|
||||
@@ -296,34 +336,39 @@ export class PostsService {
|
||||
const nextVideoUrl = hasVideoUpdate
|
||||
? videoFile
|
||||
? uploadedVideoUrl
|
||||
: dto.videoUrl ?? ''
|
||||
: post.videoUrl ?? '';
|
||||
const nextHlsUrl = hasVideoUpdate ? (videoFile ? uploadedHlsUrl : '') : post.hlsUrl ?? '';
|
||||
: (dto.videoUrl ?? '')
|
||||
: (post.videoUrl ?? '');
|
||||
const nextHlsUrl = hasVideoUpdate ? (videoFile ? uploadedHlsUrl : '') : (post.hlsUrl ?? '');
|
||||
const nextAudioUrl = hasAudioUpdate
|
||||
? audioFile
|
||||
? uploadedAudioUrl
|
||||
: dto.audioUrl ?? ''
|
||||
: post.audioUrl ?? '';
|
||||
const nextThumbnailVariants = coverImageFile || videoFile
|
||||
: (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,8 +379,11 @@ export class PostsService {
|
||||
mentionUsernames: previousMentionUsernames,
|
||||
mentionedUsers: [] as Array<{ id: string; username: string }>,
|
||||
};
|
||||
const { location: nextLocation, latitude: nextLatitude, longitude: nextLongitude } =
|
||||
this.normalizeLocation(dto, {
|
||||
const {
|
||||
location: nextLocation,
|
||||
latitude: nextLatitude,
|
||||
longitude: nextLongitude,
|
||||
} = this.normalizeLocation(dto, {
|
||||
location: post.location ?? '',
|
||||
latitude: post.latitude ?? null,
|
||||
longitude: post.longitude ?? null,
|
||||
@@ -356,6 +404,7 @@ export class PostsService {
|
||||
},
|
||||
{
|
||||
audioSourceBuffer: audioFile?.buffer,
|
||||
extractedDurationSeconds: savedVideoUpload?.durationSeconds ?? uploadedAudioDurationSeconds,
|
||||
waveformSeed: nextAudioUrl || nextContent || post.id,
|
||||
thumbnailUrl: uploadedThumbnailUrl,
|
||||
},
|
||||
@@ -427,7 +476,9 @@ export class PostsService {
|
||||
await Promise.all([
|
||||
...savedImageUploads.map((item) => this.deleteSavedImageAsset(item)),
|
||||
uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(),
|
||||
uploadedHlsUrl ? this.storageService.deleteContainingDirectory(uploadedHlsUrl) : Promise.resolve(),
|
||||
uploadedHlsUrl
|
||||
? this.storageService.deleteContainingDirectory(uploadedHlsUrl)
|
||||
: Promise.resolve(),
|
||||
uploadedThumbnailUrl
|
||||
? this.deleteThumbnailAsset(uploadedThumbnailUrl, uploadedThumbnailVariants)
|
||||
: Promise.resolve(),
|
||||
@@ -442,7 +493,9 @@ export class PostsService {
|
||||
await Promise.all([
|
||||
...savedImageUploads.map((item) => this.deleteSavedImageAsset(item)),
|
||||
uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(),
|
||||
uploadedHlsUrl ? this.storageService.deleteContainingDirectory(uploadedHlsUrl) : Promise.resolve(),
|
||||
uploadedHlsUrl
|
||||
? this.storageService.deleteContainingDirectory(uploadedHlsUrl)
|
||||
: Promise.resolve(),
|
||||
uploadedThumbnailUrl
|
||||
? this.deleteThumbnailAsset(uploadedThumbnailUrl, uploadedThumbnailVariants)
|
||||
: Promise.resolve(),
|
||||
@@ -471,7 +524,10 @@ export class PostsService {
|
||||
}
|
||||
if (hasImageUpdate) {
|
||||
const nextImageSet = new Set(updated.imageUrls ?? []);
|
||||
const existingImageAssets = this.buildSavedImageAssets(post.imageUrls ?? [], existingImageVariants);
|
||||
const existingImageAssets = this.buildSavedImageAssets(
|
||||
post.imageUrls ?? [],
|
||||
existingImageVariants,
|
||||
);
|
||||
await Promise.all(
|
||||
existingImageAssets
|
||||
.filter((asset) => !nextImageSet.has(asset.primaryUrl))
|
||||
@@ -692,7 +748,10 @@ export class PostsService {
|
||||
});
|
||||
}
|
||||
|
||||
async registerView(userId: string, postId: string): Promise<{ success: true; postId: string; viewCount: number }> {
|
||||
async registerView(
|
||||
userId: string,
|
||||
postId: string,
|
||||
): Promise<{ success: true; postId: string; viewCount: number }> {
|
||||
const post = await this.postsRepository.findById(postId);
|
||||
if (!post) {
|
||||
throw new NotFoundException('Post not found');
|
||||
@@ -771,14 +830,18 @@ export class PostsService {
|
||||
): Promise<PostDocument> {
|
||||
await this.assertPostOwner(userId, postId);
|
||||
const updated = await this.postsRepository.updateById(postId, {
|
||||
...(typeof dto.commentsDisabled === 'boolean' ? { commentsDisabled: dto.commentsDisabled } : {}),
|
||||
...(typeof dto.commentsDisabled === 'boolean'
|
||||
? { commentsDisabled: dto.commentsDisabled }
|
||||
: {}),
|
||||
...(typeof dto.commentsFollowersOnly === 'boolean'
|
||||
? { commentsFollowersOnly: dto.commentsFollowersOnly }
|
||||
: {}),
|
||||
...(Array.isArray(dto.commentFilterKeywords)
|
||||
? {
|
||||
commentFilterKeywords: Array.from(
|
||||
new Set(dto.commentFilterKeywords.map((item) => item.trim().toLowerCase()).filter(Boolean)),
|
||||
new Set(
|
||||
dto.commentFilterKeywords.map((item) => item.trim().toLowerCase()).filter(Boolean),
|
||||
),
|
||||
).slice(0, 50),
|
||||
}
|
||||
: {}),
|
||||
@@ -809,7 +872,10 @@ export class PostsService {
|
||||
|
||||
async archive(userId: string, postId: string): Promise<PostDocument> {
|
||||
await this.assertPostOwner(userId, postId);
|
||||
const updated = await this.postsRepository.updateById(postId, { isArchived: true, pinnedToProfile: false });
|
||||
const updated = await this.postsRepository.updateById(postId, {
|
||||
isArchived: true,
|
||||
pinnedToProfile: false,
|
||||
});
|
||||
if (!updated) {
|
||||
throw new NotFoundException('Post not found');
|
||||
}
|
||||
@@ -896,7 +962,11 @@ export class PostsService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
private resolvePostType(imageUrls: string[] = [], videoUrl?: string, audioUrl?: string): PostType {
|
||||
private resolvePostType(
|
||||
imageUrls: string[] = [],
|
||||
videoUrl?: string,
|
||||
audioUrl?: string,
|
||||
): PostType {
|
||||
const hasImages = imageUrls.length > 0;
|
||||
const hasVideo = !!videoUrl?.trim();
|
||||
const hasAudio = !!audioUrl?.trim();
|
||||
@@ -933,6 +1003,7 @@ export class PostsService {
|
||||
},
|
||||
options: {
|
||||
audioSourceBuffer?: Buffer;
|
||||
extractedDurationSeconds?: number | null;
|
||||
waveformSeed?: string;
|
||||
thumbnailUrl?: string;
|
||||
} = {},
|
||||
@@ -942,7 +1013,9 @@ export class PostsService {
|
||||
|
||||
if (!supportsMediaMetadata) {
|
||||
if (this.hasMediaMetadataInput(dto)) {
|
||||
throw new BadRequestException('Audio/video metadata is allowed only for audio or video posts');
|
||||
throw new BadRequestException(
|
||||
'Audio/video metadata is allowed only for audio or video posts',
|
||||
);
|
||||
}
|
||||
return {
|
||||
durationSeconds: null,
|
||||
@@ -960,7 +1033,11 @@ export class PostsService {
|
||||
|
||||
return {
|
||||
durationSeconds:
|
||||
typeof dto.durationSeconds === 'number' ? dto.durationSeconds : fallback.durationSeconds,
|
||||
typeof options.extractedDurationSeconds === 'number'
|
||||
? options.extractedDurationSeconds
|
||||
: typeof dto.durationSeconds === 'number'
|
||||
? dto.durationSeconds
|
||||
: fallback.durationSeconds,
|
||||
thumbnailUrl:
|
||||
typeof dto.thumbnailUrl === 'string'
|
||||
? dto.thumbnailUrl.trim()
|
||||
@@ -1036,7 +1113,10 @@ export class PostsService {
|
||||
|
||||
const users = await this.usersRepository.findByUsernames(mergedMentionUsernames);
|
||||
const userByUsername = new Map(
|
||||
users.map((user) => [user.username.toLowerCase(), { id: user.id, username: user.username.toLowerCase() }]),
|
||||
users.map((user) => [
|
||||
user.username.toLowerCase(),
|
||||
{ id: user.id, username: user.username.toLowerCase() },
|
||||
]),
|
||||
);
|
||||
|
||||
const mentionedUsers = mergedMentionUsernames
|
||||
@@ -1088,8 +1168,14 @@ export class PostsService {
|
||||
): PostImageItem[] {
|
||||
return imageUrls.map((url, index) => ({
|
||||
url,
|
||||
caption: typeof captions?.[index] === 'string' ? captions[index].trim() : fallback[index]?.caption ?? '',
|
||||
altText: typeof altTexts?.[index] === 'string' ? altTexts[index].trim() : fallback[index]?.altText ?? '',
|
||||
caption:
|
||||
typeof captions?.[index] === 'string'
|
||||
? captions[index].trim()
|
||||
: (fallback[index]?.caption ?? ''),
|
||||
altText:
|
||||
typeof altTexts?.[index] === 'string'
|
||||
? altTexts[index].trim()
|
||||
: (fallback[index]?.altText ?? ''),
|
||||
order: index,
|
||||
}));
|
||||
}
|
||||
@@ -1187,10 +1273,15 @@ export class PostsService {
|
||||
await Promise.all(
|
||||
mentionedUsers.map(async (mentionedUser) => {
|
||||
try {
|
||||
await this.notificationsService.createMentionNotification(actorId, mentionedUser.id, postId, {
|
||||
await this.notificationsService.createMentionNotification(
|
||||
actorId,
|
||||
mentionedUser.id,
|
||||
postId,
|
||||
{
|
||||
resourceType: 'post',
|
||||
previewText: content.slice(0, 140),
|
||||
});
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Mention notification failed for actor=${actorId} recipient=${mentionedUser.id}: ${
|
||||
@@ -1206,6 +1297,10 @@ export class PostsService {
|
||||
this.validateMediaFile('video', file);
|
||||
const optimized = await this.videoProcessingService.optimizeForPlayback(file);
|
||||
const extension = this.validateMediaFile('video', optimized.file);
|
||||
const durationSeconds = await this.mediaProbeService.extractDurationSecondsFromBuffer(
|
||||
optimized.file.buffer,
|
||||
optimized.file,
|
||||
);
|
||||
|
||||
let videoUrl = '';
|
||||
let hlsUrl = '';
|
||||
@@ -1241,18 +1336,24 @@ export class PostsService {
|
||||
thumbnailVariants = savedThumbnail.variants;
|
||||
}
|
||||
|
||||
return { videoUrl, hlsUrl, thumbnailUrl, thumbnailVariants };
|
||||
return { videoUrl, hlsUrl, thumbnailUrl, thumbnailVariants, durationSeconds };
|
||||
} catch (error) {
|
||||
await Promise.all([
|
||||
videoUrl ? this.deleteManagedPostMedia(videoUrl) : Promise.resolve(),
|
||||
hlsUrl ? this.storageService.deleteContainingDirectory(hlsUrl) : Promise.resolve(),
|
||||
thumbnailUrl ? this.deleteThumbnailAsset(thumbnailUrl, thumbnailVariants) : Promise.resolve(),
|
||||
thumbnailUrl
|
||||
? this.deleteThumbnailAsset(thumbnailUrl, thumbnailVariants)
|
||||
: Promise.resolve(),
|
||||
]);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createRepost(userId: string, sourcePostId: string, dto: CreateRepostDto): Promise<PostDocument> {
|
||||
async createRepost(
|
||||
userId: string,
|
||||
sourcePostId: string,
|
||||
dto: CreateRepostDto,
|
||||
): Promise<PostDocument> {
|
||||
const sourcePost = await this.postsRepository.findById(sourcePostId);
|
||||
if (!sourcePost) {
|
||||
throw new NotFoundException('Source post not found');
|
||||
@@ -1289,8 +1390,7 @@ export class PostsService {
|
||||
file: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
|
||||
): Promise<string> {
|
||||
const extension = this.validateMediaFile(mediaType, file);
|
||||
const folder =
|
||||
mediaType === 'image' ? 'images' : mediaType === 'video' ? 'videos' : 'audios';
|
||||
const folder = mediaType === 'image' ? 'images' : mediaType === 'video' ? 'videos' : 'audios';
|
||||
return this.storageService.saveFile({
|
||||
folderSegments: ['posts', folder],
|
||||
extension,
|
||||
@@ -1539,7 +1639,8 @@ export class PostsService {
|
||||
thumbnailVariants?.originalUrl ||
|
||||
'';
|
||||
|
||||
const hasManagedVariantGroup = !!thumbnailVariants &&
|
||||
const hasManagedVariantGroup =
|
||||
!!thumbnailVariants &&
|
||||
[
|
||||
thumbnailVariants.originalUrl,
|
||||
thumbnailVariants.lowUrl,
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم