const { onCall, onRequest } = require("firebase-functions/v2/https"); const axios = require("axios"); const admin = require("firebase-admin"); const { logger } = require("firebase-functions/logger"); const { SUNO_API_KEY } = require("../config/keys"); // Initialiser Firestore const db = admin.firestore(); /** * ===== CONFIG GLOBALE ===== */ const SUNO_API_BASE = "https://api.sunoapi.org"; const SUNO_API_PATH = "/api/v1/generate"; const SUNO_STATUS_PATH = "/api/v1/generate/record-info"; const SUNO_TIMESTAMPED_LYRICS_PATH = "/api/v1/generate/get-timestamped-lyrics"; const SUNO_MODEL = "V4_5"; // const SUNO_MODEL = "V3_5"; const SUNO_CALLBACK_URL = "https://us-central1-musicland-d33f9.cloudfunctions.net/music-sunoCallback"; /** * Limite la longueur d'une chaîne * @param {string} str - La chaîne à limiter * @param {number} max - La longueur maximale * @return {string} La chaîne limitée */ function clampLen(str = "", max) { if (!max) return str || ""; if (!str) return ""; return str.length <= max ? str : str.slice(0, max); } /** * Construit le style musical à partir des paramètres * @param {Object} params - Les paramètres de style * @return {string} Le style formaté */ function buildStyle({ genres = [], voice = "", instruments = [], tempo = "" }) { const flatGenres = (genres || []).map((g) => ("" + g).trim()).filter(Boolean); const flatInstruments = (instruments || []) .map((i) => ("" + i).trim()) .filter(Boolean); const styleParts = [ flatGenres.length ? `Genres: ${flatGenres.join(", ")}` : "", tempo ? `Tempo: ${tempo}` : "", flatInstruments.length ? `Instruments: ${flatInstruments.join(", ")}` : "", voice ? `Vocal: ${voice}` : "", "Mix: propre, punchy, large stéréo, radio-ready", ].filter(Boolean); const style = styleParts.join(" ; "); return clampLen(style, 600); } /** * Convertit les paroles en prompt avec tags * @param {Array} lyrics - Tableau des paroles * @return {string} Prompt formaté avec tags */ function lyricsToTaggedPrompt(lyrics = []) { if (!Array.isArray(lyrics) || lyrics.length === 0) { return ""; } const sections = lyrics .map((section) => { if (!section || typeof section !== "object") return ""; const { type = "verse", lyrics: content = "" } = section; if (!content.trim()) return ""; const tag = type.toLowerCase(); return `[${tag}]\n${content.trim()}`; }) .filter(Boolean); return sections.join("\n\n"); } /** * Construit le prompt pour Suno * @param {Object} params - Paramètres du prompt * @return {string} Prompt formaté */ function buildSunoPrompt({ title, audience = "", projectContext = "", emotionGuide = "", styleGuide = "", }) { const parts = [ title ? `Titre: ${title}` : "", audience ? `Public cible: ${audience}` : "", projectContext ? `Contexte: ${projectContext}` : "", emotionGuide ? `Émotion: ${emotionGuide}` : "", styleGuide ? `Style: ${styleGuide}` : "", ].filter(Boolean); return parts.join("\n\n"); } /** * Construit les guides de style et d'émotion * @param {Object} params - Paramètres des guides * @return {Object} Guides formatés */ function buildGuides({ genres = [], tempo = "", voice = "", instruments = [], }) { const styleElements = []; if (genres && genres.length > 0) { styleElements.push(`Genres: ${genres.join(", ")}`); } if (tempo) { const tempoGuide = tempo.toLowerCase().includes("lent") ? "Rythme lent et contemplatif" : tempo.toLowerCase().includes("rapide") ? "Rythme énergique et dynamique" : `Tempo ${tempo.toLowerCase()}.`; styleElements.push(tempoGuide); } if (instruments && instruments.length > 0) { styleElements.push(`Instruments principaux: ${instruments.join(", ")}`); } if (voice) { styleElements.push(`Style vocal: ${voice}`); } return { styleGuide: styleElements.join(" "), emotionGuide: "", }; } /** * Fonction principale de génération de musique */ exports.generateMusic = onCall(async ({ data = {} }) => { try { const { title = "", lyrics = [], genres = [], voice = "", instruments = [], tempo = "", audience = "", projectContext = "", } = data; const { styleGuide, emotionGuide } = buildGuides({ genres, voice, instruments, tempo, }); const taggedLyrics = lyricsToTaggedPrompt(lyrics); const safeTitle = clampLen(title, 80); // Construire les informations de contexte pour le style const contextInfo = buildSunoPrompt({ title: safeTitle, audience, projectContext, emotionGuide, styleGuide, }); // Le style combine les informations de genre ET le contexte const baseStyle = buildStyle({ genres, voice, instruments, tempo }); const enhancedStyle = `${baseStyle}. ${contextInfo}`; const payload = { customMode: true, instrumental: false, model: SUNO_MODEL, prompt: clampLen(taggedLyrics, 3000), title: safeTitle, style: clampLen(enhancedStyle, 600), callBackUrl: SUNO_CALLBACK_URL || "", }; console.log("PAYLOAD", payload); const response = await axios.post( `${SUNO_API_BASE}${SUNO_API_PATH}`, payload, { headers: { "Content-Type": "application/json", Authorization: `Bearer ${SUNO_API_KEY}`, }, } ); const parsed = response.data; return { success: !!parsed?.data?.taskId, request: { model: SUNO_MODEL, instrumental: false, title: safeTitle, style: enhancedStyle, prompt: taggedLyrics, }, response: parsed || {}, }; } catch (error) { console.error("❌ Erreur lors de la génération de musique:", error); throw new Error( `Erreur lors de la génération de musique: ${error.message}` ); } }); /** * Fonction pour suivre le statut d'une génération de musique Suno */ exports.getSunoStatus = onCall(async ({ data = {} }) => { try { const { taskId } = data; if (!taskId) { throw new Error("TaskId manquant"); } console.log("🔍 Vérification du statut Suno", { taskId }); let response; let parsed; try { response = await axios.get( `${SUNO_API_BASE}${SUNO_STATUS_PATH}?taskId=${taskId}`, { headers: { Authorization: `Bearer ${SUNO_API_KEY}`, }, timeout: 30000, // 30 secondes de timeout } ); parsed = response.data; console.log("📊 Réponse Suno API:", parsed); } catch (error) { console.error("❌ Erreur détaillée API Suno Status:", { message: error.message, status: error.response?.status, statusText: error.response?.statusText, data: error.response?.data, config: { url: error.config?.url, headers: error.config?.headers, }, }); // Gestion spéciale pour les taskId de test if ( error.response && error.response.status === 404 && taskId.includes("test") ) { return { success: true, taskId, data: { status: "not_found", message: "TaskId de test - tâche non trouvée dans l'API Suno", isTestId: true, }, }; } // Retourner une erreur plus descriptive const errorStatus = error.response?.status || "UNKNOWN"; const errorMessage = error.response?.data?.msg || error.response?.data?.message || error.response?.statusText || error.message; return { success: false, taskId, error: { status: errorStatus, message: errorMessage, type: "API_ERROR", }, }; } console.log("✅ Statut Suno récupéré", { taskId, status: parsed?.data?.status || parsed?.status, }); return { success: true, taskId, data: parsed?.data || parsed, }; } catch (error) { console.error("❌ Erreur lors de la vérification du statut:", error); return { success: false, taskId: data?.taskId, error: { message: error.message, type: "INTERNAL_ERROR", }, }; } }); /** * Récupère les timestamps (aligned words) pour une génération Suno * Attend: { taskId: string, musicIndex: number, projectId: string } */ exports.getSunoTimestamps = onCall(async ({ data = {} }) => { try { const { taskId, musicIndex, projectId } = data || {}; if (!taskId) { throw new Error("TaskId manquant"); } const index = Number(musicIndex); if (!Number.isFinite(index) || index < 0) { throw new Error("musicIndex invalide"); } if (!projectId || typeof projectId !== "string" || !projectId.trim()) { throw new Error("projectId manquant ou invalide"); } console.log("🔎 Récupération des timestamps Suno", { taskId, musicIndex: index, }); let response; let parsed; try { response = await axios.post( `${SUNO_API_BASE}${SUNO_TIMESTAMPED_LYRICS_PATH}`, { taskId, musicIndex: index }, { headers: { "Content-Type": "application/json", Authorization: `Bearer ${SUNO_API_KEY}`, }, timeout: 30000, } ); parsed = response.data; console.log("📊 Réponse timestamps Suno API:", parsed?.code, parsed?.msg); } catch (error) { console.error("❌ Erreur API Suno Timestamps:", { message: error.message, status: error.response?.status, statusText: error.response?.statusText, data: error.response?.data, config: { url: error.config?.url, headers: error.config?.headers ? { ...error.config.headers, Authorization: "[redacted]" } : undefined, }, }); const errorStatus = error.response?.status || "UNKNOWN"; const errorMessage = error.response?.data?.msg || error.response?.data?.message || error.response?.statusText || error.message; return { success: false, taskId, musicIndex: index, error: { status: errorStatus, message: errorMessage, type: "API_ERROR", }, }; } const dataToReturn = parsed?.data || parsed || {}; // Sauvegarde obligatoire dans le document projet let saved = false; try { await db .collection("projects") .doc(projectId) .set( { musicTimestamps: { [musicIndex]: dataToReturn, }, updatedAt: admin.firestore.FieldValue.serverTimestamp(), }, { merge: true } ); saved = true; console.log("💾 Timestamps sauvegardés dans Firestore", { projectId, index, }); } catch (e) { console.error( "❌ Échec sauvegarde timestamps Firestore:", e?.message || e ); } return { success: true, taskId, musicIndex: index, projectId, saved, musicTimestamps: dataToReturn, }; } catch (error) { console.error("❌ Erreur interne getSunoTimestamps:", error); return { success: false, taskId: data?.taskId, musicIndex: data?.musicIndex, error: { message: error.message, type: "INTERNAL_ERROR", }, }; } }); /** * Cloud Function pour recevoir les callbacks de l'API Suno * Cette fonction est appelée par l'API Suno lorsque la génération * de musique est terminée */ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => { try { console.log("🎵 [SunoCallback] Reçu:", JSON.stringify(req.body)); if (req.method !== "POST") { console.warn("⚠️ [SunoCallback] Méthode non autorisée:", req.method); return res.status(405).json({ error: "Méthode non autorisée" }); } const body = req.body || {}; 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 = body.taskId || body.task_id || body?.data?.taskId || body?.data?.task_id || null; const tracks = Array.isArray(body?.data?.data) ? body.data.data : Array.isArray(body.data) ? body.data : []; console.log("🎯 [SunoCallback] Détails:", { code, status, callbackType, taskId, count: tracks.length, }); // On n'agit que si code === 200 et status === "complete" if (code !== 200 || status !== "complete") { console.log("ℹ️ [SunoCallback] Callback ignoré (code/status)", { code, status, }); return res.status(200).json({ success: true, ignored: true }); } if (!taskId) { console.warn("⚠️ [SunoCallback] taskId manquant dans le callback"); return res.status(200).json({ success: true, ignored: true }); } // 1) Récupérer le projectId associé au taskId let projectId = null; try { const projSnap = await db .collection("projects") .where("sunoTaskId", "==", taskId) .limit(1) .get(); if (!projSnap.empty) { projectId = projSnap.docs[0].id; } } catch (e) { console.error("❌ [SunoCallback] Erreur lookup project par taskId:", e); } if (!projectId) { console.warn("⚠️ [SunoCallback] Aucun projet trouvé pour", { taskId }); return res.status(200).json({ success: true, ignored: true }); } // 2) Extraire jusqu'à 2 URLs audio const audioUrls = tracks .map( (t) => t.audio_url || t.audioUrl || t.stream_audio_url || t.streamAudioUrl ) .filter(Boolean) .slice(0, 2); if (audioUrls.length < 2) { console.warn( "⚠️ [SunoCallback] Moins de 2 pistes audio dans le callback", { found: audioUrls.length, tracksSample: tracks.map((t) => ({ id: t.id, has_audio_url: !!t.audio_url, has_stream_audio_url: !!t.stream_audio_url, })), } ); } // 3) Télécharger et sauvegarder dans Cloud Storage + récupérer download URLs const bucket = admin.storage().bucket(); console.log("🪣 [SunoCallback] Bucket:", bucket.name); const saveOne = async (url, index) => { if (!url) return null; try { console.log(`⬇️ [SunoCallback] Téléchargement piste ${index + 1}`); const resp = await axios.get(url, { responseType: "arraybuffer" }); const buffer = Buffer.from(resp.data); const path = `musics/${projectId}/sound${index + 1}.mp3`; const token = require("crypto").randomUUID(); const file = bucket.file(path); await file.save(buffer, { resumable: false, metadata: { contentType: "audio/mpeg", cacheControl: "public, max-age=31536000", metadata: { firebaseStorageDownloadTokens: token }, }, }); const downloadUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent( path )}?alt=media&token=${token}`; console.log("✅ [SunoCallback] Sauvegardé:", path, "URL:", downloadUrl); return { path, url: downloadUrl }; } catch (e) { console.error( `❌ [SunoCallback] Échec save piste ${index + 1}:`, e.message ); return null; } }; const [p1, p2] = await Promise.all([ saveOne(audioUrls[0], 0), saveOne(audioUrls[1], 1), ]); // 4) Mettre à jour le statut du projet try { const musicUrls = [p1?.url, p2?.url].filter(Boolean); await db.collection("projects").doc(projectId).set( { musicStatus: "GENERATED", musicUrls, updatedAt: admin.firestore.FieldValue.serverTimestamp(), }, { merge: true } ); console.log("🏷️ [SunoCallback] Projet marqué GENERATED", { projectId, musicUrlsCount: musicUrls.length, }); } catch (e) { console.error("❌ [SunoCallback] Erreur maj projet:", e.message); } return res.status(200).json({ success: true, projectId, saved: [p1, p2].filter(Boolean), }); } catch (error) { logger.error("❌ [SunoCallback] Erreur interne:", error); res .status(500) .json({ error: "Erreur interne du serveur", message: error.message }); } });