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_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();
|
||||||
|
|||||||
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 { 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,
|
||||||
|
|||||||
المرجع في مشكلة جديدة
حظر مستخدم