fix of the day, payment + sub + music generation

This commit is contained in:
Thomas Demirdjian
2025-11-17 14:27:15 +01:00
parent 97321cdbda
commit 93f2711574
20 changed files with 761 additions and 398 deletions
+5 -2
View File
@@ -9,7 +9,10 @@ exports.generatePicturePrompt = (project = {}) => {
const sanitizeInline = (value = "") => {
if (typeof value !== "string") return "";
return value.replace(/[\r\n]+/g, " ").replace(/[<>]/g, "").trim();
return value
.replace(/[\r\n]+/g, " ")
.replace(/[<>]/g, "")
.trim();
};
const titleForPrompt = sanitizeInline(title) || "Sans titre";
@@ -74,7 +77,7 @@ exports.generatePicturePrompt = (project = {}) => {
: "";
const prompt = `<BRIEF>
<OBJECTIF>Créer une pochette d'album en adéquation avec les paroles de la musique</OBJECTIF>
<OBJECTIF>Créer une pochette d'album en adéquation avec les paroles de la musique et qui respecte le style ${coverStyle}</OBJECTIF>
<TITRE>${titleForPrompt}</TITRE>
${artistLine} <STYLE_MUSICAL>${tagsLine}</STYLE_MUSICAL>
<EXTRAITS_PAROLES>${lyricsLine}</EXTRAITS_PAROLES>
+191 -102
View File
@@ -7,9 +7,12 @@ 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,
@@ -18,6 +21,51 @@ const {
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
@@ -42,21 +90,46 @@ async function markProjectMusicFailure(projectId, error) {
if (status) errorPayload.status = status;
if (error?.code) errorPayload.code = error.code;
await docRef.set(
{
musicStatus: "FAILED",
sunoTaskId: FieldValue.delete(),
generationStartAt: FieldValue.delete(),
musicError: errorPayload,
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true },
);
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 message = `La génération de musique pour "${projectTitle}" a échoué.`;
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",
@@ -267,12 +340,11 @@ const downloadTrackToStorage = async (
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 resp = await axios.get(url, { responseType: "stream" });
const path = `users/${userId}/projects/${projectId}/task-${taskId}-${index + 1}.mp3`;
const token = require("crypto").randomUUID();
const token = randomUUID();
const file = bucket.file(path);
await file.save(buffer, {
const writeStream = file.createWriteStream({
resumable: false,
metadata: {
contentType: "audio/mpeg",
@@ -280,6 +352,7 @@ const downloadTrackToStorage = async (
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}`;
@@ -681,104 +754,120 @@ exports.getSunoStatus = onCall(async ({ data = {} }) => {
* 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) => {
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" });
}
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));
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)", {
const { code, status, taskId, tracks } = parseSunoCallbackPayload(req.body);
console.log("🎯 [SunoCallback] Détails:", {
code,
status,
taskId,
count: tracks.length,
});
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 });
}
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,
},
);
if (code !== 200 || status !== "complete") {
console.log(" [SunoCallback] Callback ignoré (code/status)", {
code,
status,
});
return res.status(200).json({ success: true, ignored: true });
}
const storedUrls = await saveTracksToStorage(audioUrls, {
userId,
projectId,
taskId,
});
if (!taskId) {
console.warn("⚠️ [SunoCallback] taskId manquant dans le callback");
return res.status(200).json({ success: true, ignored: true });
}
const musicUrls = await mergeMusicUrls(projectRef, storedUrls);
let projectIdForFailure = null;
console.log("🏷️ [SunoCallback] Projet marqué GENERATED", {
projectId,
musicUrlsCount: musicUrls.length,
});
try {
const { projectId, projectData, projectRef } =
await fetchProjectByTaskId(taskId);
projectIdForFailure = projectId;
const { userId, projectTitle } = formatProjectMeta(projectData);
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,
const audioUrls = extractAudioUrlsFromTracks(tracks);
if (audioUrls.length < 2) {
console.warn(
"⚠️ [SunoCallback] Moins de 2 pistes audio dans le callback",
{
found: audioUrls.length,
},
});
} 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;
logger.error("❌ [SunoCallback] Erreur interne:", error);
return res.status(statusCode).json({
success: false,
error: error?.message || "Erreur interne du serveur",
});
}
});
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",
});
}
},
);