diff --git a/.env.example b/.env.example index 915ab62..38fc72a 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/docs/FRONTEND_INTEGRATION.md b/docs/FRONTEND_INTEGRATION.md index e836553..2cf3ba8 100644 --- a/docs/FRONTEND_INTEGRATION.md +++ b/docs/FRONTEND_INTEGRATION.md @@ -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: diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 4041471..56c3d0e 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -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: { diff --git a/src/config/validation.schema.ts b/src/config/validation.schema.ts index 2725beb..4f57a55 100644 --- a/src/config/validation.schema.ts +++ b/src/config/validation.schema.ts @@ -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), diff --git a/src/infrastructure/storage/managed-storage.service.ts b/src/infrastructure/storage/managed-storage.service.ts index 618be35..d49bc29 100644 --- a/src/infrastructure/storage/managed-storage.service.ts +++ b/src/infrastructure/storage/managed-storage.service.ts @@ -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> { + 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 = {}; + + 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 = {}; + 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 { 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 { + 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('storage.s3.bucket', { infer: true }) ?? ''; if (!bucket) { @@ -207,4 +322,36 @@ export class ManagedStorageService implements OnModuleDestroy { return null; } + + private async deleteS3Prefix(prefix: string): Promise { + 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); + } } diff --git a/src/infrastructure/storage/video-processing.service.ts b/src/infrastructure/storage/video-processing.service.ts index 5b8ba3d..015b5a9 100644 --- a/src/infrastructure/storage/video-processing.service.ts +++ b/src/infrastructure/storage/video-processing.service.ts @@ -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('videoProcessing.generateHls', { infer: true }) ?? true; + } + private getFfmpegPath(): string { return ( this.configService.get('videoProcessing.ffmpegPath', { infer: true }) ?? 'ffmpeg' @@ -163,6 +188,14 @@ export class VideoProcessingService { return this.configService.get('videoProcessing.thumbnailWidth', { infer: true }) ?? 720; } + private getHlsSegmentDurationSeconds(): number { + return ( + this.configService.get('videoProcessing.hlsSegmentDurationSeconds', { + infer: true, + }) ?? 4 + ); + } + private getMaxFps(): number { return this.configService.get('videoProcessing.maxFps', { infer: true }) ?? 30; } @@ -218,6 +251,75 @@ export class VideoProcessingService { } } + private async generateHlsPackage( + optimizedMp4Path: string, + hlsDir: string, + ): Promise> { + 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 { if (this.ffmpegAvailabilityResolved) { return this.ffmpegAvailable; diff --git a/src/main.ts b/src/main.ts index 4751cfb..eece56d 100644 --- a/src/main.ts +++ b/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 { const app = await NestFactory.create(AppModule, { bufferLogs: true }); const configService = app.get(ConfigService); @@ -78,7 +180,26 @@ async function bootstrap(): Promise { } 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('redis.enabled', { infer: true }) ?? false; @@ -104,11 +225,31 @@ async function bootstrap(): Promise { const port = configService.get('port', 4000); const host = configService.get('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); diff --git a/src/modules/posts/posts.service.ts b/src/modules/posts/posts.service.ts index 8a67f15..fbcfd9b 100644 --- a/src/modules/posts/posts.service.ts +++ b/src/modules/posts/posts.service.ts @@ -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; diff --git a/src/modules/posts/schemas/post.schema.ts b/src/modules/posts/schemas/post.schema.ts index 0f9f515..7428443 100644 --- a/src/modules/posts/schemas/post.schema.ts +++ b/src/modules/posts/schemas/post.schema.ts @@ -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;