more fixes and changes, crash, design …

This commit is contained in:
Thomas Demirdjian
2025-11-14 18:25:28 +01:00
parent cd31d42c0c
commit 705fda0ce3
17 changed files with 1136 additions and 785 deletions
+223 -205
View File
@@ -18,8 +18,6 @@ const {
SUNO_STATUS_PATH,
} = require("../config/suno");
const MAX_STORED_MUSIC_TRACKS = 4;
/**
* Marque un projet comme échoué suite à une erreur Suno
* @param {string} projectId - Identifiant du projet
@@ -55,15 +53,9 @@ async function markProjectMusicFailure(projectId, error) {
{ merge: true },
);
const receiverId =
typeof projectData?.userId === "string" && projectData.userId.trim()
? projectData.userId.trim()
: null;
const receiverId = sanitizeField(projectData?.userId);
if (receiverId) {
const projectTitle =
typeof projectData?.title === "string" && projectData.title.trim()
? projectData.title.trim()
: "ton projet";
const projectTitle = sanitizeField(projectData?.title, "ton projet");
const message = `La génération de musique pour "${projectTitle}" a échoué.`;
try {
await sendNotification({
@@ -205,6 +197,161 @@ function detectVocalGender(voiceInput = "") {
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: "arraybuffer" });
const buffer = Buffer.from(resp.data);
const path = `users/${userId}/projects/${projectId}/task-${taskId}-${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 (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
@@ -535,232 +682,103 @@ exports.getSunoStatus = onCall(async ({ data = {} }) => {
* 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" });
}
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 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
: [];
const { code, status, taskId, tracks } = parseSunoCallbackPayload(req.body);
console.log("🎯 [SunoCallback] Détails:", {
code,
status,
taskId,
count: tracks.length,
});
console.log("🎯 [SunoCallback] Détails:", {
if (code !== 200 || status !== "complete") {
console.log("️ [SunoCallback] Callback ignoré (code/status)", {
code,
status,
callbackType,
taskId,
count: tracks.length,
});
return res.status(200).json({ success: true, ignored: true });
}
// 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 });
}
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;
const projSnap = await refList.projects
.where("sunoTaskId", "==", taskId)
.limit(1)
.get();
if (projSnap.empty) {
throw new Error("Aucun projet trouvé pour ce taskId");
}
const projectDoc = projSnap.docs[0];
const projectData = projectDoc?.data() || {};
projectId = projectDoc.id;
const userId =
typeof projectData.userId === "string" && projectData.userId.trim()
? projectData.userId.trim()
: null;
const projectTitle =
typeof projectData.title === "string" && projectData.title.trim()
? projectData.title.trim()
: "ton projet";
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);
try {
const { projectId, projectData, projectRef } =
await fetchProjectByTaskId(taskId);
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,
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;
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 {
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 = `users/${userId}/projects/${projectId}/task-${taskId}-${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 },
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,
},
});
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) {
} catch (notifError) {
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 newMusicUrls = [p1?.url, p2?.url]
.filter((url) => typeof url === "string" && url.trim())
.map((url) => url.trim());
const projectRef = refList.projects.doc(projectId);
let existingMusicUrls = [];
try {
const projectSnap = await projectRef.get();
const projectData = projectSnap?.data() || {};
if (Array.isArray(projectData.musicUrls)) {
existingMusicUrls = projectData.musicUrls
.filter((url) => typeof url === "string" && url.trim())
.map((url) => url.trim());
}
} catch (readError) {
console.error(
"⚠️ [SunoCallback] Impossible de récupérer les anciennes musiques:",
readError,
"[sunoCallback] Failed to send success notification:",
notifError,
);
}
const mergedMusicUrls = [...existingMusicUrls, ...newMusicUrls];
const uniqueMusicUrls = mergedMusicUrls.filter(
(url, index) => mergedMusicUrls.indexOf(url) === index,
);
const musicUrls =
uniqueMusicUrls.length > MAX_STORED_MUSIC_TRACKS
? uniqueMusicUrls.slice(
uniqueMusicUrls.length - MAX_STORED_MUSIC_TRACKS,
)
: uniqueMusicUrls;
await projectRef.set(
{
musicStatus: "GENERATED",
musicUrls,
musicError: FieldValue.delete(),
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true },
);
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,
);
}
}
} catch (e) {
console.error("❌ [SunoCallback] Erreur maj projet:", e.message);
}
return res.status(200).json({
success: true,
projectId,
saved: [p1, p2].filter(Boolean),
savedCount: storedUrls.length,
musicUrlsCount: musicUrls.length,
});
} catch (error) {
const statusCode =
error?.message === "PROJECT_NOT_FOUND_FOR_TASK" ? 404 : 500;
logger.error("❌ [SunoCallback] Erreur interne:", error);
res
.status(500)
.json({ error: "Erreur interne du serveur", message: error.message });
return res.status(statusCode).json({
success: false,
error: error?.message || "Erreur interne du serveur",
});
}
});