219 أسطر
8.0 KiB
TypeScript
219 أسطر
8.0 KiB
TypeScript
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<string>('storage.provider', { infer: true }) ?? 'local';
|
|
const publicBaseUrl = this.configService.get<string>('publicBaseUrl', { infer: true }) ?? '';
|
|
const warnings: string[] = [];
|
|
const imageProcessingEnabled =
|
|
this.configService.get<boolean>('imageProcessing.enabled', { infer: true }) ?? false;
|
|
const videoProcessingEnabled =
|
|
this.configService.get<boolean>('videoProcessing.enabled', { infer: true }) ?? false;
|
|
const videoHlsGenerationEnabled =
|
|
this.configService.get<boolean>('videoProcessing.generateHls', { infer: true }) ?? true;
|
|
const videoThumbnailGenerationEnabled =
|
|
this.configService.get<boolean>('videoProcessing.generateThumbnails', { infer: true }) ??
|
|
true;
|
|
const audioProcessingEnabled =
|
|
this.configService.get<boolean>('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<string, unknown> | 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<boolean>('aiMusic.enabled', { infer: true });
|
|
if (!enabled) {
|
|
throw new ServiceUnavailableException('AI music generation is disabled');
|
|
}
|
|
|
|
const apiKey = this.configService.get<string>('aiMusic.apiKey', { infer: true }) ?? '';
|
|
const projectId = this.configService.get<string>('aiMusic.projectId', { infer: true }) ?? '';
|
|
const location = this.configService.get<string>('aiMusic.location', { infer: true }) ?? '';
|
|
const model = this.configService.get<string>('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<string, unknown> = {
|
|
instances: [{ prompt: dto.prompt }],
|
|
parameters: {
|
|
sampleCount: 1,
|
|
durationSeconds: dto.durationSeconds ?? 12,
|
|
},
|
|
};
|
|
|
|
if (typeof dto.seed === 'number') {
|
|
(requestBody.parameters as Record<string, unknown>).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';
|
|
}
|
|
}
|