import { BadGatewayException, Injectable, ServiceUnavailableException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { GoogleAuth } from 'google-auth-library'; import { generateWaveformPeaksFromBuffer } from '../../common/utils/waveform.util'; import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service'; import { MediaProbeService } from '../../infrastructure/storage/media-probe.service'; import { TextToMusicDto } from './dto/text-to-music.dto'; @Injectable() export class MediaService { constructor( private readonly configService: ConfigService, private readonly storageService: ManagedStorageService, private readonly mediaProbeService: MediaProbeService, ) {} async getMediaHealth() { const [storageHealth, ffmpeg, ffprobe] = await Promise.all([ this.storageService.getHealth(), this.mediaProbeService.checkFfmpeg(), this.mediaProbeService.checkFfprobe(), ]); const storageProvider = this.configService.get('storage.provider', { infer: true }) ?? 'local'; const publicBaseUrl = this.configService.get('publicBaseUrl', { infer: true }) ?? ''; const warnings: string[] = []; const imageProcessingEnabled = this.configService.get('imageProcessing.enabled', { infer: true }) ?? false; const videoProcessingEnabled = this.configService.get('videoProcessing.enabled', { infer: true }) ?? false; const videoHlsGenerationEnabled = this.configService.get('videoProcessing.generateHls', { infer: true }) ?? true; const videoThumbnailGenerationEnabled = this.configService.get('videoProcessing.generateThumbnails', { infer: true }) ?? true; const audioProcessingEnabled = this.configService.get('audioProcessing.enabled', { infer: true }) ?? false; if (storageProvider === 'local') { warnings.push( 'Local storage requires persistent volume mounted to /app/uploads in production', ); } if (!publicBaseUrl) { warnings.push('PUBLIC_BASE_URL is not configured'); } if ((imageProcessingEnabled || videoProcessingEnabled) && !ffmpeg.available) { warnings.push('ffmpeg is not available; image/video processing may fail'); } if (!ffprobe.available) { warnings.push('ffprobe is not available; duration extraction may fail'); } if (storageProvider === 's3' && !(storageHealth.isS3Configured ?? storageHealth.s3Configured)) { warnings.push('S3 provider selected but missing required env variables'); } if (storageProvider === 's3' && !storageHealth.storagePublicBaseUrlConfigured) { warnings.push('STORAGE_PUBLIC_BASE_URL is not configured; media may be served from the storage endpoint instead of CDN'); } const s3Health = storageHealth.s3 as Record | undefined; if (storageProvider === 's3' && s3Health?.reachable === false) { warnings.push('S3 bucket is not reachable from the backend'); } const storageWritable = storageProvider !== 'local' || storageHealth.uploadPathWritable !== false; const status = !storageWritable ? 'error' : warnings.length ? 'warning' : 'ok'; return { status, storage: storageHealth, processing: { imageProcessingEnabled, videoProcessingEnabled, videoHlsGenerationEnabled, videoThumbnailGenerationEnabled, audioProcessingEnabled, ffmpegPath: ffmpeg.path, ffmpegAvailable: ffmpeg.available, ffmpegVersion: ffmpeg.version, ffprobePath: ffprobe.path, ffprobeAvailable: ffprobe.available, ffprobeVersion: ffprobe.version, ffmpeg, ffprobe, }, serving: { rangeRequests: true, immutableCacheSeconds: 31536000, hlsManifestCacheSeconds: 300, s3ImmutableCacheControl: 'public, max-age=31536000, immutable', s3HlsManifestCacheControl: 'public, max-age=300', }, staticServing: { uploadsPublicPath: storageHealth.uploadsPublicPath ?? storageHealth.publicPath ?? '/uploads', rangeRequestsExpected: true, cacheHeadersExpected: true, hlsMimeExpected: 'application/vnd.apple.mpegurl', }, warnings, }; } async generateMusicFromText(userId: string, dto: TextToMusicDto) { const enabled = this.configService.get('aiMusic.enabled', { infer: true }); if (!enabled) { throw new ServiceUnavailableException('AI music generation is disabled'); } const apiKey = this.configService.get('aiMusic.apiKey', { infer: true }) ?? ''; const projectId = this.configService.get('aiMusic.projectId', { infer: true }) ?? ''; const location = this.configService.get('aiMusic.location', { infer: true }) ?? ''; const model = this.configService.get('aiMusic.model', { infer: true }) ?? 'lyria-002'; if (!projectId || !location) { throw new ServiceUnavailableException('AI music settings are not configured'); } let url = `https://${location}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${location}/publishers/google/models/${model}:predict`; let authorizationHeader: string | null = null; if (apiKey) { url = `${url}?key=${encodeURIComponent(apiKey)}`; } else { const auth = new GoogleAuth({ scopes: ['https://www.googleapis.com/auth/cloud-platform'], }); const client = await auth.getClient(); const accessTokenRaw = await client.getAccessToken(); const accessToken = typeof accessTokenRaw === 'string' ? accessTokenRaw : (accessTokenRaw?.token ?? ''); if (!accessToken) { throw new ServiceUnavailableException('Failed to authenticate with Google Cloud'); } authorizationHeader = `Bearer ${accessToken}`; } const requestBody: Record = { instances: [{ prompt: dto.prompt }], parameters: { sampleCount: 1, durationSeconds: dto.durationSeconds ?? 12, }, }; if (typeof dto.seed === 'number') { (requestBody.parameters as Record).seed = dto.seed; } const response = await fetch(url, { method: 'POST', headers: { ...(authorizationHeader ? { Authorization: authorizationHeader } : {}), 'Content-Type': 'application/json', }, body: JSON.stringify(requestBody), }); const result = (await response.json()) as { error?: { message?: string }; predictions?: Array<{ bytesBase64Encoded?: string; mimeType?: string; audio?: { bytesBase64Encoded?: string; mimeType?: string }; }>; }; if (!response.ok) { throw new BadGatewayException( result?.error?.message ?? 'Google AI returned an unexpected error', ); } const first = result?.predictions?.[0]; const audioBase64 = first?.bytesBase64Encoded ?? first?.audio?.bytesBase64Encoded ?? ''; const mimeType = first?.mimeType ?? first?.audio?.mimeType ?? 'audio/wav'; if (!audioBase64) { throw new BadGatewayException('Google AI did not return audio content'); } const extension = this.resolveAudioExtension(mimeType); const buffer = Buffer.from(audioBase64, 'base64'); const audioUrl = await this.storageService.saveFile({ folderSegments: ['ai-music'], extension: `.${extension}`, buffer, contentType: mimeType, fileNamePrefix: `ai-${userId}`, }); return { prompt: dto.prompt, durationSeconds: dto.durationSeconds ?? 12, mimeType, sizeBytes: buffer.length, audioUrl, waveformPeaks: generateWaveformPeaksFromBuffer(buffer), }; } private resolveAudioExtension(mimeType: string): string { if (mimeType.includes('mpeg') || mimeType.includes('mp3')) { return 'mp3'; } if (mimeType.includes('ogg')) { return 'ogg'; } if (mimeType.includes('aac')) { return 'aac'; } if (mimeType.includes('wav')) { return 'wav'; } return 'wav'; } }