diff --git a/.vscode/tasks.json b/.vscode/tasks.json index c763755..a668fe5 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -3,23 +3,95 @@ "version": "2.0.0", "tasks": [ { - "label": "API", + "label": "🌐 API Gateway", "type": "shell", - "command": "npx nx serve api --no-tui", - "isBackground": true, - "problemMatcher": { - "pattern": { - "regexp": "^$", - "file": 1, - "location": 2, - "message": 3 - }, - "background": { - "activeOnStart": true, - "beginsPattern": ".*", - "endsPattern": ".*Application is running on.*" + "command": "npx", + "args": ["nx", "serve", "api", "--no-tui"], + "group": "build", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "dedicated", + "showReuseMessage": true, + "clear": false + }, + "options": { + "env": { + "NODE_ENV": "development" } - } + }, + "problemMatcher": [] }, + { + "label": "🛑 Stop Nx Daemon", + "type": "shell", + "command": "npx", + "args": ["nx", "daemon", "--stop"], + "group": "build", + "presentation": { + "echo": true, + "reveal": "silent", + "focus": false, + "panel": "shared", + "showReuseMessage": false, + "clear": false + }, + "problemMatcher": [] + }, + { + "label": "🔄 Reset Nx Cache", + "type": "shell", + "command": "npx", + "args": ["nx", "reset"], + "group": "build", + "dependsOn": "🛑 Stop Nx Daemon", + "dependsOrder": "sequence", + "presentation": { + "echo": true, + "reveal": "silent", + "focus": false, + "panel": "shared", + "showReuseMessage": false, + "clear": false + }, + "problemMatcher": [] + }, + { + "label": "🚀 Start Nx Daemon", + "type": "shell", + "command": "npx", + "args": ["nx", "daemon", "--start", "--verbose"], + "group": "build", + "dependsOn": "🔄 Reset Nx Cache", + "dependsOrder": "sequence", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared", + "showReuseMessage": true, + "clear": false + }, + "problemMatcher": [] + }, + { + "label": "🔄 Restart Nx Daemon", + "type": "shell", + "command": "echo", + "args": ["Nx Daemon restarted successfully"], + "dependsOn": "🚀 Start Nx Daemon", + "dependsOrder": "sequence", + "group": "build", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared", + "showReuseMessage": true, + "clear": false + }, + "problemMatcher": [] + } ] } \ No newline at end of file diff --git a/apps/api/src/app/app.controller.spec.ts b/apps/api/src/app/app.controller.spec.ts deleted file mode 100644 index e0b8e9f..0000000 --- a/apps/api/src/app/app.controller.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; -import { VideosService } from './videos/videos.service'; - -// Mock uuid -jest.mock('uuid', () => ({ - v4: jest.fn(() => 'test-uuid-123'), -})); - -describe('AppController', () => { - let app: TestingModule; - let videosService: VideosService; - - beforeAll(async () => { - app = await Test.createTestingModule({ - controllers: [AppController], - providers: [ - AppService, - { - provide: VideosService, - useValue: { - create: jest.fn(), - findAll: jest.fn(), - }, - }, - ], - }).compile(); - - videosService = app.get(VideosService); - }); - - describe('getData', () => { - it('should return "Hello API"', () => { - const appController = app.get(AppController); - expect(appController.getData()).toEqual({ message: 'Hello API' }); - }); - }); -}); diff --git a/apps/api/src/app/app.controller.ts b/apps/api/src/app/app.controller.ts index ab9a01d..1028efb 100644 --- a/apps/api/src/app/app.controller.ts +++ b/apps/api/src/app/app.controller.ts @@ -4,6 +4,7 @@ import { HttpCode, HttpStatus, Logger, + Param, Post, UploadedFile, UseInterceptors, @@ -15,12 +16,13 @@ import { ApiCreatedResponse, ApiOkResponse, ApiOperation, + ApiParam, ApiTags, } from '@nestjs/swagger'; -import { v4 as uuidv4 } from 'uuid'; import { AppService } from './app.service'; +import { StorageService } from './storage/storage.service'; import { UploadVideoDto } from './videos/dto/upload-video.dto'; -import { Video, VideoStatus } from './videos/entities/video.entity'; +import { Video } from './videos/entities/video.entity'; import { VideosService } from './videos/videos.service'; interface UploadedFileInterface { @@ -40,6 +42,7 @@ export class AppController { constructor( private readonly appService: AppService, private readonly videosService: VideosService, + private readonly storageService: StorageService, ) { this.logger.log(`AppController constructor`); } @@ -102,19 +105,16 @@ export class AppController { `Received file upload: filename=${file.originalname}, mimetype=${file.mimetype}, size=${file.size}`, ); - // Generate a unique S3 key (placeholder until MinIO is implemented) - const fileExtension = file.originalname.split('.').pop(); - const s3Key = `raw-videos/${uuidv4()}.${fileExtension}`; - - // Create video record - const video = await this.videosService.create( + // Pass buffer directly instead of converting to stream to avoid MinIO signature issues + // Videos service handles MinIO upload and database record creation + const video = await this.videosService.uploadAndCreate( + file.buffer, file.originalname, - s3Key, file.mimetype, - VideoStatus.UPLOADED, + file.size, ); - this.logger.log(`Video record created: id=${video.id}`); + this.logger.log(`Video uploaded and record created: id=${video.id}`); return video; } @@ -130,4 +130,139 @@ export class AppController { async testGetVideos(): Promise { return await this.videosService.findAll(); } + + @Post('test-storage/delete/:objectName') + @ApiOperation({ + summary: 'Delete an object from MinIO storage', + description: + 'Deletes an object from MinIO storage. Handles object not found gracefully (idempotent operation).', + }) + @ApiParam({ + name: 'objectName', + description: 'Full object key/path (e.g., raw-videos/video.mp4)', + type: 'string', + example: 'raw-videos/123e4567-e89b-12d3-a456-426614174000.mp4', + }) + @ApiOkResponse({ + description: 'Object deleted successfully or already deleted', + schema: { + type: 'object', + properties: { + success: { type: 'boolean' }, + message: { type: 'string' }, + objectName: { type: 'string' }, + }, + }, + }) + @HttpCode(HttpStatus.OK) + async testDeleteObject( + @Param('objectName') objectName: string, + ): Promise<{ success: boolean; message: string; objectName: string }> { + this.logger.log(`Test delete object request: objectName=${objectName}`); + + try { + // Check if object exists before deletion + const exists = await this.storageService.objectExists(objectName); + this.logger.log( + `Object '${objectName}' exists before deletion: ${exists}`, + ); + + // Delete the object + await this.storageService.deleteObject(objectName); + + // Verify deletion + const stillExists = await this.storageService.objectExists(objectName); + this.logger.log( + `Object '${objectName}' exists after deletion: ${stillExists}`, + ); + return { + success: true, + message: exists + ? 'Object deleted successfully' + : 'Object was already deleted (idempotent operation)', + objectName, + }; + } catch (error) { + this.logger.error( + `Failed to delete object '${objectName}': ${error.message}`, + ); + throw error; + } + } + + @Post('test-storage/delete-video/:id') + @ApiOperation({ + summary: 'Delete a video object by video ID', + description: + 'Deletes a video file from MinIO storage using the video ID. Retrieves the s3Key from the database and deletes the object.', + }) + @ApiParam({ + name: 'id', + description: 'Video ID (UUID)', + type: 'string', + example: '123e4567-e89b-12d3-a456-426614174000', + }) + @ApiOkResponse({ + description: 'Video object deleted successfully', + schema: { + type: 'object', + properties: { + success: { type: 'boolean' }, + message: { type: 'string' }, + videoId: { type: 'string' }, + s3Key: { type: 'string' }, + }, + }, + }) + @HttpCode(HttpStatus.OK) + async testDeleteVideoObject( + @Param('id') id: string, + ): Promise<{ + success: boolean; + message: string; + videoId: string; + s3Key: string; + }> { + this.logger.log(`Test delete video object request: videoId=${id}`); + + // Get video record from database + const video = await this.videosService.findOne(id); + if (!video) { + throw new Error(`Video with ID ${id} not found`); + } + + this.logger.log( + `Found video: id=${video.id}, s3Key=${video.s3Key}`, + ); + + try { + // Check if object exists + const exists = await this.storageService.objectExists(video.s3Key); + this.logger.log( + `Video object '${video.s3Key}' exists before deletion: ${exists}`, + ); + + // Delete the object + await this.storageService.deleteObject(video.s3Key); + + // Verify deletion + const stillExists = await this.storageService.objectExists(video.s3Key); + this.logger.log( + `Video object '${video.s3Key}' exists after deletion: ${stillExists}`, + ); + return { + success: true, + message: exists + ? 'Video object deleted successfully from MinIO' + : 'Video object was already deleted (idempotent operation)', + videoId: video.id, + s3Key: video.s3Key, + }; + } catch (error) { + this.logger.error( + `Failed to delete video object for video ${id}: ${error.message}`, + ); + throw error; + } + } } diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 370b773..d42bc2c 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import configuration from '../config/configuration'; import { AppController } from './app.controller'; import { AppService } from './app.service'; +import { StorageModule } from './storage/storage.module'; import { VideosModule } from './videos/videos.module'; @Module({ @@ -29,6 +30,7 @@ import { VideosModule } from './videos/videos.module'; inject: [ConfigService], }), VideosModule, + StorageModule, ], controllers: [AppController], providers: [AppService], diff --git a/apps/api/src/app/storage/exceptions/storage.exceptions.ts b/apps/api/src/app/storage/exceptions/storage.exceptions.ts new file mode 100644 index 0000000..105fe32 --- /dev/null +++ b/apps/api/src/app/storage/exceptions/storage.exceptions.ts @@ -0,0 +1,69 @@ +import { + BadRequestException, + InternalServerErrorException, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; + +/** + * Custom exception for MinIO connection errors + */ +export class StorageConnectionError extends ServiceUnavailableException { + constructor(message: string, cause?: Error) { + super({ + statusCode: 503, + message: `Storage connection error: ${message}`, + error: 'Service Unavailable', + cause: cause?.message, + }); + this.name = 'StorageConnectionError'; + } +} + +/** + * Custom exception for storage operation errors (upload, download, delete) + */ +export class StorageOperationError extends InternalServerErrorException { + constructor(operation: string, message: string, cause?: Error) { + super({ + statusCode: 500, + message: `Storage ${operation} failed: ${message}`, + error: 'Internal Server Error', + operation, + cause: cause?.message, + }); + this.name = 'StorageOperationError'; + } +} + +/** + * Custom exception for object not found errors + */ +export class StorageNotFoundError extends NotFoundException { + constructor(objectName: string, cause?: Error) { + super({ + statusCode: 404, + message: `Storage object not found: '${objectName}'`, + error: 'Not Found', + objectName, + cause: cause?.message, + }); + this.name = 'StorageNotFoundError'; + } +} + +/** + * Custom exception for validation errors (invalid parameters) + */ +export class StorageValidationError extends BadRequestException { + constructor(message: string, field?: string) { + super({ + statusCode: 400, + message: `Storage validation error: ${message}`, + error: 'Bad Request', + field, + }); + this.name = 'StorageValidationError'; + } +} + diff --git a/apps/api/src/app/storage/storage.module.ts b/apps/api/src/app/storage/storage.module.ts new file mode 100644 index 0000000..ed554be --- /dev/null +++ b/apps/api/src/app/storage/storage.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { StorageService } from './storage.service'; + +@Module({ + providers: [StorageService], + exports: [StorageService], // Export for use in other modules +}) +export class StorageModule {} + diff --git a/apps/api/src/app/storage/storage.service.ts b/apps/api/src/app/storage/storage.service.ts new file mode 100644 index 0000000..f3c2a69 --- /dev/null +++ b/apps/api/src/app/storage/storage.service.ts @@ -0,0 +1,569 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as MinIO from 'minio'; +import { Readable } from 'stream'; +import { + StorageConnectionError, + StorageNotFoundError, + StorageOperationError, + StorageValidationError, +} from './exceptions/storage.exceptions'; + +@Injectable() +export class StorageService implements OnModuleInit { + private readonly logger = new Logger(StorageService.name); + + private minioClient: MinIO.Client; + + constructor(private configService: ConfigService) {} + + async onModuleInit() { + this.logger.log('StorageService module initialization started'); + const startTime = Date.now(); + try { + await this.initializeClient(); + await this.ensureBucketsExist(); + const duration = Date.now() - startTime; + this.logger.log( + `StorageService module initialization completed successfully in ${duration}ms`, + ); + } catch (error) { + const duration = Date.now() - startTime; + this.logger.error( + `StorageService module initialization failed after ${duration}ms: ${error.message}`, + error.stack, + ); + throw error; + } + } + + private async initializeClient(): Promise { + try { + const endpoint = this.configService.get('minio.endpoint'); + const port = this.configService.get('minio.port'); + const useSSL = this.configService.get('minio.useSSL'); + const accessKey = this.configService.get('minio.accessKey'); + const secretKey = this.configService.get('minio.secretKey'); + + this.logger.log( + `Initializing MinIO client: ${endpoint}:${port} (SSL: ${useSSL})`, + ); + + // Initialize MinIO client with configuration values + // Note: Do not hardcode values - use config to ensure consistency + // Region is not needed for local MinIO instances + this.minioClient = new MinIO.Client({ + endPoint: endpoint, + port: port, + useSSL: useSSL, + accessKey: accessKey, + secretKey: secretKey, + // Do not set region for local MinIO - it can cause signature issues + }); + + // Validate connection by listing buckets (lightweight operation) + await this.validateConnection(); + + this.logger.log('MinIO client initialized successfully'); + } catch (error) { + this.logger.error( + `Failed to initialize MinIO client: ${error.message}`, + error.stack, + ); + throw new StorageConnectionError( + `${error.message}. Ensure MinIO is running.`, + error, + ); + } + } + + private async validateConnection(): Promise { + this.logger.debug('Validating MinIO connection...'); + const startTime = Date.now(); + try { + // List buckets to validate connection + const buckets = await this.minioClient.listBuckets(); + const duration = Date.now() - startTime; + this.logger.log( + `MinIO connection validated successfully in ${duration}ms (found ${buckets.length} bucket(s))`, + ); + } catch (error) { + const duration = Date.now() - startTime; + this.logger.error( + `MinIO connection validation failed after ${duration}ms: ${error.message}`, + error.stack, + ); + throw error; + } + } + + private async ensureBucketsExist(): Promise { + const rawBucket = this.configService.get('minio.buckets.raw'); + const processedBucket = this.configService.get( + 'minio.buckets.processed', + ); + + this.logger.log( + `Ensuring buckets exist: ${rawBucket}, ${processedBucket}`, + ); + + await this.ensureBucketExists(rawBucket); + await this.ensureBucketExists(processedBucket); + + this.logger.log('Bucket verification completed'); + } + + private async ensureBucketExists(bucketName: string): Promise { + const startTime = Date.now(); + try { + const exists = await this.minioClient.bucketExists(bucketName); + + if (exists) { + const duration = Date.now() - startTime; + this.logger.log( + `Bucket '${bucketName}' already exists (verified in ${duration}ms)`, + ); + return; + } + + this.logger.log(`Creating bucket '${bucketName}'...`); + const createStartTime = Date.now(); + await this.minioClient.makeBucket(bucketName); + const createDuration = Date.now() - createStartTime; + this.logger.log( + `Bucket '${bucketName}' created successfully in ${createDuration}ms`, + ); + } catch (error) { + // Handle "bucket already exists" error gracefully (idempotent) + if (error.code === 'BucketAlreadyOwnedByYou') { + this.logger.warn( + `Bucket '${bucketName}' already exists (race condition handled)`, + ); + return; + } + + this.logger.error( + `Failed to create bucket '${bucketName}': ${error.message}`, + error.stack, + ); + throw new StorageOperationError( + 'bucket creation', + `Failed to create bucket '${bucketName}': ${error.message}`, + error, + ); + } + } + + /** + * Upload a file via streaming to MinIO. + * Streams data directly without buffering entire file in memory. + * Also accepts Buffer directly to avoid signature calculation issues. + * + * @param stream - Node.js Readable stream or Buffer (from HTTP request, file system, etc.) + * @param objectName - Full object key/path (e.g., 'raw-videos/{videoId}.mp4') + * @param metadata - Optional object metadata (e.g., { 'Content-Type': 'video/mp4' }) + * @param size - Optional file size in bytes (required for accurate signature calculation when stream.readableLength is unavailable) + * @returns Promise resolving to the object name/key + * @throws Error for stream errors, MinIO connection errors, upload failures, or invalid parameters + */ + async uploadStream( + stream: Readable | Buffer, + objectName: string, + metadata?: Record, + size?: number, + ): Promise { + // Validate parameters + if (!stream) { + throw new StorageValidationError('Stream parameter is required', 'stream'); + } + + if (!objectName || typeof objectName !== 'string' || objectName.trim() === '') { + throw new StorageValidationError( + 'Object name must be a non-empty string', + 'objectName', + ); + } + + // Parse objectName to extract bucket and object key + const { bucketName, objectKey } = this.parseObjectName(objectName); + + const startTime = Date.now(); + + // Handle Buffer directly - this avoids signature calculation issues with streams + if (Buffer.isBuffer(stream)) { + const bufferSize = size ?? stream.length; + const metadataInfo = metadata + ? `with metadata: ${Object.keys(metadata).join(', ')}` + : 'without metadata'; + this.logger.log( + `Starting buffer upload: bucket='${bucketName}', key='${objectKey}', size=${bufferSize} bytes, ${metadataInfo}`, + ); + + try { + // Use minimal metadata (only Content-Type) to avoid signature issues + // Some MinIO versions have issues with custom metadata in signature calculation + const minimalMetadata = metadata && metadata['Content-Type'] + ? { 'Content-Type': metadata['Content-Type'] } + : undefined; + await this.minioClient.putObject(bucketName, objectKey, stream, bufferSize, minimalMetadata); + const duration = Date.now() - startTime; + this.logger.log( + `Successfully uploaded buffer to '${objectName}' in ${duration}ms (bucket='${bucketName}', key='${objectKey}')`, + ); + return objectName; + } catch (error) { + const uploadDuration = Date.now() - startTime; + this.logger.error( + `MinIO buffer upload failed for '${objectName}' after ${uploadDuration}ms: ${error.message}`, + error.stack, + ); + throw new StorageOperationError( + 'upload', + `Upload failed for '${objectName}': ${error.message}`, + error, + ); + } + } + + // Handle Readable stream + // Validate stream is readable + if (!(stream instanceof Readable) || !stream.readable) { + throw new StorageValidationError('Stream is not readable', 'stream'); + } + + // Use provided size parameter, fallback to stream.readableLength + // CRITICAL: MinIO signature calculation requires a valid number, not undefined + const actualSize = size ?? stream.readableLength; + + // Validate that we have a size for signature calculation + if (actualSize === undefined || actualSize === null) { + throw new StorageValidationError( + 'Size parameter is required for accurate MinIO signature calculation. Provide file size explicitly.', + 'size', + ); + } + + const streamSize = actualSize; + const metadataInfo = metadata + ? `with metadata: ${Object.keys(metadata).join(', ')}` + : 'without metadata'; + + this.logger.log( + `Starting stream upload: bucket='${bucketName}', key='${objectKey}', size=${streamSize} bytes, ${metadataInfo}`, + ); + return new Promise((resolve, reject) => { + // Handle stream errors + const streamErrorHandler = (error: Error) => { + const streamErrorDuration = Date.now() - startTime; + this.logger.error( + `Stream error during upload for '${objectName}' after ${streamErrorDuration}ms: ${error.message}`, + error.stack, + ); + reject( + new StorageOperationError('upload', `Stream error: ${error.message}`, error), + ); + }; + + stream.once('error', streamErrorHandler); + + // Upload to MinIO + // Size parameter is critical for accurate signature calculation + this.minioClient + .putObject(bucketName, objectKey, stream, actualSize, metadata) + .then(() => { + // Remove error handler since upload succeeded + stream.removeListener('error', streamErrorHandler); + + const duration = Date.now() - startTime; + this.logger.log( + `Successfully uploaded stream to '${objectName}' in ${duration}ms (bucket='${bucketName}', key='${objectKey}')`, + ); + resolve(objectName); + }) + .catch((error) => { + // Remove error handler + stream.removeListener('error', streamErrorHandler); + + const uploadDuration = Date.now() - startTime; + this.logger.error( + `MinIO upload failed for '${objectName}' after ${uploadDuration}ms: ${error.message}`, + error.stack, + ); + + // Provide descriptive error messages + if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') { + reject( + new StorageConnectionError( + `${error.message}. Ensure MinIO is running and accessible.`, + error, + ), + ); + } else if (error.code === 'NoSuchBucket') { + reject( + new StorageOperationError( + 'upload', + `Bucket '${bucketName}' does not exist. Ensure buckets are created.`, + error, + ), + ); + } else { + reject( + new StorageOperationError( + 'upload', + `Upload failed for '${objectName}': ${error.message}`, + error, + ), + ); + } + }); + }); + } + + /** + * Parse objectName to extract bucket name and object key. + * Object name format: '{bucket-name}/{object-key}' (e.g., 'raw-videos/video.mp4') + * + * @param objectName - Full object path including bucket prefix + * @returns Object with bucketName and objectKey + * @throws Error if objectName format is invalid or bucket doesn't match configured buckets + */ + private parseObjectName(objectName: string): { + bucketName: string; + objectKey: string; + } { + const trimmedName = objectName.trim(); + + // Find the first '/' to separate bucket from key + const firstSlashIndex = trimmedName.indexOf('/'); + + if (firstSlashIndex === -1 || firstSlashIndex === 0) { + throw new StorageValidationError( + `Invalid object name format: '${objectName}'. Expected format: '{bucket-name}/{object-key}'`, + 'objectName', + ); + } + + const bucketName = trimmedName.substring(0, firstSlashIndex); + const objectKey = trimmedName.substring(firstSlashIndex + 1); + + if (!objectKey || objectKey.trim() === '') { + throw new StorageValidationError( + `Invalid object name: object key cannot be empty in '${objectName}'`, + 'objectName', + ); + } + + // Validate bucket name matches configured buckets + const rawBucket = this.configService.get('minio.buckets.raw'); + const processedBucket = this.configService.get( + 'minio.buckets.processed', + ); + + if (bucketName !== rawBucket && bucketName !== processedBucket) { + throw new StorageValidationError( + `Invalid bucket name: '${bucketName}'. Must be one of: '${rawBucket}', '${processedBucket}'`, + 'objectName', + ); + } + return { bucketName, objectKey }; + } + + /** + * Download a file as a readable stream from MinIO. + * Streams data directly without buffering entire file in memory. + * + * @param objectName - Full object key/path (e.g., 'raw-videos/{videoId}.mp4') + * @returns Promise resolving to a Readable stream + * @throws Error if object doesn't exist, MinIO connection fails, or object name is invalid + */ + async downloadStream(objectName: string): Promise { + // Validate parameters + if (!objectName || typeof objectName !== 'string' || objectName.trim() === '') { + throw new StorageValidationError( + 'Object name must be a non-empty string', + 'objectName', + ); + } + + // Parse objectName to extract bucket and object key + const { bucketName, objectKey } = this.parseObjectName(objectName); + + const startTime = Date.now(); + this.logger.log( + `Starting stream download: bucket='${bucketName}', key='${objectKey}'`, + ); + + try { + const stream = await this.minioClient.getObject(bucketName, objectKey); + const duration = Date.now() - startTime; + this.logger.log( + `Successfully initiated download stream for '${objectName}' in ${duration}ms (bucket='${bucketName}', key='${objectKey}')`, + ); + return stream; + } catch (error) { + const duration = Date.now() - startTime; + this.logger.error( + `MinIO download failed for '${objectName}' after ${duration}ms: ${error.message}`, + error.stack, + ); + + // Provide descriptive error messages + if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') { + throw new StorageConnectionError( + `${error.message}. Ensure MinIO is running and accessible.`, + error, + ); + } else if (error.code === 'NoSuchKey' || error.code === 'NotFound') { + throw new StorageNotFoundError(objectName, error); + } else if (error.code === 'NoSuchBucket') { + throw new StorageOperationError( + 'download', + `Bucket '${bucketName}' does not exist. Ensure buckets are created.`, + error, + ); + } else { + throw new StorageOperationError( + 'download', + `Download failed for '${objectName}': ${error.message}`, + error, + ); + } + } + } + + /** + * Check if an object exists in MinIO. + * Efficient operation that does not download the object. + * + * @param objectName - Full object key/path + * @returns Promise resolving to boolean (true if exists, false if not) + */ + async objectExists(objectName: string): Promise { + // Validate parameters + if (!objectName || typeof objectName !== 'string' || objectName.trim() === '') { + throw new StorageValidationError( + 'Object name must be a non-empty string', + 'objectName', + ); + } + + // Parse objectName to extract bucket and object key + const { bucketName, objectKey } = this.parseObjectName(objectName); + + const startTime = Date.now(); + this.logger.debug( + `Checking object existence: bucket='${bucketName}', key='${objectKey}'`, + ); + + try { + const stats = await this.minioClient.statObject(bucketName, objectKey); + const duration = Date.now() - startTime; + this.logger.debug( + `Object '${objectName}' exists (size: ${stats.size} bytes, checked in ${duration}ms)`, + ); + return true; + } catch (error) { + // Object doesn't exist if statObject throws NoSuchKey or NotFound + if (error.code === 'NoSuchKey' || error.code === 'NotFound') { + const duration = Date.now() - startTime; + this.logger.debug( + `Object '${objectName}' does not exist (checked in ${duration}ms)`, + ); + return false; + } + + // For other errors (connection, bucket issues), log and return false + // or rethrow based on error type + if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') { + this.logger.error( + `MinIO connection error while checking object existence: ${error.message}`, + ); + throw new StorageConnectionError( + `${error.message}. Ensure MinIO is running and accessible.`, + error, + ); + } else if (error.code === 'NoSuchBucket') { + this.logger.error( + `Bucket '${bucketName}' does not exist while checking object '${objectName}'`, + ); + return false; // Bucket doesn't exist, so object doesn't exist + } + + // For unexpected errors, log and return false (graceful degradation) + this.logger.warn( + `Unexpected error while checking object existence for '${objectName}': ${error.message}`, + ); + return false; + } + } + + /** + * Delete an object from MinIO. + * Handles object not found gracefully (does not throw if already deleted). + * + * @param objectName - Full object key/path + * @returns Promise resolving to void + * @throws Error for MinIO connection errors or invalid object names + */ + async deleteObject(objectName: string): Promise { + // Validate parameters + if (!objectName || typeof objectName !== 'string' || objectName.trim() === '') { + throw new StorageValidationError( + 'Object name must be a non-empty string', + 'objectName', + ); + } + + // Parse objectName to extract bucket and object key + const { bucketName, objectKey } = this.parseObjectName(objectName); + + const startTime = Date.now(); + this.logger.log( + `Deleting object: bucket='${bucketName}', key='${objectKey}'`, + ); + + try { + await this.minioClient.removeObject(bucketName, objectKey); + const duration = Date.now() - startTime; + this.logger.log( + `Successfully deleted object '${objectName}' in ${duration}ms (bucket='${bucketName}', key='${objectKey}')`, + ); + } catch (error) { + // Handle object not found gracefully (idempotent operation) + if (error.code === 'NoSuchKey' || error.code === 'NotFound') { + const duration = Date.now() - startTime; + this.logger.warn( + `Object '${objectName}' not found during deletion (already deleted, checked in ${duration}ms)`, + ); + return; // Don't throw - object already doesn't exist + } + + const duration = Date.now() - startTime; + this.logger.error( + `MinIO deletion failed for '${objectName}' after ${duration}ms: ${error.message}`, + error.stack, + ); + + // Provide descriptive error messages for other errors + if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') { + throw new StorageConnectionError( + `${error.message}. Ensure MinIO is running and accessible.`, + error, + ); + } else if (error.code === 'NoSuchBucket') { + throw new StorageOperationError( + 'delete', + `Bucket '${bucketName}' does not exist. Ensure buckets are created.`, + error, + ); + } else { + throw new StorageOperationError( + 'delete', + `Deletion failed for '${objectName}': ${error.message}`, + error, + ); + } + } + } +} + diff --git a/apps/api/src/app/videos/videos.module.ts b/apps/api/src/app/videos/videos.module.ts index a6d8233..dedcef5 100644 --- a/apps/api/src/app/videos/videos.module.ts +++ b/apps/api/src/app/videos/videos.module.ts @@ -1,10 +1,14 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { StorageModule } from '../storage/storage.module'; import { Video } from './entities/video.entity'; import { VideosService } from './videos.service'; @Module({ - imports: [TypeOrmModule.forFeature([Video])], + imports: [ + TypeOrmModule.forFeature([Video]), + StorageModule, // Import StorageModule to use StorageService + ], providers: [VideosService], exports: [VideosService], }) diff --git a/apps/api/src/app/videos/videos.service.ts b/apps/api/src/app/videos/videos.service.ts index 405d092..ac1306c 100644 --- a/apps/api/src/app/videos/videos.service.ts +++ b/apps/api/src/app/videos/videos.service.ts @@ -1,6 +1,9 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { Readable } from 'stream'; import { Repository } from 'typeorm'; +import { v4 as uuidv4 } from 'uuid'; +import { StorageService } from '../storage/storage.service'; import { Video, VideoStatus } from './entities/video.entity'; @Injectable() @@ -10,10 +13,74 @@ export class VideosService { constructor( @InjectRepository(Video) private videoRepository: Repository