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 `
${content}
`; }) .join("\n"); const fallbackStructureTags = [ '
couplet
', '
refrain
', ].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;