Add optimized media delivery and HLS support
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled
هذا الالتزام موجود في:
@@ -66,6 +66,8 @@ VIDEO_PROCESSING_CRF=28
|
||||
VIDEO_PROCESSING_PRESET=veryfast
|
||||
VIDEO_PROCESSING_AUDIO_BITRATE_KBPS=128
|
||||
VIDEO_PROCESSING_GENERATE_THUMBNAILS=true
|
||||
VIDEO_PROCESSING_GENERATE_HLS=true
|
||||
VIDEO_PROCESSING_HLS_SEGMENT_DURATION_SECONDS=4
|
||||
VIDEO_PROCESSING_THUMBNAIL_WIDTH=720
|
||||
|
||||
GOOGLE_CLIENT_ID=your_google_client_id
|
||||
|
||||
@@ -18,7 +18,7 @@ CORS_ORIGINS=http://192.168.1.12:3000,http://192.168.1.12:5173
|
||||
GOOGLE_CALLBACK_URL=http://192.168.1.12:4000/api/v1/auth/google/callback
|
||||
```
|
||||
|
||||
With `PUBLIC_BASE_URL` configured, file fields such as `avatar`, `coverImage`, `imageUrls`, `videoUrl`, `audioUrl`, `thumbnailUrl`, `mediaUrl`, and marketplace images are returned as absolute URLs.
|
||||
With `PUBLIC_BASE_URL` configured, file fields such as `avatar`, `coverImage`, `imageUrls`, `videoUrl`, `hlsUrl`, `audioUrl`, `thumbnailUrl`, `mediaUrl`, and marketplace images are returned as absolute URLs.
|
||||
|
||||
## Pagination contract
|
||||
|
||||
@@ -211,11 +211,30 @@ When `VIDEO_PROCESSING_ENABLED=true` and `ffmpeg` is available on the server:
|
||||
|
||||
- uploaded post/reel videos are converted to optimized `mp4`
|
||||
- `+faststart` is applied so playback begins faster on mobile/web
|
||||
- uploaded post/reel videos also produce an HLS playlist at `hlsUrl` when `VIDEO_PROCESSING_GENERATE_HLS=true`
|
||||
- local storage responses serve `mp4`, `m3u8`, `m4s`, and `ts` files with explicit media `Content-Type` headers and `Accept-Ranges: bytes`
|
||||
- a thumbnail image is generated automatically if the client does not send `thumbnailUrl`
|
||||
|
||||
If `ffmpeg` is not installed or video processing is disabled, uploads still work and the original
|
||||
video file is stored as-is.
|
||||
|
||||
Recommended client behavior:
|
||||
|
||||
- use `hlsUrl` first when present for adaptive/streaming playback
|
||||
- fall back to `videoUrl` for progressive `mp4` playback
|
||||
|
||||
## Audio and image delivery
|
||||
|
||||
Managed uploads are stored under stable UUID-based paths, so local storage responses now send cache-friendly headers:
|
||||
|
||||
- images (`jpg`, `jpeg`, `png`, `webp`, `gif`) are served with explicit `Content-Type` and long-lived immutable `Cache-Control`
|
||||
- audio (`mp3`, `wav`, `m4a`, `aac`, `ogg`) is served with explicit `Content-Type`, long-lived immutable `Cache-Control`, and `Accept-Ranges: bytes`
|
||||
|
||||
Recommended client behavior:
|
||||
|
||||
- for audio, stream `audioUrl` directly and allow byte-range playback/resume
|
||||
- for images, render `imageUrls` directly and rely on URL-based caching for repeat views
|
||||
|
||||
## Marketplace split
|
||||
|
||||
Marketplace is now separated from musical instruments at the API contract level:
|
||||
|
||||
@@ -104,6 +104,10 @@ export default () => ({
|
||||
audioBitrateKbps: Number(process.env.VIDEO_PROCESSING_AUDIO_BITRATE_KBPS ?? 128),
|
||||
generateThumbnails:
|
||||
(process.env.VIDEO_PROCESSING_GENERATE_THUMBNAILS ?? 'true').toLowerCase() === 'true',
|
||||
generateHls: (process.env.VIDEO_PROCESSING_GENERATE_HLS ?? 'true').toLowerCase() === 'true',
|
||||
hlsSegmentDurationSeconds: Number(
|
||||
process.env.VIDEO_PROCESSING_HLS_SEGMENT_DURATION_SECONDS ?? 4,
|
||||
),
|
||||
thumbnailWidth: Number(process.env.VIDEO_PROCESSING_THUMBNAIL_WIDTH ?? 720),
|
||||
},
|
||||
logging: {
|
||||
|
||||
@@ -71,6 +71,8 @@ export const validationSchema = Joi.object({
|
||||
.default('veryfast'),
|
||||
VIDEO_PROCESSING_AUDIO_BITRATE_KBPS: Joi.number().min(64).max(320).default(128),
|
||||
VIDEO_PROCESSING_GENERATE_THUMBNAILS: 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_THUMBNAIL_WIDTH: Joi.number().min(160).max(1920).default(720),
|
||||
LOG_LEVEL: Joi.string().valid('error', 'warn', 'log', 'debug', 'verbose').default('log'),
|
||||
REQUEST_LOGGING_ENABLED: Joi.boolean().truthy('true').falsy('false').default(true),
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { BadRequestException, Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
DeleteObjectsCommand,
|
||||
ListObjectsV2Command,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { Upload } from '@aws-sdk/lib-storage';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { mkdir, unlink, writeFile } from 'fs/promises';
|
||||
import { join, posix } from 'path';
|
||||
import { mkdir, rm, unlink, writeFile } from 'fs/promises';
|
||||
import { dirname, join, posix } from 'path';
|
||||
|
||||
@Injectable()
|
||||
export class ManagedStorageService implements OnModuleDestroy {
|
||||
@@ -48,6 +53,67 @@ export class ManagedStorageService implements OnModuleDestroy {
|
||||
return `/${objectKey}`;
|
||||
}
|
||||
|
||||
async saveFiles(params: {
|
||||
folderSegments: string[];
|
||||
files: Array<{
|
||||
relativePath: string;
|
||||
buffer: Buffer;
|
||||
contentType?: string;
|
||||
}>;
|
||||
}): Promise<Record<string, string>> {
|
||||
if (!params.files.length) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const provider = this.getProvider();
|
||||
const basePath = this.getBasePath();
|
||||
const normalizedSegments = params.folderSegments.map((segment) =>
|
||||
segment.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''),
|
||||
);
|
||||
const entries = params.files.map((file) => {
|
||||
const relativePath = this.normalizeRelativePath(file.relativePath);
|
||||
const objectKey = posix.join(basePath, ...normalizedSegments, relativePath);
|
||||
|
||||
return {
|
||||
...file,
|
||||
relativePath,
|
||||
objectKey,
|
||||
};
|
||||
});
|
||||
|
||||
if (provider === 's3') {
|
||||
const client = this.getS3Client();
|
||||
const bucket = this.getS3Bucket();
|
||||
const urls: Record<string, string> = {};
|
||||
|
||||
for (const entry of entries) {
|
||||
const upload = new Upload({
|
||||
client,
|
||||
params: {
|
||||
Bucket: bucket,
|
||||
Key: entry.objectKey,
|
||||
Body: entry.buffer,
|
||||
ContentType: entry.contentType || undefined,
|
||||
},
|
||||
});
|
||||
await upload.done();
|
||||
urls[entry.relativePath] = this.resolvePublicUrl(entry.objectKey);
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
const urls: Record<string, string> = {};
|
||||
for (const entry of entries) {
|
||||
const targetPath = join(process.cwd(), ...entry.objectKey.split('/'));
|
||||
await mkdir(dirname(targetPath), { recursive: true });
|
||||
await writeFile(targetPath, entry.buffer);
|
||||
urls[entry.relativePath] = `/${entry.objectKey}`;
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
async deleteFile(fileUrl?: string): Promise<void> {
|
||||
if (!fileUrl) {
|
||||
return;
|
||||
@@ -75,12 +141,47 @@ export class ManagedStorageService implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
try {
|
||||
await unlink(join(process.cwd(), relativePath.replace(/\//g, '\\')));
|
||||
await unlink(join(process.cwd(), ...relativePath.split('/')));
|
||||
} catch {
|
||||
// Ignore cleanup failures for already-missing files.
|
||||
}
|
||||
}
|
||||
|
||||
async deleteContainingDirectory(fileUrl?: string): Promise<void> {
|
||||
if (!fileUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.getProvider() === 's3') {
|
||||
const objectKey = this.resolveS3ObjectKey(fileUrl);
|
||||
if (!objectKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prefix = posix.dirname(objectKey).replace(/\/?$/, '/');
|
||||
const basePrefix = `${this.getBasePath()}/`;
|
||||
if (!prefix.startsWith(basePrefix) || prefix === basePrefix) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.deleteS3Prefix(prefix);
|
||||
return;
|
||||
}
|
||||
|
||||
const relativePath = this.resolveLocalRelativePath(fileUrl);
|
||||
if (!relativePath || relativePath.includes('..')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const directoryPath = dirname(relativePath);
|
||||
const basePath = this.getBasePath();
|
||||
if (!directoryPath || directoryPath === '.' || directoryPath === basePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
await rm(join(process.cwd(), ...directoryPath.split('/')), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
this.s3Client = null;
|
||||
}
|
||||
@@ -98,6 +199,20 @@ export class ManagedStorageService implements OnModuleDestroy {
|
||||
.replace(/^\/+|\/+$/g, '');
|
||||
}
|
||||
|
||||
private normalizeRelativePath(relativePath: string): string {
|
||||
const normalized = relativePath.replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
if (
|
||||
!normalized ||
|
||||
normalized
|
||||
.split('/')
|
||||
.some((segment) => !segment || segment === '.' || segment === '..')
|
||||
) {
|
||||
throw new BadRequestException('Invalid managed file path');
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private getS3Bucket(): string {
|
||||
const bucket = this.configService.get<string>('storage.s3.bucket', { infer: true }) ?? '';
|
||||
if (!bucket) {
|
||||
@@ -207,4 +322,36 @@ export class ManagedStorageService implements OnModuleDestroy {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async deleteS3Prefix(prefix: string): Promise<void> {
|
||||
const client = this.getS3Client();
|
||||
const bucket = this.getS3Bucket();
|
||||
let continuationToken: string | undefined;
|
||||
|
||||
do {
|
||||
const listed = await client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: bucket,
|
||||
Prefix: prefix,
|
||||
ContinuationToken: continuationToken,
|
||||
}),
|
||||
);
|
||||
|
||||
const keys =
|
||||
listed.Contents?.map((item) => item.Key).filter((key): key is string => !!key) ?? [];
|
||||
if (keys.length) {
|
||||
await client.send(
|
||||
new DeleteObjectsCommand({
|
||||
Bucket: bucket,
|
||||
Delete: {
|
||||
Objects: keys.map((key) => ({ Key: key })),
|
||||
Quiet: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
continuationToken = listed.IsTruncated ? listed.NextContinuationToken : undefined;
|
||||
} while (continuationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { spawn } from 'child_process';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { extname, join } from 'path';
|
||||
|
||||
@@ -20,6 +20,14 @@ export type OptimizedVideoResult = {
|
||||
extension: '.jpg';
|
||||
contentType: 'image/jpeg';
|
||||
};
|
||||
generatedHls?: {
|
||||
playlistRelativePath: string;
|
||||
files: Array<{
|
||||
relativePath: string;
|
||||
buffer: Buffer;
|
||||
contentType: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -44,6 +52,7 @@ export class VideoProcessingService {
|
||||
const inputPath = join(workingDir, `input-${randomUUID()}${this.resolveInputExtension(file)}`);
|
||||
const outputPath = join(workingDir, `optimized-${randomUUID()}.mp4`);
|
||||
const thumbnailPath = join(workingDir, `thumbnail-${randomUUID()}.jpg`);
|
||||
const hlsDir = join(workingDir, `hls-${randomUUID()}`);
|
||||
|
||||
try {
|
||||
await writeFile(inputPath, file.buffer);
|
||||
@@ -121,9 +130,21 @@ export class VideoProcessingService {
|
||||
}
|
||||
}
|
||||
|
||||
let generatedHls: OptimizedVideoResult['generatedHls'];
|
||||
if (this.shouldGenerateHls()) {
|
||||
try {
|
||||
generatedHls = await this.generateHlsPackage(outputPath, hlsDir);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`HLS generation failed: ${error instanceof Error ? error.message : 'unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
file: optimizedFile,
|
||||
generatedThumbnail,
|
||||
generatedHls,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
@@ -149,6 +170,10 @@ export class VideoProcessingService {
|
||||
);
|
||||
}
|
||||
|
||||
private shouldGenerateHls(): boolean {
|
||||
return this.configService.get<boolean>('videoProcessing.generateHls', { infer: true }) ?? true;
|
||||
}
|
||||
|
||||
private getFfmpegPath(): string {
|
||||
return (
|
||||
this.configService.get<string>('videoProcessing.ffmpegPath', { infer: true }) ?? 'ffmpeg'
|
||||
@@ -163,6 +188,14 @@ export class VideoProcessingService {
|
||||
return this.configService.get<number>('videoProcessing.thumbnailWidth', { infer: true }) ?? 720;
|
||||
}
|
||||
|
||||
private getHlsSegmentDurationSeconds(): number {
|
||||
return (
|
||||
this.configService.get<number>('videoProcessing.hlsSegmentDurationSeconds', {
|
||||
infer: true,
|
||||
}) ?? 4
|
||||
);
|
||||
}
|
||||
|
||||
private getMaxFps(): number {
|
||||
return this.configService.get<number>('videoProcessing.maxFps', { infer: true }) ?? 30;
|
||||
}
|
||||
@@ -218,6 +251,75 @@ export class VideoProcessingService {
|
||||
}
|
||||
}
|
||||
|
||||
private async generateHlsPackage(
|
||||
optimizedMp4Path: string,
|
||||
hlsDir: string,
|
||||
): Promise<NonNullable<OptimizedVideoResult['generatedHls']>> {
|
||||
await mkdir(hlsDir, { recursive: true });
|
||||
|
||||
await this.runFfmpeg([
|
||||
'-y',
|
||||
'-i',
|
||||
optimizedMp4Path,
|
||||
'-map',
|
||||
'0:v:0',
|
||||
'-map',
|
||||
'0:a:0?',
|
||||
'-c:v',
|
||||
'copy',
|
||||
'-c:a',
|
||||
'copy',
|
||||
'-f',
|
||||
'hls',
|
||||
'-hls_time',
|
||||
String(this.getHlsSegmentDurationSeconds()),
|
||||
'-hls_playlist_type',
|
||||
'vod',
|
||||
'-hls_list_size',
|
||||
'0',
|
||||
'-hls_flags',
|
||||
'independent_segments',
|
||||
'-hls_segment_type',
|
||||
'fmp4',
|
||||
'-hls_fmp4_init_filename',
|
||||
'init.mp4',
|
||||
'-hls_segment_filename',
|
||||
join(hlsDir, 'segment-%03d.m4s'),
|
||||
join(hlsDir, 'playlist.m3u8'),
|
||||
]);
|
||||
|
||||
const fileNames = (await readdir(hlsDir)).sort((left, right) => left.localeCompare(right));
|
||||
const files = await Promise.all(
|
||||
fileNames.map(async (fileName) => ({
|
||||
relativePath: fileName,
|
||||
buffer: await readFile(join(hlsDir, fileName)),
|
||||
contentType: this.resolveStreamingContentType(fileName),
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
playlistRelativePath: 'playlist.m3u8',
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
private resolveStreamingContentType(fileName: string): string {
|
||||
const extension = extname(fileName).toLowerCase();
|
||||
|
||||
switch (extension) {
|
||||
case '.m3u8':
|
||||
return 'application/vnd.apple.mpegurl';
|
||||
case '.m4s':
|
||||
return 'video/iso.segment';
|
||||
case '.ts':
|
||||
return 'video/mp2t';
|
||||
case '.mp4':
|
||||
return 'video/mp4';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureFfmpegAvailable(): Promise<boolean> {
|
||||
if (this.ffmpegAvailabilityResolved) {
|
||||
return this.ffmpegAvailable;
|
||||
|
||||
155
src/main.ts
155
src/main.ts
@@ -6,13 +6,115 @@ import * as express from 'express';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { NetworkInterfaceInfo, networkInterfaces } from 'os';
|
||||
import { extname, join } from 'path';
|
||||
import { AppModule } from './app.module';
|
||||
import { ResponseEnvelopeInterceptor } from './common/interceptors/response-envelope.interceptor';
|
||||
import { AppLoggerService } from './infrastructure/logging/app-logger.service';
|
||||
import { RedisService } from './infrastructure/redis/redis.service';
|
||||
import { RedisIoAdapter } from './infrastructure/socket/redis-io.adapter';
|
||||
|
||||
const getLocalIpv4Addresses = (): string[] =>
|
||||
Object.values(networkInterfaces())
|
||||
.flatMap((entries) => entries ?? [])
|
||||
.filter((entry): entry is NetworkInterfaceInfo => !!entry)
|
||||
.filter((entry) => entry.family === 'IPv4' && !entry.internal)
|
||||
.map((entry) => entry.address);
|
||||
|
||||
const isPrivateIpv4Host = (host: string): boolean =>
|
||||
/^10\./.test(host) || /^192\.168\./.test(host) || /^172\.(1[6-9]|2\d|3[0-1])\./.test(host);
|
||||
|
||||
const IMMUTABLE_MEDIA_CACHE_CONTROL = 'public, max-age=31536000, immutable';
|
||||
const SHORT_MANIFEST_CACHE_CONTROL = 'public, max-age=300, stale-while-revalidate=60';
|
||||
|
||||
const getStaticMediaHeaders = (
|
||||
extension: string,
|
||||
): {
|
||||
contentType?: string;
|
||||
cacheControl?: string;
|
||||
acceptRanges?: boolean;
|
||||
} => {
|
||||
switch (extension) {
|
||||
case '.jpg':
|
||||
case '.jpeg':
|
||||
return {
|
||||
contentType: 'image/jpeg',
|
||||
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
|
||||
};
|
||||
case '.png':
|
||||
return {
|
||||
contentType: 'image/png',
|
||||
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
|
||||
};
|
||||
case '.webp':
|
||||
return {
|
||||
contentType: 'image/webp',
|
||||
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
|
||||
};
|
||||
case '.gif':
|
||||
return {
|
||||
contentType: 'image/gif',
|
||||
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
|
||||
};
|
||||
case '.mp3':
|
||||
return {
|
||||
contentType: 'audio/mpeg',
|
||||
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
|
||||
acceptRanges: true,
|
||||
};
|
||||
case '.wav':
|
||||
return {
|
||||
contentType: 'audio/wav',
|
||||
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
|
||||
acceptRanges: true,
|
||||
};
|
||||
case '.m4a':
|
||||
return {
|
||||
contentType: 'audio/mp4',
|
||||
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
|
||||
acceptRanges: true,
|
||||
};
|
||||
case '.aac':
|
||||
return {
|
||||
contentType: 'audio/aac',
|
||||
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
|
||||
acceptRanges: true,
|
||||
};
|
||||
case '.ogg':
|
||||
return {
|
||||
contentType: 'audio/ogg',
|
||||
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
|
||||
acceptRanges: true,
|
||||
};
|
||||
case '.mp4':
|
||||
return {
|
||||
contentType: 'video/mp4',
|
||||
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
|
||||
acceptRanges: true,
|
||||
};
|
||||
case '.m3u8':
|
||||
return {
|
||||
contentType: 'application/vnd.apple.mpegurl',
|
||||
cacheControl: SHORT_MANIFEST_CACHE_CONTROL,
|
||||
acceptRanges: true,
|
||||
};
|
||||
case '.m4s':
|
||||
return {
|
||||
contentType: 'video/iso.segment',
|
||||
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
|
||||
acceptRanges: true,
|
||||
};
|
||||
case '.ts':
|
||||
return {
|
||||
contentType: 'video/mp2t',
|
||||
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
|
||||
acceptRanges: true,
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const app = await NestFactory.create(AppModule, { bufferLogs: true });
|
||||
const configService = app.get(ConfigService);
|
||||
@@ -78,7 +180,26 @@ async function bootstrap(): Promise<void> {
|
||||
}
|
||||
|
||||
if (storageProvider === 'local') {
|
||||
app.use(`/${storageBasePath}`, express.static(uploadsDir));
|
||||
app.use(
|
||||
`/${storageBasePath}`,
|
||||
express.static(uploadsDir, {
|
||||
acceptRanges: true,
|
||||
setHeaders: (res, filePath) => {
|
||||
const extension = extname(filePath).toLowerCase();
|
||||
const mediaHeaders = getStaticMediaHeaders(extension);
|
||||
|
||||
if (mediaHeaders.contentType) {
|
||||
res.setHeader('Content-Type', mediaHeaders.contentType);
|
||||
}
|
||||
if (mediaHeaders.cacheControl) {
|
||||
res.setHeader('Cache-Control', mediaHeaders.cacheControl);
|
||||
}
|
||||
if (mediaHeaders.acceptRanges) {
|
||||
res.setHeader('Accept-Ranges', 'bytes');
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const redisEnabled = configService.get<boolean>('redis.enabled', { infer: true }) ?? false;
|
||||
@@ -104,11 +225,31 @@ async function bootstrap(): Promise<void> {
|
||||
|
||||
const port = configService.get<number>('port', 4000);
|
||||
const host = configService.get<string>('host', '0.0.0.0');
|
||||
if (host === '0.0.0.0' && publicBaseUrl.includes('localhost')) {
|
||||
appLogger.warn(
|
||||
`PUBLIC_BASE_URL is set to "${publicBaseUrl}". Mobile devices on the LAN will not be able to open uploaded files until this is changed to your machine IP, for example http://192.168.x.x:${port}`,
|
||||
'Bootstrap',
|
||||
);
|
||||
if (host === '0.0.0.0') {
|
||||
const localIpv4Addresses = getLocalIpv4Addresses();
|
||||
let publicBaseHost = '';
|
||||
|
||||
try {
|
||||
publicBaseHost = new URL(publicBaseUrl).hostname;
|
||||
} catch {
|
||||
publicBaseHost = '';
|
||||
}
|
||||
|
||||
if (publicBaseHost === 'localhost') {
|
||||
appLogger.warn(
|
||||
`PUBLIC_BASE_URL is set to "${publicBaseUrl}". Mobile devices on the LAN will not be able to open uploaded files until this is changed to your machine IP, for example http://192.168.x.x:${port}`,
|
||||
'Bootstrap',
|
||||
);
|
||||
} else if (
|
||||
publicBaseHost &&
|
||||
isPrivateIpv4Host(publicBaseHost) &&
|
||||
!localIpv4Addresses.includes(publicBaseHost)
|
||||
) {
|
||||
appLogger.warn(
|
||||
`PUBLIC_BASE_URL is set to "${publicBaseUrl}" but this IP is not assigned to the current machine. Detected local IPv4 addresses: ${localIpv4Addresses.join(', ') || 'none'}. Uploaded files and avatars may not load until PUBLIC_BASE_URL is updated.`,
|
||||
'Bootstrap',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await app.listen(port, host);
|
||||
|
||||
@@ -50,6 +50,7 @@ type NormalizedPostMediaMetadata = {
|
||||
|
||||
type SavedVideoUpload = {
|
||||
videoUrl: string;
|
||||
hlsUrl: string;
|
||||
thumbnailUrl: string;
|
||||
};
|
||||
|
||||
@@ -100,6 +101,7 @@ export class PostsService {
|
||||
const uploadedImageUrls = await this.saveImageFiles(imageFiles);
|
||||
const savedVideoUpload = videoFile ? await this.saveVideoUpload(videoFile) : null;
|
||||
const uploadedVideoUrl = savedVideoUpload?.videoUrl ?? '';
|
||||
const uploadedHlsUrl = savedVideoUpload?.hlsUrl ?? '';
|
||||
const uploadedThumbnailUrl = savedVideoUpload?.thumbnailUrl ?? '';
|
||||
const uploadedAudioUrl = audioFile ? await this.saveMediaFile('audio', audioFile) : '';
|
||||
const finalImageUrls = uploadedImageUrls.length ? uploadedImageUrls : inputImageUrls;
|
||||
@@ -127,6 +129,7 @@ export class PostsService {
|
||||
content: finalContent,
|
||||
imageUrls: finalImageUrls,
|
||||
videoUrl: finalVideoUrl,
|
||||
hlsUrl: uploadedHlsUrl,
|
||||
audioUrl: finalAudioUrl,
|
||||
taggedUserIds,
|
||||
mentionUsernames: mentionResolution.mentionUsernames,
|
||||
@@ -142,6 +145,7 @@ export class PostsService {
|
||||
await Promise.all([
|
||||
...uploadedImageUrls.map((url) => this.deleteManagedPostMedia(url)),
|
||||
uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(),
|
||||
uploadedHlsUrl ? this.storageService.deleteContainingDirectory(uploadedHlsUrl) : Promise.resolve(),
|
||||
uploadedThumbnailUrl ? this.deleteManagedPostMedia(uploadedThumbnailUrl) : Promise.resolve(),
|
||||
uploadedAudioUrl ? this.deleteManagedPostMedia(uploadedAudioUrl) : Promise.resolve(),
|
||||
]);
|
||||
@@ -198,6 +202,7 @@ export class PostsService {
|
||||
const uploadedImageUrls = await this.saveImageFiles(imageFiles);
|
||||
const savedVideoUpload = videoFile ? await this.saveVideoUpload(videoFile) : null;
|
||||
const uploadedVideoUrl = savedVideoUpload?.videoUrl ?? '';
|
||||
const uploadedHlsUrl = savedVideoUpload?.hlsUrl ?? '';
|
||||
const uploadedThumbnailUrl = savedVideoUpload?.thumbnailUrl ?? '';
|
||||
const uploadedAudioUrl = audioFile ? await this.saveMediaFile('audio', audioFile) : '';
|
||||
const hasImageUpdate = typeof dto.imageUrls !== 'undefined' || imageFiles.length > 0;
|
||||
@@ -215,6 +220,7 @@ export class PostsService {
|
||||
? uploadedVideoUrl
|
||||
: dto.videoUrl ?? ''
|
||||
: post.videoUrl ?? '';
|
||||
const nextHlsUrl = hasVideoUpdate ? (videoFile ? uploadedHlsUrl : '') : post.hlsUrl ?? '';
|
||||
const nextAudioUrl = hasAudioUpdate
|
||||
? audioFile
|
||||
? uploadedAudioUrl
|
||||
@@ -266,6 +272,7 @@ export class PostsService {
|
||||
...dto,
|
||||
content: nextContent,
|
||||
imageUrls: nextImageUrls,
|
||||
hlsUrl: nextHlsUrl,
|
||||
taggedUserIds: nextTaggedUserIds,
|
||||
mentionUsernames: mentionResolution.mentionUsernames,
|
||||
location: nextLocation,
|
||||
@@ -287,14 +294,17 @@ export class PostsService {
|
||||
}
|
||||
if (hasAudioUpdate && !hasVideoUpdate) {
|
||||
payload.videoUrl = '';
|
||||
payload.hlsUrl = '';
|
||||
}
|
||||
if (hasImageUpdate) {
|
||||
payload.videoUrl = '';
|
||||
payload.audioUrl = '';
|
||||
payload.hlsUrl = '';
|
||||
}
|
||||
|
||||
if (videoFile) {
|
||||
payload.videoUrl = uploadedVideoUrl;
|
||||
payload.hlsUrl = uploadedHlsUrl;
|
||||
payload.imageUrls = [];
|
||||
payload.audioUrl = '';
|
||||
}
|
||||
@@ -302,6 +312,7 @@ export class PostsService {
|
||||
payload.audioUrl = uploadedAudioUrl;
|
||||
payload.imageUrls = [];
|
||||
payload.videoUrl = '';
|
||||
payload.hlsUrl = '';
|
||||
}
|
||||
|
||||
let updated: PostDocument | null;
|
||||
@@ -311,6 +322,7 @@ export class PostsService {
|
||||
await Promise.all([
|
||||
...uploadedImageUrls.map((url) => this.deleteManagedPostMedia(url)),
|
||||
uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(),
|
||||
uploadedHlsUrl ? this.storageService.deleteContainingDirectory(uploadedHlsUrl) : Promise.resolve(),
|
||||
uploadedThumbnailUrl ? this.deleteManagedPostMedia(uploadedThumbnailUrl) : Promise.resolve(),
|
||||
uploadedAudioUrl ? this.deleteManagedPostMedia(uploadedAudioUrl) : Promise.resolve(),
|
||||
]);
|
||||
@@ -320,6 +332,7 @@ export class PostsService {
|
||||
await Promise.all([
|
||||
...uploadedImageUrls.map((url) => this.deleteManagedPostMedia(url)),
|
||||
uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(),
|
||||
uploadedHlsUrl ? this.storageService.deleteContainingDirectory(uploadedHlsUrl) : Promise.resolve(),
|
||||
uploadedThumbnailUrl ? this.deleteManagedPostMedia(uploadedThumbnailUrl) : Promise.resolve(),
|
||||
uploadedAudioUrl ? this.deleteManagedPostMedia(uploadedAudioUrl) : Promise.resolve(),
|
||||
]);
|
||||
@@ -329,6 +342,9 @@ export class PostsService {
|
||||
if (hasVideoUpdate && (post.videoUrl ?? '') !== (updated.videoUrl ?? '')) {
|
||||
await this.deleteManagedPostMedia(post.videoUrl ?? '');
|
||||
}
|
||||
if ((post.hlsUrl ?? '') !== (updated.hlsUrl ?? '')) {
|
||||
await this.storageService.deleteContainingDirectory(post.hlsUrl ?? '');
|
||||
}
|
||||
if ((post.thumbnailUrl ?? '') !== (updated.thumbnailUrl ?? '')) {
|
||||
await this.deleteManagedPostMedia(post.thumbnailUrl ?? '');
|
||||
}
|
||||
@@ -369,6 +385,7 @@ export class PostsService {
|
||||
await Promise.all([
|
||||
...(post.imageUrls ?? []).map((url) => this.deleteManagedPostMedia(url)),
|
||||
this.deleteManagedPostMedia(post.videoUrl ?? ''),
|
||||
this.storageService.deleteContainingDirectory(post.hlsUrl ?? ''),
|
||||
this.deleteManagedPostMedia(post.audioUrl ?? ''),
|
||||
]);
|
||||
await this.usersRepository.incrementPostsCount(userId, -1);
|
||||
@@ -610,6 +627,7 @@ export class PostsService {
|
||||
await Promise.all([
|
||||
...(post.imageUrls ?? []).map((url) => this.deleteManagedPostMedia(url)),
|
||||
this.deleteManagedPostMedia(post.videoUrl ?? ''),
|
||||
this.storageService.deleteContainingDirectory(post.hlsUrl ?? ''),
|
||||
this.deleteManagedPostMedia(post.audioUrl ?? ''),
|
||||
]);
|
||||
const authorId = this.extractEntityId(post.authorId);
|
||||
@@ -918,7 +936,9 @@ export class PostsService {
|
||||
const extension = this.validateMediaFile('video', optimized.file);
|
||||
|
||||
let videoUrl = '';
|
||||
let hlsUrl = '';
|
||||
let thumbnailUrl = '';
|
||||
const hlsFolderName = `stream-${new Types.ObjectId().toString()}`;
|
||||
|
||||
try {
|
||||
videoUrl = await this.storageService.saveFile({
|
||||
@@ -929,6 +949,14 @@ export class PostsService {
|
||||
fileNamePrefix: 'video',
|
||||
});
|
||||
|
||||
if (optimized.generatedHls?.files.length) {
|
||||
const savedHlsFiles = await this.storageService.saveFiles({
|
||||
folderSegments: ['posts', 'hls', hlsFolderName],
|
||||
files: optimized.generatedHls.files,
|
||||
});
|
||||
hlsUrl = savedHlsFiles[optimized.generatedHls.playlistRelativePath] ?? '';
|
||||
}
|
||||
|
||||
if (optimized.generatedThumbnail) {
|
||||
thumbnailUrl = await this.storageService.saveFile({
|
||||
folderSegments: ['posts', 'thumbnails'],
|
||||
@@ -939,10 +967,11 @@ export class PostsService {
|
||||
});
|
||||
}
|
||||
|
||||
return { videoUrl, thumbnailUrl };
|
||||
return { videoUrl, hlsUrl, thumbnailUrl };
|
||||
} catch (error) {
|
||||
await Promise.all([
|
||||
videoUrl ? this.deleteManagedPostMedia(videoUrl) : Promise.resolve(),
|
||||
hlsUrl ? this.storageService.deleteContainingDirectory(hlsUrl) : Promise.resolve(),
|
||||
thumbnailUrl ? this.deleteManagedPostMedia(thumbnailUrl) : Promise.resolve(),
|
||||
]);
|
||||
throw error;
|
||||
|
||||
@@ -22,6 +22,9 @@ export class Post {
|
||||
@Prop({ default: '' })
|
||||
videoUrl!: string;
|
||||
|
||||
@Prop({ default: '' })
|
||||
hlsUrl!: string;
|
||||
|
||||
@Prop({ default: '' })
|
||||
audioUrl!: string;
|
||||
|
||||
@@ -134,6 +137,7 @@ PostSchema.index({
|
||||
const transformManagedPostFiles = (_doc: unknown, ret: any) => {
|
||||
ret.imageUrls = resolveManagedFileUrls(ret.imageUrls);
|
||||
ret.videoUrl = resolveManagedFileUrl(ret.videoUrl);
|
||||
ret.hlsUrl = resolveManagedFileUrl(ret.hlsUrl);
|
||||
ret.audioUrl = resolveManagedFileUrl(ret.audioUrl);
|
||||
ret.thumbnailUrl = resolveManagedFileUrl(ret.thumbnailUrl);
|
||||
return ret;
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم