643 lines
21 KiB
JavaScript
643 lines
21 KiB
JavaScript
const { onCall, onRequest, HttpsError } = require('firebase-functions/v2/https')
|
|
const axios = require('axios')
|
|
const admin = require('firebase-admin')
|
|
const { FieldValue } = require('firebase-admin/firestore')
|
|
const { logger } = require('firebase-functions/logger')
|
|
const { pipeline } = require('stream/promises')
|
|
const { randomUUID } = require('crypto')
|
|
const { ALERT_TYPE, refList } = require('../index')
|
|
const { sendNotification } = require('./notifications')
|
|
const { createOrderDocument, ORDER_TYPES } = require('./helpers/orders')
|
|
const { SUNO_API_KEY } = require('../config/secrets')
|
|
const {
|
|
SUNO_MODEL,
|
|
SUNO_CALLBACK_URL,
|
|
SUNO_API_BASE,
|
|
SUNO_API_PATH,
|
|
SUNO_STATUS_PATH,
|
|
} = require('../config/suno')
|
|
|
|
const MUSIC_GENERATION_CREDIT_COST = 8
|
|
const MUSIC_REFUND_SOURCE = 'music_generation_refund'
|
|
|
|
// ==========================================
|
|
// 1. DICTIONNAIRES DE TRADUCTION (FRONT -> SUNO)
|
|
// ==========================================
|
|
|
|
const STYLE_MAP = {
|
|
// Mapping du SONG_STYLE
|
|
Upbeat: 'Upbeat, Happy, Feel-good',
|
|
'Grandios(e)': 'Grand, Cinematic, Orchestral',
|
|
Chill: 'Chill, Relaxed, Downtempo',
|
|
Epic: 'Epic, Heroic, Trailer Music',
|
|
Dramatic: 'Dramatic, Intense, Theatrical',
|
|
Comedic: 'Comedy, Novelty, Funny',
|
|
Theatrical: 'Musical Theater, Broadway, Storytelling',
|
|
Flamboyant: 'Flamboyant, Glam, Exuberant',
|
|
Mélancolique: 'Melancholic, Sad, Emotional',
|
|
Introspective: 'Introspective, Deep, Thoughtful',
|
|
'Urban Tragedy': 'Urban, Dark, Grit, Cinematic',
|
|
Eerie: 'Eerie, Haunting, Spooky',
|
|
Mysterious: 'Mysterious, Enigmatic, Suspenseful',
|
|
}
|
|
|
|
const GENRE_MAP = {
|
|
// Mapping du CHOOSE_GENRE
|
|
Pop: 'Pop',
|
|
'Hip Hop/Rap': 'Hip Hop, Rap',
|
|
Soul: 'Soul, Neo-Soul',
|
|
Blues: 'Blues, Delta Blues',
|
|
Folk: 'Folk, Acoustic',
|
|
Punk: 'Punk Rock, High Energy',
|
|
Dance: 'Dance, Club',
|
|
Grunge: 'Grunge, Distorted',
|
|
EDM: 'EDM, Electronic',
|
|
Trap: 'Trap, 808s',
|
|
Latino: 'Latin, Reggaeton',
|
|
Dancehall: 'Dancehall, Island',
|
|
'Latin Pop': 'Latin Pop',
|
|
Raggaeton: 'Reggaeton, Urbano',
|
|
Rock: 'Rock',
|
|
'Rock progressif': 'Prog Rock, Complex',
|
|
'Hard Rock': 'Hard Rock',
|
|
Metal: 'Heavy Metal',
|
|
'R&B': 'R&B, Contemporary R&B',
|
|
Phonk: 'Phonk, Memphis Rap, Drift',
|
|
House: 'House, Deep House',
|
|
Alternative: 'Alternative Rock, Indie',
|
|
Indie: 'Indie Pop',
|
|
Country: 'Country, Americana',
|
|
Synthwave: 'Synthwave, Retrowave, 80s',
|
|
Afrobeat: 'Afrobeat, African Rhythms',
|
|
'K-Pop': 'K-Pop, Idol',
|
|
Techno: 'Techno, Minimal',
|
|
Funk: 'Funk, Groove',
|
|
Disco: 'Disco, Nu-Disco',
|
|
'New wave': 'New Wave, Post-Punk',
|
|
Jazz: 'Jazz, Smooth Jazz',
|
|
'Lo-Fi': 'Lo-Fi, Chillhop',
|
|
'Bedroom Pop': 'Bedroom Pop, Dreamy',
|
|
Ambient: 'Ambient, Atmospheric',
|
|
'Dream Pop': 'Dream Pop, Shoegaze',
|
|
Grime: 'Grime, UK Rap',
|
|
Hyperpop: 'Hyperpop, Glitch',
|
|
Gospel: 'Gospel, Spiritual',
|
|
}
|
|
|
|
const INSTRUMENT_MAP = {
|
|
'Piano classique': 'Grand Piano',
|
|
'Piano éléctrique': 'Electric Piano, Rhodes',
|
|
Synthétiseur: 'Synthesizer',
|
|
'Guitare acoustique': 'Acoustic Guitar',
|
|
'Guitare électrique': 'Electric Guitar',
|
|
Batterie: 'Drums',
|
|
Banjo: 'Banjo',
|
|
Violon: 'Violin, Strings',
|
|
Saxophone: 'Saxophone',
|
|
'Saxophone alto': 'Alto Sax',
|
|
Trompette: 'Trumpet',
|
|
Flûte: 'Flute',
|
|
Clarinette: 'Clarinet',
|
|
Djembe: 'Percussion, Djembe',
|
|
Bongos: 'Bongos',
|
|
Congas: 'Congas',
|
|
Harmonica: 'Harmonica',
|
|
Handpan: 'Handpan',
|
|
Harpe: 'Harp',
|
|
Xylophone: 'Xylophone, Mallets',
|
|
Mandoline: 'Mandolin',
|
|
Accordéon: 'Accordion',
|
|
Orgue: 'Organ',
|
|
Electronique: 'Electronic Fx',
|
|
}
|
|
|
|
const RHYTHM_MAP = {
|
|
'Très rapide': 'Very Fast Tempo, High BPM',
|
|
Rapide: 'Fast Tempo',
|
|
Normal: 'Mid-tempo',
|
|
Lent: 'Slow Tempo, Downtempo',
|
|
'Très lent': 'Very Slow, Ballad',
|
|
}
|
|
|
|
// ==========================================
|
|
// 2. FONCTIONS DE PARSING (VOIX & STRUCTURE)
|
|
// ==========================================
|
|
|
|
/**
|
|
* Analyse les phrases longues du frontend pour extraire les tags vocaux Suno
|
|
*/
|
|
function parseVoiceTags(voiceSelections = []) {
|
|
if (!Array.isArray(voiceSelections)) return []
|
|
|
|
// Concatène tout pour recherche regex (Base + Sensibilité + Technique)
|
|
const fullText = voiceSelections.join(' ').toLowerCase()
|
|
const tags = []
|
|
|
|
// Genre
|
|
if (fullText.includes('féminine') || fullText.includes('femme')) tags.push('Female Vocals')
|
|
if (fullText.includes('masculine') || fullText.includes('homme')) tags.push('Male Vocals')
|
|
if (fullText.includes('deux voix') || fullText.includes('duo')) tags.push('Duet')
|
|
if (fullText.includes('chœur') || fullText.includes('gospel')) tags.push('Choir, Backing Vocals')
|
|
if (fullText.includes('enfant')) tags.push('Youthful Vocals')
|
|
|
|
// Style / Technique
|
|
if (fullText.includes('rap') || fullText.includes('slam')) tags.push('Rapping, Flow')
|
|
if (fullText.includes('parlé') || fullText.includes('raconte'))
|
|
tags.push('Spoken Word, Narration')
|
|
if (fullText.includes('cri') || fullText.includes('scream')) tags.push('Screaming, Aggressive')
|
|
if (fullText.includes('murmure') || fullText.includes('chuchote'))
|
|
tags.push('Whispering, Intimate')
|
|
if (fullText.includes('robot') || fullText.includes('synthétique')) tags.push('Autotune, Robotic')
|
|
if (fullText.includes('puissant')) tags.push('Powerful, Belting')
|
|
if (fullText.includes('aérienne') || fullText.includes('légère')) tags.push('Airy, Ethereal')
|
|
if (fullText.includes('rauque') || fullText.includes('granuleuse')) tags.push('Raspy, Gritty')
|
|
if (fullText.includes('opéra') || fullText.includes('soprano')) tags.push('Operatic')
|
|
if (fullText.includes('sensuelle') || fullText.includes('séduisante'))
|
|
tags.push('Seductive, Breathless')
|
|
|
|
return [...new Set(tags)]
|
|
}
|
|
|
|
/**
|
|
* Convertit les phrases de structure custom en Balises Suno
|
|
*/
|
|
function mapStructureToTag(description) {
|
|
const d = description.toLowerCase()
|
|
|
|
if (d.includes('intro') && d.includes('court')) return '[Short Intro]'
|
|
if (d.includes('intro')) return '[Intro]'
|
|
if (d.includes('pré-refrain')) return '[Pre-Chorus]'
|
|
if (d.includes('solo guitare électrique')) return '[Electric Guitar Solo]'
|
|
if (d.includes('solo guitare')) return '[Guitar Solo]'
|
|
if (d.includes('solo batterie')) return '[Drum Solo]'
|
|
if (d.includes('solo saxo')) return '[Saxophone Solo]'
|
|
if (d.includes('solo violon')) return '[Violin Solo]'
|
|
if (d.includes('interlude')) return '[Instrumental Interlude]'
|
|
if (d.includes('apogée') || d.includes('finition')) return '[Big Finish]'
|
|
if (d.includes('arrêt net')) return '[Sudden End]'
|
|
if (d.includes('baissant le volume') || d.includes('fade')) return '[Fade Out]'
|
|
if (d.includes('silence')) return '[Fade to Silence]'
|
|
if (d.includes('break') || d.includes('pause')) return '[Break]'
|
|
|
|
// Mapping standard des types lyrics
|
|
if (d === 'couplet' || d === 'verse') return '[Verse]'
|
|
if (d === 'refrain' || d === 'chorus') return '[Chorus]'
|
|
if (d === 'pont' || d === 'bridge') return '[Bridge]'
|
|
|
|
return null // Pas de tag trouvé
|
|
}
|
|
|
|
// ==========================================
|
|
// 3. CONSTRUCTION DU STYLE ET DU PROMPT
|
|
// ==========================================
|
|
|
|
/**
|
|
* Génère la chaîne de style optimisée (max ~200 chars)
|
|
*/
|
|
function buildSunoStyle({ genres, songStyle, voiceData, instruments, rhythm }) {
|
|
const parts = []
|
|
|
|
// 1. Genres (Priorité 1)
|
|
if (Array.isArray(genres)) {
|
|
genres.forEach((g) => {
|
|
if (GENRE_MAP[g]) parts.push(GENRE_MAP[g])
|
|
else parts.push(g)
|
|
})
|
|
}
|
|
|
|
// 2. Song Style / Vibe (Priorité 2)
|
|
if (songStyle && STYLE_MAP[songStyle]) {
|
|
parts.push(STYLE_MAP[songStyle])
|
|
} else if (songStyle && songStyle !== 'Autre') {
|
|
parts.push(songStyle)
|
|
}
|
|
|
|
// 3. Rhythm (Priorité 3)
|
|
if (rhythm && RHYTHM_MAP[rhythm]) {
|
|
parts.push(RHYTHM_MAP[rhythm])
|
|
}
|
|
|
|
// 4. Instruments
|
|
if (Array.isArray(instruments)) {
|
|
instruments.slice(0, 3).forEach((i) => {
|
|
// Max 3 instruments pour ne pas diluer
|
|
if (INSTRUMENT_MAP[i]) parts.push(INSTRUMENT_MAP[i])
|
|
})
|
|
}
|
|
|
|
// 5. Vocals
|
|
const vocalTags = parseVoiceTags(voiceData)
|
|
parts.push(...vocalTags)
|
|
|
|
// Tags de qualité technique (toujours ajoutés)
|
|
parts.push('High Fidelity', 'Stereo')
|
|
|
|
// Déduplication et join
|
|
const uniqueStyle = [...new Set(parts)]
|
|
return clampLen(uniqueStyle.join(', '), 250)
|
|
}
|
|
|
|
/**
|
|
* Construit le texte final des paroles avec les balises de structure
|
|
*/
|
|
function buildFormattedLyrics(lyricsArray) {
|
|
if (!Array.isArray(lyricsArray) || lyricsArray.length === 0) return ''
|
|
|
|
const formattedLines = lyricsArray.map((section) => {
|
|
// Le frontend peut envoyer soit { type: "...", lyrics: "..." } soit juste une string description pour les instrumentaux
|
|
const typeOrDescription = section.type || section.description || ''
|
|
const textContent = section.lyrics || ''
|
|
|
|
// Essayer de trouver un tag spécial (ex: "Introduction instrumentale")
|
|
let tag = mapStructureToTag(typeOrDescription)
|
|
|
|
// Fallback si pas de tag spécial trouvé mais type standard
|
|
if (!tag) {
|
|
if (typeOrDescription.toLowerCase().includes('couplet')) tag = '[Verse]'
|
|
else if (typeOrDescription.toLowerCase().includes('refrain')) tag = '[Chorus]'
|
|
else tag = `[${typeOrDescription}]` // Fallback générique
|
|
}
|
|
|
|
// Si c'est une section instrumentale (pas de lyrics)
|
|
if (!textContent.trim()) {
|
|
return `\n${tag}\n`
|
|
}
|
|
|
|
return `\n${tag}\n${textContent.trim()}`
|
|
})
|
|
|
|
// Sécurité: Ajouter Intro et Outro si absents (Suno best practice)
|
|
const fullText = formattedLines.join('\n')
|
|
let finalPrompt = fullText
|
|
|
|
if (!fullText.includes('[Intro]')) {
|
|
finalPrompt = '[Intro]\n' + finalPrompt
|
|
}
|
|
if (
|
|
!fullText.includes('[Outro]') &&
|
|
!fullText.includes('[Fade Out]') &&
|
|
!fullText.includes('[Sudden End]')
|
|
) {
|
|
finalPrompt = finalPrompt + '\n\n[Outro]'
|
|
}
|
|
|
|
return finalPrompt.trim()
|
|
}
|
|
|
|
// ==========================================
|
|
// 4. FONCTIONS UTILITAIRES DE BASE (GARDÉES)
|
|
// ==========================================
|
|
|
|
function clampLen(str = '', max) {
|
|
if (!max) return str || ''
|
|
if (!str) return ''
|
|
return str.length <= max ? str : str.slice(0, max)
|
|
}
|
|
|
|
const sanitizeField = (value, fallback = null) => {
|
|
const cleaned = typeof value === 'string' ? value.trim() : value
|
|
return typeof cleaned !== 'string' || !cleaned ? fallback : cleaned
|
|
}
|
|
|
|
const sanitizeMusicUrls = (urls = []) =>
|
|
(Array.isArray(urls) ? urls : [])
|
|
.filter((url) => typeof url === 'string' && url.trim())
|
|
.map((url) => url.trim())
|
|
|
|
const formatProjectMeta = (projectData = {}) => {
|
|
const userId = sanitizeField(projectData?.userId)
|
|
const projectTitle = sanitizeField(projectData?.title, 'ton projet')
|
|
return { userId, projectTitle }
|
|
}
|
|
|
|
const parseSunoCallbackPayload = (rawBody = {}) => {
|
|
const body = rawBody || {}
|
|
const code = body.code ?? body.statusCode ?? null
|
|
const callbackType = (body?.data?.callbackType || '').toString().toLowerCase()
|
|
const status = (body.status || body.state || callbackType || '').toString().toLowerCase()
|
|
const taskId = sanitizeField(body?.data?.task_id)
|
|
const tracks = Array.isArray(body?.data?.data)
|
|
? body.data.data
|
|
: Array.isArray(body.data)
|
|
? body.data
|
|
: []
|
|
return { code, status, taskId, tracks }
|
|
}
|
|
|
|
const extractAudioUrlsFromTracks = (tracks = []) =>
|
|
tracks
|
|
.map((t) => t.audio_url || t.audioUrl || t.stream_audio_url || t.streamAudioUrl)
|
|
.filter(Boolean)
|
|
.slice(0, 2)
|
|
|
|
const fetchProjectByTaskId = async (taskId) => {
|
|
const snapshot = await refList.projects.where('sunoTaskId', '==', taskId).limit(1).get()
|
|
if (snapshot.empty) throw new Error('PROJECT_NOT_FOUND_FOR_TASK')
|
|
const doc = snapshot.docs[0]
|
|
return {
|
|
projectId: doc.id,
|
|
projectData: doc.data() || {},
|
|
projectRef: doc.ref,
|
|
}
|
|
}
|
|
|
|
// ... (Garde refundMusicCredits, markProjectMusicFailure, downloadTrackToStorage, saveTracksToStorage, mergeMusicUrls inchangés) ...
|
|
// Je remets les versions raccourcies pour l'exemple mais utilise tes fonctions existantes pour le stockage/db
|
|
|
|
const refundMusicCredits = async ({
|
|
projectId,
|
|
userId,
|
|
reason = 'music_generation_failed',
|
|
context = {},
|
|
}) => {
|
|
if (!projectId || !userId || MUSIC_GENERATION_CREDIT_COST <= 0) return null
|
|
try {
|
|
const metadata = {
|
|
source: MUSIC_REFUND_SOURCE,
|
|
reason,
|
|
projectId,
|
|
...context,
|
|
}
|
|
const { orderId } = await createOrderDocument({
|
|
userId,
|
|
type: ORDER_TYPES.SONG,
|
|
amount: MUSIC_GENERATION_CREDIT_COST,
|
|
songId: projectId,
|
|
createdBy: 'system',
|
|
metadata,
|
|
})
|
|
logger.log('💸 [Music] Crédits remboursés', { projectId, userId, orderId })
|
|
return { orderId }
|
|
} catch (error) {
|
|
logger.error('❌ [Music] Échec remboursement', {
|
|
projectId,
|
|
userId,
|
|
error: error?.message,
|
|
})
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function markProjectMusicFailure(projectId, error) {
|
|
if (!projectId) return
|
|
try {
|
|
const docRef = refList.projects.doc(projectId)
|
|
const projectSnap = await docRef.get()
|
|
const projectData = projectSnap?.data() || {}
|
|
const status = error?.response?.status || error?.status || null
|
|
const sunoMessage =
|
|
error?.response?.data?.msg || error?.response?.data?.message || error?.message || 'Erreur'
|
|
const errorPayload = { source: 'SUNO_API', message: sunoMessage }
|
|
|
|
const receiverId = sanitizeField(projectData?.userId)
|
|
const alreadyRefunded = projectData?.musicCreditsRefunded === true
|
|
let refundResult = null
|
|
if (receiverId && !alreadyRefunded) {
|
|
refundResult = await refundMusicCredits({
|
|
projectId,
|
|
userId: receiverId,
|
|
reason: sunoMessage,
|
|
})
|
|
}
|
|
await docRef.set(
|
|
{
|
|
musicStatus: 'FAILED',
|
|
musicError: errorPayload,
|
|
updatedAt: FieldValue.serverTimestamp(),
|
|
},
|
|
{ merge: true }
|
|
)
|
|
// (Notification logic here...)
|
|
} catch (err) {
|
|
console.error('Error marking failure', err)
|
|
}
|
|
}
|
|
|
|
const downloadTrackToStorage = async (url, { userId, projectId, taskId, bucket, index }) => {
|
|
if (!url) return null
|
|
try {
|
|
const resp = await axios.get(url, { responseType: 'stream' })
|
|
const path = `users/${userId}/projects/${projectId}/task-${taskId}-${index + 1}.mp3`
|
|
const token = randomUUID()
|
|
const file = bucket.file(path)
|
|
const writeStream = file.createWriteStream({
|
|
resumable: false,
|
|
metadata: {
|
|
contentType: 'audio/mpeg',
|
|
metadata: { firebaseStorageDownloadTokens: token },
|
|
},
|
|
})
|
|
await pipeline(resp.data, writeStream)
|
|
return {
|
|
path,
|
|
url: `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(path)}?alt=media&token=${token}`,
|
|
}
|
|
} catch (error) {
|
|
return null
|
|
}
|
|
}
|
|
|
|
const saveTracksToStorage = async (audioUrls, meta) => {
|
|
if (!audioUrls?.length) return []
|
|
const bucket = admin.storage().bucket()
|
|
const results = await Promise.all(
|
|
audioUrls.map((url, index) => downloadTrackToStorage(url, { ...meta, bucket, index }))
|
|
)
|
|
return results.filter(Boolean).map((entry) => entry.url)
|
|
}
|
|
|
|
const mergeMusicUrls = async (projectRef, newUrls) => {
|
|
const sanitized = sanitizeMusicUrls(newUrls)
|
|
let existing = []
|
|
try {
|
|
existing = sanitizeMusicUrls((await projectRef.get())?.data()?.musicUrls)
|
|
} catch (e) { }
|
|
const final = [...new Set([...existing, ...sanitized])]
|
|
await projectRef.set(
|
|
{
|
|
musicStatus: 'GENERATED',
|
|
musicUrls: final,
|
|
musicError: FieldValue.delete(),
|
|
},
|
|
{ merge: true }
|
|
)
|
|
return final
|
|
}
|
|
|
|
// ==========================================
|
|
// 5. FONCTIONS PRINCIPALES (CLOUD FUNCTIONS)
|
|
// ==========================================
|
|
|
|
exports.generateMusic = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} }) => {
|
|
try {
|
|
// Extraction des données du Frontend
|
|
const {
|
|
title = '',
|
|
lyrics = [], // Tableau d'objets {type, lyrics} ou strings
|
|
genres = [], // ["Pop", "Rock"]
|
|
songStyle = '', // "Upbeat"
|
|
voice = [], // ["Une voix féminine...", "Un cri brut..."] (array ou objet)
|
|
instruments = [], // ["Piano", "Violon"]
|
|
rhythm = '', // "Très rapide"
|
|
// Les champs suivants (step 1) ne sont plus utilisés dans le style Suno direct pour éviter le bruit,
|
|
// mais ont déjà servi à générer les paroles (lyrics).
|
|
// audience, context, objective, etc.
|
|
} = data
|
|
|
|
// Normalisation input Voix (peut être array, string ou objet selon ta spec)
|
|
let voiceData = []
|
|
if (Array.isArray(voice)) {
|
|
voiceData = voice
|
|
.map((v) => {
|
|
if (typeof v === 'object' && v !== null && v.value) return v.value
|
|
if (typeof v === 'string') return v
|
|
return ''
|
|
})
|
|
.filter(Boolean)
|
|
} else if (typeof voice === 'object' && voice !== null) {
|
|
voiceData = Object.values(voice).flat()
|
|
} else if (typeof voice === 'string') {
|
|
voiceData = [voice]
|
|
}
|
|
|
|
// 1. Construction du STYLE MUSICAL (Tags)
|
|
const optimizedStyle = buildSunoStyle({
|
|
genres,
|
|
songStyle,
|
|
voiceData,
|
|
instruments,
|
|
rhythm,
|
|
})
|
|
|
|
// 2. Construction du PROMPT (Paroles + Structure)
|
|
const formattedPrompt = buildFormattedLyrics(lyrics)
|
|
const safeTitle = clampLen(title, 80)
|
|
|
|
// 3. Détection du genre vocal pour le param vocalGender (optimisation v3.5)
|
|
// On regarde si on trouve 'male' ou 'female' dans les tags générés
|
|
let vGender = null
|
|
if (optimizedStyle.includes('Female')) vGender = 'female'
|
|
else if (optimizedStyle.includes('Male')) vGender = 'male'
|
|
|
|
console.log('🎵 [Suno Optimisation] Result:', {
|
|
style: optimizedStyle,
|
|
gender: vGender,
|
|
title: safeTitle,
|
|
promptStructure: formattedPrompt.substring(0, 150) + '...', // Aperçu
|
|
})
|
|
|
|
// 4. Appel API
|
|
const payload = {
|
|
customMode: true,
|
|
instrumental: false,
|
|
model: SUNO_MODEL || 'chirp-v3-5', // Toujours viser le dernier modèle
|
|
prompt: clampLen(formattedPrompt, 3000),
|
|
title: safeTitle,
|
|
style: optimizedStyle,
|
|
callBackUrl: SUNO_CALLBACK_URL || '',
|
|
}
|
|
|
|
if (vGender) payload.vocalGender = vGender
|
|
|
|
const response = await axios.post(`${SUNO_API_BASE}${SUNO_API_PATH}`, payload, {
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${SUNO_API_KEY.value()}`,
|
|
},
|
|
})
|
|
|
|
const parsed = response.data
|
|
|
|
return {
|
|
success: !!parsed?.data?.taskId,
|
|
request: {
|
|
model: payload.model,
|
|
title: safeTitle,
|
|
style: optimizedStyle,
|
|
promptPreview: formattedPrompt.substring(0, 50) + '...',
|
|
},
|
|
response: parsed || {},
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Erreur generateMusic:', error)
|
|
try {
|
|
await markProjectMusicFailure(data?.projectId, error)
|
|
} catch (e) { }
|
|
|
|
const status = error?.response?.status || 'INTERNAL_ERROR'
|
|
const message = error?.response?.data?.msg || error?.message || 'Erreur Suno'
|
|
throw new HttpsError('internal', message, { status, source: 'SUNO_API' })
|
|
}
|
|
})
|
|
|
|
// GET STATUS (inchangé mais inclus pour complétude)
|
|
exports.getSunoStatus = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} }) => {
|
|
const { taskId } = data
|
|
if (!taskId) throw new Error('TaskId manquant')
|
|
try {
|
|
const response = await axios.get(`${SUNO_API_BASE}${SUNO_STATUS_PATH}?taskId=${taskId}`, {
|
|
headers: { Authorization: `Bearer ${SUNO_API_KEY.value()}` },
|
|
timeout: 30000,
|
|
})
|
|
return {
|
|
success: true,
|
|
taskId,
|
|
data: response.data?.data || response.data,
|
|
}
|
|
} catch (error) {
|
|
if (taskId.includes('test'))
|
|
return {
|
|
success: true,
|
|
taskId,
|
|
data: { status: 'not_found', isTestId: true },
|
|
}
|
|
return { success: false, taskId, error: { message: error.message } }
|
|
}
|
|
})
|
|
|
|
// CALLBACK (Standard)
|
|
exports.sunoCallback = onRequest({ methods: ['POST'], memory: '1GiB' }, async (req, res) => {
|
|
if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' })
|
|
const { code, status, taskId, tracks } = parseSunoCallbackPayload(req.body)
|
|
|
|
if (code !== 200 || status !== 'complete' || !taskId)
|
|
return res.status(200).json({ success: true, ignored: true })
|
|
|
|
try {
|
|
const { projectId, projectData, projectRef } = await fetchProjectByTaskId(taskId)
|
|
const { userId, projectTitle } = formatProjectMeta(projectData)
|
|
|
|
const storedUrls = await saveTracksToStorage(extractAudioUrlsFromTracks(tracks), {
|
|
userId,
|
|
projectId,
|
|
taskId,
|
|
})
|
|
const musicUrls = await mergeMusicUrls(projectRef, storedUrls)
|
|
|
|
if (userId) {
|
|
try {
|
|
await sendNotification({
|
|
sender: 'SYSTEM',
|
|
receiver: userId,
|
|
receiverCollection: 'users',
|
|
title: 'Musique prête',
|
|
message: `Ta musique pour "${projectTitle}" est prête.`,
|
|
data: {
|
|
type: ALERT_TYPE?.MUSIC_GENERATION_SUCCESS,
|
|
projectId,
|
|
projectTitle,
|
|
musicUrls,
|
|
taskId,
|
|
},
|
|
})
|
|
} catch (e) {
|
|
console.error(e)
|
|
}
|
|
}
|
|
return res.status(200).json({ success: true, projectId })
|
|
} catch (error) {
|
|
// Gestion erreur silencieuse pour le webhook
|
|
return res.status(500).json({ error: error.message })
|
|
}
|
|
})
|