874 lines
25 KiB
JavaScript
874 lines
25 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 { SUNO_API_KEY } = require("../config/keys");
|
||
const { createOrderDocument, ORDER_TYPES } = require("./helpers/orders");
|
||
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";
|
||
|
||
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 crédits", {
|
||
projectId,
|
||
userId,
|
||
error: error?.message,
|
||
});
|
||
return null;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Marque un projet comme échoué suite à une erreur Suno
|
||
* @param {string} projectId - Identifiant du projet
|
||
* @param {Object} error - Erreur capturée (axios)
|
||
*/
|
||
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 lors de la génération de musique";
|
||
const errorPayload = {
|
||
source: "SUNO_API",
|
||
message: sunoMessage,
|
||
};
|
||
if (status) errorPayload.status = status;
|
||
if (error?.code) errorPayload.code = error.code;
|
||
|
||
const receiverId = sanitizeField(projectData?.userId);
|
||
const alreadyRefunded =
|
||
projectData?.musicCreditsRefunded === true ||
|
||
typeof projectData?.musicCreditsRefundOrderId === "string";
|
||
|
||
let refundResult = null;
|
||
if (receiverId && !alreadyRefunded) {
|
||
refundResult = await refundMusicCredits({
|
||
projectId,
|
||
userId: receiverId,
|
||
reason: sunoMessage,
|
||
context: {
|
||
status: status || null,
|
||
code: error?.code || null,
|
||
},
|
||
});
|
||
}
|
||
|
||
const updatePayload = {
|
||
musicStatus: "FAILED",
|
||
sunoTaskId: FieldValue.delete(),
|
||
generationStartAt: FieldValue.delete(),
|
||
musicError: errorPayload,
|
||
updatedAt: FieldValue.serverTimestamp(),
|
||
};
|
||
|
||
if (refundResult?.orderId) {
|
||
updatePayload.musicCreditsRefunded = true;
|
||
updatePayload.musicCreditsRefundOrderId = refundResult.orderId;
|
||
updatePayload.musicCreditsRefundedAt = FieldValue.serverTimestamp();
|
||
}
|
||
|
||
await docRef.set(updatePayload, { merge: true });
|
||
|
||
if (receiverId) {
|
||
const projectTitle = sanitizeField(projectData?.title, "ton projet");
|
||
const baseMessage = `La génération de musique pour "${projectTitle}" a échoué.`;
|
||
const message = refundResult?.orderId
|
||
? `${baseMessage} Tes crédits ont été remboursés.`
|
||
: baseMessage;
|
||
try {
|
||
await sendNotification({
|
||
sender: "SYSTEM",
|
||
receiver: receiverId,
|
||
receiverCollection: "users",
|
||
title: "Génération de musique échouée",
|
||
message,
|
||
data: {
|
||
type: ALERT_TYPE?.MUSIC_GENERATION_FAILED,
|
||
projectId,
|
||
projectTitle,
|
||
error: errorPayload,
|
||
},
|
||
});
|
||
} catch (notifyError) {
|
||
console.error(
|
||
"[markProjectMusicFailure] Failed to send notification:",
|
||
notifyError,
|
||
);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error(
|
||
"❌ [generateMusic] Impossible de marquer le projet en erreur:",
|
||
err,
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
}
|
||
|
||
/**
|
||
* Déduit des balises vocales explicites reconnues par les modèles (EN)
|
||
* à partir de la description utilisateur (FR)
|
||
*/
|
||
function extractVoiceStrings(voice) {
|
||
if (!voice) return [];
|
||
if (typeof voice === "string") return [voice];
|
||
if (Array.isArray(voice)) {
|
||
return voice
|
||
.map((v) => (typeof v === "string" ? v : v?.value || v?.text || ""))
|
||
.filter(Boolean);
|
||
}
|
||
if (typeof voice === "object") {
|
||
return Object.values(voice).filter(
|
||
(v) => typeof v === "string" && v.trim(),
|
||
);
|
||
}
|
||
return [];
|
||
}
|
||
|
||
function voiceTagsFromUserChoice(voiceInput = "") {
|
||
const all = extractVoiceStrings(voiceInput);
|
||
const tags = [];
|
||
const addFromText = (txt) => {
|
||
const v = String(txt || "").toLowerCase();
|
||
|
||
// Gender / ensemble
|
||
if (/féminin|feminin|féminine|feminine/.test(v))
|
||
tags.push("Female lead vocal");
|
||
if (/masculin|masculine/.test(v)) tags.push("Male lead vocal");
|
||
if (/deux voix|duo|deux chanteurs/.test(v))
|
||
tags.push("Duet (male and female voices)");
|
||
if (/choeu?r|gospel/.test(v)) tags.push("Gospel choir vocals");
|
||
|
||
// Delivery/techniques
|
||
if (/rap\b/.test(v)) tags.push("Rap vocal");
|
||
if (/slam|parl[ée]e|chant parl[ée]/.test(v))
|
||
tags.push("Spoken word / slam");
|
||
if (/raconte|narrat/.test(v)) tags.push("Narration / spoken narrator");
|
||
if (/cri|scream|rugueu|growl/.test(v)) tags.push("Screamed vocals");
|
||
if (/sprech/.test(v)) tags.push("Sprechgesang (sung-spoken) style");
|
||
|
||
// Tone/texture
|
||
if (/séduis|sensuel/.test(v)) tags.push("Seductive, sensual tone");
|
||
if (/profonde|r[ée]sonnan/.test(v)) tags.push("Deep, resonant voice");
|
||
if (/l[ée]g[èe]re|a[ée]rienne/.test(v)) tags.push("Light, airy voice");
|
||
if (/relaxante|sophistiqu[ée]/.test(v)) tags.push("Smooth, lounge vocal");
|
||
if (/synth[ée]tique|robot/.test(v)) tags.push("Synthetic/processed vocal");
|
||
if (/d[ée]coup|chopp/.test(v)) tags.push("Chopped vocal samples");
|
||
|
||
// Choir color
|
||
if (/c[ée]leste|divin|angel/i.test(v)) tags.push("Ethereal, angelic choir");
|
||
};
|
||
|
||
if (all.length === 0) addFromText(voiceInput);
|
||
else all.forEach(addFromText);
|
||
|
||
return Array.from(new Set(tags));
|
||
}
|
||
|
||
/**
|
||
* Détecte un genre vocal simple (m/f) pour Suno à partir du texte utilisateur.
|
||
*/
|
||
function detectVocalGender(voiceInput = "") {
|
||
// If array of objects with category, prefer 'base'
|
||
if (Array.isArray(voiceInput)) {
|
||
const baseItem = voiceInput.find(
|
||
(v) =>
|
||
v && (v.category === "base" || v?.category?.toLowerCase() === "base"),
|
||
);
|
||
const text = baseItem ? baseItem.value || baseItem.text || baseItem : null;
|
||
if (text) return detectVocalGender(text);
|
||
// Fallback: scan all
|
||
for (const it of voiceInput) {
|
||
const g = detectVocalGender(it?.value || it?.text || it);
|
||
if (g) return g;
|
||
}
|
||
return undefined;
|
||
}
|
||
if (typeof voiceInput === "object" && voiceInput) {
|
||
// object keyed by category
|
||
const base = voiceInput.base || voiceInput["base"];
|
||
if (base) return detectVocalGender(base);
|
||
const vals = Object.values(voiceInput).filter(Boolean);
|
||
for (const v of vals) {
|
||
const g = detectVocalGender(v);
|
||
if (g) return g;
|
||
}
|
||
return undefined;
|
||
}
|
||
const v = String(voiceInput || "").toLowerCase();
|
||
if (!v) return undefined;
|
||
if (/(féminin|feminin|féminine|feminine|voix\s*f[ée]min)/.test(v)) return "f";
|
||
if (/(masculin|masculine|voix\s*mascul)/.test(v)) return "m";
|
||
if (/(femme)/.test(v)) return "f";
|
||
if (/(homme)/.test(v)) return "m";
|
||
return undefined;
|
||
}
|
||
|
||
const sanitizeField = (value, fallback = null) => {
|
||
const cleaned = typeof value === "string" ? value.trim() : value;
|
||
if (typeof cleaned !== "string" || !cleaned) return fallback;
|
||
return 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,
|
||
};
|
||
};
|
||
|
||
const downloadTrackToStorage = async (
|
||
url,
|
||
{ userId, projectId, taskId, bucket, index },
|
||
) => {
|
||
if (!url) return null;
|
||
try {
|
||
console.log(`⬇️ [SunoCallback] Téléchargement piste ${index + 1}`);
|
||
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",
|
||
cacheControl: "public, max-age=31536000",
|
||
metadata: { firebaseStorageDownloadTokens: token },
|
||
},
|
||
});
|
||
await pipeline(resp.data, writeStream);
|
||
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 (error) {
|
||
console.error(
|
||
`❌ [SunoCallback] Échec save piste ${index + 1}:`,
|
||
error.message,
|
||
);
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const saveTracksToStorage = async (audioUrls, meta) => {
|
||
if (!audioUrls?.length) return [];
|
||
const bucket = admin.storage().bucket();
|
||
console.log("🪣 [SunoCallback] Bucket:", bucket.name);
|
||
const context = { ...meta, bucket };
|
||
const results = await Promise.all(
|
||
audioUrls.map((url, index) =>
|
||
downloadTrackToStorage(url, { ...context, index }),
|
||
),
|
||
);
|
||
return results.filter(Boolean).map((entry) => entry.url);
|
||
};
|
||
|
||
const mergeMusicUrls = async (projectRef, newUrls) => {
|
||
const sanitizedNewUrls = sanitizeMusicUrls(newUrls);
|
||
console.log("➕ [SunoCallback] Nouveaux morceaux à ajouter:", {
|
||
count: sanitizedNewUrls.length,
|
||
urls: sanitizedNewUrls,
|
||
});
|
||
|
||
let existingUrls = [];
|
||
try {
|
||
const snapshot = await projectRef.get();
|
||
existingUrls = sanitizeMusicUrls(snapshot?.data()?.musicUrls);
|
||
console.log("📦 [SunoCallback] Morceaux déjà stockés:", {
|
||
count: existingUrls.length,
|
||
urls: existingUrls,
|
||
});
|
||
} catch (error) {
|
||
console.error(
|
||
"⚠️ [SunoCallback] Impossible de récupérer les anciennes musiques:",
|
||
error,
|
||
);
|
||
}
|
||
|
||
const allUrls = [...existingUrls, ...sanitizedNewUrls];
|
||
const dedupedUrls = allUrls.filter(
|
||
(url, index) => allUrls.indexOf(url) === index,
|
||
);
|
||
|
||
console.log("🎶 [SunoCallback] Morceaux conservés après fusion:", {
|
||
count: dedupedUrls.length,
|
||
urls: dedupedUrls,
|
||
});
|
||
|
||
await projectRef.set(
|
||
{
|
||
musicStatus: "GENERATED",
|
||
musicUrls: dedupedUrls,
|
||
musicError: FieldValue.delete(),
|
||
updatedAt: FieldValue.serverTimestamp(),
|
||
},
|
||
{ merge: true },
|
||
);
|
||
|
||
return dedupedUrls;
|
||
};
|
||
|
||
/**
|
||
* 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 voiceTags = voiceTagsFromUserChoice(voice);
|
||
const voiceStrings = extractVoiceStrings(voice);
|
||
const voiceUserText = voiceStrings.join(" ; ");
|
||
|
||
const styleParts = [
|
||
flatGenres.length ? `Genres: ${flatGenres.join(", ")}` : "",
|
||
tempo ? `Tempo: ${tempo}` : "",
|
||
flatInstruments.length ? `Instruments: ${flatInstruments.join(", ")}` : "",
|
||
// Inject tags AND the raw user string to fully convey preference
|
||
voiceTags.length ? `Vocal: ${voiceTags.join(", ")}` : "",
|
||
voiceUserText ? `Vocal (user): ${voiceUserText}` : "",
|
||
"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(", ")}`);
|
||
}
|
||
|
||
const voiceStrings = extractVoiceStrings(voice);
|
||
if (voiceStrings.length) {
|
||
const tags = voiceTagsFromUserChoice(voice);
|
||
if (tags.length) styleElements.push(`Vocal direction: ${tags.join(", ")}`);
|
||
styleElements.push(`User vocal request: ${voiceStrings.join(" ; ")}`);
|
||
}
|
||
|
||
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 || "",
|
||
};
|
||
|
||
// Appliquer la préférence de genre vocal si détectée (doc: vocalGender: "m" | "f")
|
||
const vGender = detectVocalGender(voice);
|
||
if (vGender) payload.vocalGender = vGender;
|
||
|
||
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,
|
||
vocalGender: vGender || null,
|
||
},
|
||
response: parsed || {},
|
||
};
|
||
} catch (error) {
|
||
console.error("❌ Erreur lors de la génération de musique:", error);
|
||
try {
|
||
await markProjectMusicFailure(data?.projectId, error);
|
||
} catch (markErr) {
|
||
console.error(
|
||
"❌ Erreur lors de la mise à jour du statut de projet:",
|
||
markErr,
|
||
);
|
||
}
|
||
|
||
const status = error?.response?.status || "INTERNAL_ERROR";
|
||
const message =
|
||
error?.response?.data?.msg ||
|
||
error?.response?.data?.message ||
|
||
error?.message ||
|
||
"Erreur lors de la génération de musique avec Suno";
|
||
|
||
throw new HttpsError("internal", message, {
|
||
status,
|
||
source: "SUNO_API",
|
||
});
|
||
}
|
||
});
|
||
|
||
/**
|
||
* 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"], memory: "1GiB" },
|
||
async (req, res) => {
|
||
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" });
|
||
}
|
||
|
||
console.log("🎵 [SunoCallback] Reçu:", JSON.stringify(req.body));
|
||
|
||
const { code, status, taskId, tracks } = parseSunoCallbackPayload(req.body);
|
||
console.log("🎯 [SunoCallback] Détails:", {
|
||
code,
|
||
status,
|
||
taskId,
|
||
count: tracks.length,
|
||
});
|
||
|
||
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 });
|
||
}
|
||
|
||
let projectIdForFailure = null;
|
||
|
||
try {
|
||
const { projectId, projectData, projectRef } =
|
||
await fetchProjectByTaskId(taskId);
|
||
projectIdForFailure = projectId;
|
||
const { userId, projectTitle } = formatProjectMeta(projectData);
|
||
|
||
const audioUrls = extractAudioUrlsFromTracks(tracks);
|
||
if (audioUrls.length < 2) {
|
||
console.warn(
|
||
"⚠️ [SunoCallback] Moins de 2 pistes audio dans le callback",
|
||
{
|
||
found: audioUrls.length,
|
||
},
|
||
);
|
||
}
|
||
|
||
const storedUrls = await saveTracksToStorage(audioUrls, {
|
||
userId,
|
||
projectId,
|
||
taskId,
|
||
});
|
||
|
||
const musicUrls = await mergeMusicUrls(projectRef, storedUrls);
|
||
|
||
console.log("🏷️ [SunoCallback] Projet marqué GENERATED", {
|
||
projectId,
|
||
musicUrlsCount: musicUrls.length,
|
||
});
|
||
|
||
if (userId) {
|
||
const successMessage =
|
||
musicUrls.length > 0
|
||
? `Ta musique pour "${projectTitle}" est prête.`
|
||
: `La génération de musique pour "${projectTitle}" est terminée.`;
|
||
try {
|
||
await sendNotification({
|
||
sender: "SYSTEM",
|
||
receiver: userId,
|
||
receiverCollection: "users",
|
||
title: "Musique prête",
|
||
message: successMessage,
|
||
data: {
|
||
type: ALERT_TYPE?.MUSIC_GENERATION_SUCCESS,
|
||
projectId,
|
||
projectTitle,
|
||
musicUrls,
|
||
taskId,
|
||
},
|
||
});
|
||
} catch (notifError) {
|
||
console.error(
|
||
"[sunoCallback] Failed to send success notification:",
|
||
notifError,
|
||
);
|
||
}
|
||
}
|
||
|
||
return res.status(200).json({
|
||
success: true,
|
||
projectId,
|
||
savedCount: storedUrls.length,
|
||
musicUrlsCount: musicUrls.length,
|
||
});
|
||
} catch (error) {
|
||
const statusCode =
|
||
error?.message === "PROJECT_NOT_FOUND_FOR_TASK" ? 404 : 500;
|
||
if (statusCode !== 404 && projectIdForFailure) {
|
||
try {
|
||
await markProjectMusicFailure(projectIdForFailure, error);
|
||
} catch (markError) {
|
||
console.error(
|
||
"⚠️ [SunoCallback] Impossible de marquer le projet en échec:",
|
||
markError,
|
||
);
|
||
}
|
||
}
|
||
logger.error("❌ [SunoCallback] Erreur interne:", error);
|
||
return res.status(statusCode).json({
|
||
success: false,
|
||
error: error?.message || "Erreur interne du serveur",
|
||
});
|
||
}
|
||
},
|
||
);
|