const { onCall, HttpsError } = require("firebase-functions/v2/https");
const { z } = require("genkit");
const { generateAI, analyseLyrics } = require("../helpers/gemini");
const {
SUNO_API_BASE,
SUNO_TIMESTAMPED_LYRICS_PATH,
} = require("../config/suno");
const { SUNO_API_KEY } = require("../config/keys");
const axios = require("axios");
const admin = require("firebase-admin");
const { refList } = require("../index");
const TOXIC_KEYWORDS = [
/\bnazis?\b/i,
/\bnazisme\b/i,
/\bnéo[- ]?nazis?\b/i,
/\banti[- ]?s[eé]mites?\b/i,
/\bantis[eé]mitisme\b/i,
/\bracis(tes?|me)\b/i,
/\bwhite\s+supremac(?:y|istes?|ism)\b/i,
/\bku\s*klux\s*klan\b/i,
];
const buildModerationBrief = ({
objective,
context,
style,
audience,
emotion,
structure,
rhymes,
}) => {
const sections = [
`${objective || ""}`,
`${context || ""}`,
`${emotion || ""}`,
``,
`${audience || ""}`,
`${Array.isArray(rhymes) ? rhymes.join(", ") : rhymes || ""}`,
];
if (Array.isArray(structure) && structure.length) {
sections.push(`${structure.join(" | ")}`);
}
return `\n${sections.join("\n")}\n`;
};
const moderationIndicatesBlock = (moderation = {}, fallbackText = "") => {
if (!moderation || typeof moderation !== "object") return false;
if (moderation.blocked === true) return true;
const excerpts = Array.isArray(moderation.excerpts)
? moderation.excerpts
: [];
const reasons = Array.isArray(moderation.reasons)
? moderation.reasons
: [];
const keywordDetected = (value) =>
typeof value === "string" && TOXIC_KEYWORDS.some((rx) => rx.test(value));
if (excerpts.some((item) => keywordDetected(item?.quote))) return true;
if (reasons.some((item) => keywordDetected(item))) return true;
if (keywordDetected(fallbackText)) return true;
return false;
};
exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => {
try {
const {
objective = "",
context = "",
style = "",
audience = "",
emotion = "",
structure = ["couplet", "refrain", "couplet", "refrain"],
rhymes = "",
} = data;
const moderationPayload = {
objective,
context,
style,
audience,
emotion,
structure,
rhymes,
};
const moderationInput = buildModerationBrief(moderationPayload);
try {
const moderation = await analyseLyrics({
title: "Brief utilisateur",
lyrics: moderationInput,
});
if (moderationIndicatesBlock(moderation, moderationInput)) {
console.warn("generateLyrics blocked by moderation", {
reasons: moderation?.reasons,
excerpts: moderation?.excerpts,
});
const summary = Array.isArray(moderation?.reasons)
? moderation.reasons.slice(0, 3).join(", ")
: "contenu sensible";
throw new HttpsError(
"invalid-argument",
`Certaines informations saisies contiennent des termes interdits (${summary}). Merci de reformuler pour poursuivre la création des paroles.`,
{ moderation },
);
}
} catch (moderationError) {
if (moderationError instanceof HttpsError) {
throw moderationError;
}
console.error("generateLyrics moderation failure", moderationError);
throw new HttpsError(
"internal",
"Impossible de vérifier le brief pour le moment. Merci de réessayer dans quelques instants.",
);
}
console.log("Function generate lyrics start with data", data);
const system = `
Tu es un parolier expert en chanson française moderne et populaire.
Tu écris des chansons marquantes, accessibles, sur des thématiques d'innovation, d'esprit d'équipe et de créativité digitale.
Ton écriture doit toucher à la fois l'humain, le collectif et la passion d'entreprendre.
Analyse attentivement les informations placées entre balises XML-like.
Génère un titre accrocheur et mémorable en plus des paroles, tout en respectant strictement la structure demandée.
Retourne uniquement un JSON conforme au schéma fourni, sans ajouter d'autres textes.
`.trim();
const structureTags = (Array.isArray(structure) ? structure : [])
.map((part, index) => {
const content = part || "";
return ` `;
})
.join("\n");
const fallbackStructureTags = [
' ',
' ',
].join("\n");
// Construction du prompt utilisateur avec des balises explicites
const prompt = `
${objective || ""}
${context || ""}
${emotion || ""}
${style || ""}
${audience || ""}
${rhymes || ""}
${structureTags || fallbackStructureTags}
Génère un titre court, mémorable et aligné avec l'esprit de la musique.
Pour chaque section demandée, rédige entre 4 et 16 lignes expressives.
Respecte strictement l'ordre et le type de chaque section (couplet, refrain, etc.).
Retourne uniquement un objet JSON valide selon le schéma.
Utilise les retours à la ligne (\n) pour séparer les phrases afin de conserver le découpage.
`.trim();
// Définition du schéma zod/genkit pour la validation du retour
const lyricsSchema = z.object({
title: z.string().describe("Titre de la chanson, court et accrocheur"),
lyrics: z
.array(
z.object({
type: z
.string()
.describe(
'Type de section : "couplet" ou "refrain" ' +
"selon la structure",
),
lyrics: z
.string()
.describe(
"Paroles de la section, chaque ligne séparée " +
"par un retour à la ligne",
),
}),
)
.describe(
"Paroles de la chanson sous forme de tableau de sections " +
"structurées.",
),
lyricsDescription: z
.string()
.describe("Courte déscription qui résume les paroles de la musique"),
success: z.boolean().describe("Indique si la génération a réussi"),
});
return await generateAI({
system,
prompt,
schema: lyricsSchema,
});
} catch (e) {
console.log(e);
}
});
// Vérifie la toxicité des paroles via Gemini et retourne un résultat structuré
// Nom Cloud Function: lyrics-analyseLyricsToxicity
exports.analyseLyricsToxicity = onCall({}, async ({ data = {} }) => {
try {
// Validation d'entrée minimale
const requestSchema = z.object({
title: z.string().optional(),
lyrics: z
.union([
z.string(),
z.object({
couplet: z.string().optional(),
refrain: z.string().optional(),
}),
z.array(
z.object({
type: z.string().optional(),
lyrics: z.string().optional(),
}),
),
])
.describe("Paroles à analyser (formats supportés)"),
});
const parsed = requestSchema.parse(data || {});
const result = await analyseLyrics({
title: parsed.title || "",
lyrics: parsed.lyrics,
});
// Prépare un message lisible pour le front
const highCats = Object.entries(result.categories || {})
.filter(([, v]) => (typeof v === "number" ? v : 0) >= 0.5)
.map(([k]) => k)
.slice(0, 5);
let errorCode = "OK";
let message = "Analyse effectuée: aucun blocage.";
if (result.blocked) {
errorCode = "TOXIC_CONTENT_BLOCKED";
const list = highCats.length ? ` (${highCats.join(", ")})` : "";
message = `Publication bloquée: contenu interdit détecté${list}.`;
} else if (result.flagged) {
errorCode = "TOXIC_CONTENT_FLAGGED";
const list = highCats.length ? ` (${highCats.join(", ")})` : "";
message = `Attention: contenu potentiellement sensible détecté${list}.`;
}
return {
success: !result.blocked,
errorCode,
message,
result,
};
} catch (err) {
console.error("analyseLyricsToxicity failed", err?.message || err);
return {
success: false,
errorCode: "ANALYSE_FAILED",
message: "Erreur lors de l'analyse de la toxicité. Réessayez plus tard.",
};
}
});
/**
* Récupère les timestamps (aligned words) pour une génération Suno
* Attend: { projectId: string }
*/
async function getSunoTimestamps(projectId) {
try {
const { sunoTaskId, songIndex } = (
await refList.projects.doc(projectId).get()
).data();
if (!sunoTaskId) {
throw new Error("TaskId manquant");
}
if (songIndex < 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", {
sunoTaskId,
songIndex,
});
let response;
let parsed;
response = await axios.post(
`${SUNO_API_BASE}${SUNO_TIMESTAMPED_LYRICS_PATH}`,
{ taskId: sunoTaskId, musicIndex: songIndex },
{
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);
const dataToReturn = parsed?.data || parsed || {};
await refList.projects.doc(projectId).set(
{
musicTimestamps: {
[songIndex]: dataToReturn,
},
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
},
{ merge: true },
);
console.log("💾 Timestamps sauvegardés dans Firestore", {
projectId,
songIndex,
});
return {
success: true,
};
} catch (error) {
console.error("❌ Erreur interne getSunoTimestamps:", error);
return {
success: false,
error: {
message: error.message,
type: "INTERNAL_ERROR",
},
};
}
}
exports.getSunoTimestamps = getSunoTimestamps;