add functions
This commit is contained in:
@@ -0,0 +1,451 @@
|
||||
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_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",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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("🎵 Received music generation callback body:", req.body);
|
||||
if (req.method !== "POST") {
|
||||
console.warn("⚠️ Méthode non autorisée:", req.method);
|
||||
return res.status(405).json({ error: "Méthode non autorisée" });
|
||||
}
|
||||
|
||||
const { code, msg, data } = req.body || {};
|
||||
console.log("🎵 Callback details:", { code, msg, data });
|
||||
|
||||
// Extraire les données du callback - peut être dans data ou directement dans req.body
|
||||
const callbackData = data || req.body || {};
|
||||
const {
|
||||
id,
|
||||
taskId,
|
||||
status,
|
||||
audio_url: audioUrl,
|
||||
video_url: videoUrl,
|
||||
image_url: imageUrl,
|
||||
lyric,
|
||||
title,
|
||||
tags,
|
||||
duration,
|
||||
error_message: errorMessage,
|
||||
} = callbackData;
|
||||
|
||||
// Normaliser le statut global et le taskId
|
||||
const overallStatus = status || code || callbackData?.status || null;
|
||||
const overallTaskId = callbackData?.taskId || callbackData?.task_id || null;
|
||||
|
||||
// Déterminer les éléments piste(s) à traiter
|
||||
const items = Array.isArray(callbackData?.response?.data)
|
||||
? callbackData.response.data
|
||||
: Array.isArray(callbackData?.sunoData)
|
||||
? callbackData.sunoData
|
||||
: id || audioUrl || imageUrl || title || tags || duration
|
||||
? [callbackData]
|
||||
: [];
|
||||
|
||||
if (!items.length) {
|
||||
// Aucun contenu piste à mettre à jour: acquitter le callback sans erreur
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: "Callback reçu (aucune piste à mettre à jour)",
|
||||
taskId: overallTaskId,
|
||||
status: overallStatus,
|
||||
});
|
||||
}
|
||||
|
||||
const updates = [];
|
||||
|
||||
for (const item of items) {
|
||||
const trackId = item.id || item.musicId || item.audioId || item.audio_id;
|
||||
if (!trackId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Chercher le document correspondant dans Firestore
|
||||
const musicRef = db.collection("music").doc(trackId);
|
||||
const musicDoc = await musicRef.get();
|
||||
if (!musicDoc.exists) {
|
||||
// Si le doc n'existe pas, ignorer silencieusement cet item
|
||||
continue;
|
||||
}
|
||||
|
||||
const updateData = {
|
||||
status: item.status || overallStatus,
|
||||
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
};
|
||||
|
||||
const audioU = item.audio_url || item.audioUrl || item.streamAudioUrl;
|
||||
const videoU = item.video_url || item.videoUrl;
|
||||
const imageU = item.image_url || item.imageUrl;
|
||||
const lyricText = item.lyric || item.prompt;
|
||||
const titleText = item.title;
|
||||
const tagsText = item.tags;
|
||||
const durationVal = item.duration;
|
||||
const errMsg = item.error_message || item.errorMessage;
|
||||
|
||||
if (audioU) updateData.audioUrl = audioU;
|
||||
if (videoU) updateData.videoUrl = videoU;
|
||||
if (imageU) updateData.imageUrl = imageU;
|
||||
if (lyricText) updateData.lyrics = lyricText;
|
||||
if (titleText) updateData.title = titleText;
|
||||
if (tagsText) updateData.style = tagsText;
|
||||
if (durationVal) updateData.duration = durationVal;
|
||||
if (errMsg) updateData.errorMessage = errMsg;
|
||||
|
||||
updates.push(
|
||||
musicRef
|
||||
.update(updateData)
|
||||
.then(() => ({ id: trackId, ok: true }))
|
||||
.catch(() => ({ id: trackId, ok: false })),
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.all(updates);
|
||||
const updated = results.filter((r) => r.ok).map((r) => r.id);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: "Callback traité avec succès",
|
||||
updated,
|
||||
taskId: overallTaskId,
|
||||
status: overallStatus,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("❌ Erreur lors du traitement du callback:", error);
|
||||
res.status(500).json({
|
||||
error: "Erreur interne du serveur",
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user