import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { v2 as cloudinary } from 'cloudinary'; @Injectable() export class CloudinaryService { private readonly logger = new Logger(CloudinaryService.name); constructor(private configService: ConfigService) { cloudinary.config({ cloud_name: this.configService.get('cloudinary.cloudName'), api_key: this.configService.get('cloudinary.apiKey'), api_secret: this.configService.get('cloudinary.apiSecret'), }); } /** * Get folder path based on file type * @param fileType - The file type (IMAGE, DOCUMENT, VIDEO) * @returns Folder path for the file type */ private getFolderPath(fileType: string): string { const baseFolder = this.configService.get('cloudinary.uploadFolder') || 'StreamFlix'; const folderMap: Record = { IMAGE: `${baseFolder}/images`, DOCUMENT: `${baseFolder}/documents`, VIDEO: `${baseFolder}/videos`, }; return folderMap[fileType.toUpperCase()] || `${baseFolder}/others`; } /** * Get resource type based on file type * @param fileType - The file type (IMAGE, DOCUMENT, VIDEO) * @returns Cloudinary resource type */ private getResourceType(fileType: string): string { const resourceMap: Record = { IMAGE: 'image', DOCUMENT: 'raw', VIDEO: 'video', }; return resourceMap[fileType.toUpperCase()] || 'auto'; } /** * Get signed upload parameters for client-side upload * @param fileType - Optional file type restriction (IMAGE, DOCUMENT, VIDEO) * @param publicId - Optional custom public_id for the file * @returns Signed upload parameters (signature, timestamp, folder, etc.) */ getSignedUploadParams(fileType?: string, publicId?: string) { try { const timestamp = Math.round(new Date().getTime() / 1000); const folder = fileType ? this.getFolderPath(fileType) : this.configService.get('cloudinary.uploadFolder') || 'StreamFlix'; const resourceType = fileType ? this.getResourceType(fileType) : 'auto'; // Parameters to sign (MUST match what frontend sends) const paramsToSign: Record = { timestamp, folder, }; // If publicId is provided, include it in signature if (publicId) { paramsToSign.public_id = publicId; } // Generate signature const signature = cloudinary.utils.api_sign_request( paramsToSign, this.configService.get('cloudinary.apiSecret') || '', ); this.logger.log( `Generated signed upload parameters for Cloudinary${fileType ? ` (type: ${fileType}, folder: ${folder})` : ''}${publicId ? `, publicId: ${publicId}` : ''}`, ); return { signature, timestamp, folder, resourceType, ...(publicId && { publicId }), ...(fileType && { fileType }), }; } catch (error) { this.logger.error('Failed to generate signed upload parameters', error); throw error; } } /** * Delete a file from Cloudinary by public ID * @param publicId - The Cloudinary public ID * @param resourceType - The resource type (image, video, raw) * @returns Deletion result */ async deleteFile( publicId: string, resourceType: string = 'image', ): Promise { try { const result = await cloudinary.uploader.destroy(publicId, { resource_type: resourceType, invalidate: true, // Invalidate CDN cache }); this.logger.log(`Successfully deleted file: ${publicId}`); return result; } catch (error) { this.logger.error(`Failed to delete file: ${publicId}`, error); throw error; } } /** * Delete a file from Cloudinary by URL * @param url - The Cloudinary file URL * @returns Deletion result */ async deleteFileByUrl(url: string): Promise { try { // Extract public ID from URL // Example: https://res.cloudinary.com/dcvbvmpfu/image/upload/v1234567890/StreamFlix/images/file.jpg // Extract: StreamFlix/images/file const publicId = this.extractPublicIdFromUrl(url); if (!publicId) { throw new NotFoundException('Could not extract public ID from URL'); } // Determine resource type from URL const resourceType = this.extractResourceTypeFromUrl(url); const result = await this.deleteFile(publicId, resourceType); this.logger.log(`Successfully deleted file from URL: ${url}`); return { ...result, deletedUrl: url, publicId, }; } catch (error) { if (error instanceof NotFoundException) { throw error; } this.logger.error(`Failed to delete file by URL: ${url}`, error); throw new NotFoundException('File not found or already deleted'); } } /** * Extract public ID from Cloudinary URL * @param url - The Cloudinary URL * @returns Public ID */ private extractPublicIdFromUrl(url: string): string | null { try { // Match pattern: /upload/v{version}/{public_id}.{extension} const match = url.match(/\/upload\/(?:v\d+\/)?(.+)\.\w+$/); return match ? match[1] : null; } catch { return null; } } /** * Extract resource type from Cloudinary URL * @param url - The Cloudinary URL * @returns Resource type (image, video, raw) */ private extractResourceTypeFromUrl(url: string): string { if (url.includes('/image/upload/')) return 'image'; if (url.includes('/video/upload/')) return 'video'; if (url.includes('/raw/upload/')) return 'raw'; return 'image'; // default } /** * Get file details from Cloudinary * @param publicId - The Cloudinary public ID * @param resourceType - The resource type * @returns File details */ async getFileDetails( publicId: string, resourceType: string = 'image', ): Promise { try { const result = await cloudinary.api.resource(publicId, { resource_type: resourceType, }); return result; } catch (error) { this.logger.error(`Failed to get file details for: ${publicId}`, error); throw new NotFoundException('File not found'); } } /** * Get file details from Cloudinary by URL * @param url - The Cloudinary file URL * @returns File details */ async getFileDetailsByUrl(url: string): Promise { try { const publicId = this.extractPublicIdFromUrl(url); if (!publicId) { throw new NotFoundException('Could not extract public ID from URL'); } const resourceType = this.extractResourceTypeFromUrl(url); const result = await this.getFileDetails(publicId, resourceType); this.logger.log(`Retrieved file details for URL: ${url}`); return result; } catch (error) { if (error instanceof NotFoundException) { throw error; } this.logger.error(`Failed to get file details by URL: ${url}`, error); throw new NotFoundException('File not found'); } } }