Refactor app flows and refresh UI components
This commit is contained in:
File diff suppressed because one or more lines are too long
+102
-14
@@ -52,10 +52,106 @@ function basicTemplate({ title = '', content = '', button = null }) {
|
||||
</body></html>`
|
||||
}
|
||||
|
||||
function welcomeTemplate({ firstName = '', lastName = '' }) {
|
||||
const fullName = [firstName, lastName].filter(Boolean).join(' ').trim()
|
||||
const greeting = fullName ? `Salut ${fullName},` : 'Salut,'
|
||||
return `<!doctype html><html lang="fr" style="background-color:#0b0b10"><head>
|
||||
function cleanEmailName(firstName) {
|
||||
return typeof firstName === 'string' ? firstName.replace(/[\r\n]+/g, ' ').trim() : ''
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function getWelcomeLanguage(preferredLanguage) {
|
||||
return preferredLanguage === 'en' ? 'en' : 'fr'
|
||||
}
|
||||
|
||||
function welcomeEmailSubject({ firstName = '', preferredLanguage = 'fr' } = {}) {
|
||||
const language = getWelcomeLanguage(preferredLanguage)
|
||||
const cleanFirstName = cleanEmailName(firstName)
|
||||
const subject = language === 'en' ? 'Welcome to Musicland' : 'Bienvenue à MusicLand'
|
||||
return cleanFirstName ? `${subject}, ${cleanFirstName}` : subject
|
||||
}
|
||||
|
||||
function welcomeTemplate({ firstName = '', preferredLanguage = 'fr' } = {}) {
|
||||
const language = getWelcomeLanguage(preferredLanguage)
|
||||
const cleanFirstName = cleanEmailName(firstName)
|
||||
const safeFirstName = escapeHtml(cleanFirstName)
|
||||
const greeting =
|
||||
language === 'en'
|
||||
? safeFirstName
|
||||
? `Hi ${safeFirstName},`
|
||||
: 'Hi,'
|
||||
: safeFirstName
|
||||
? `Salut ${safeFirstName},`
|
||||
: 'Salut,'
|
||||
const content =
|
||||
language === 'en'
|
||||
? `
|
||||
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:#ffffff!important">
|
||||
That’s it, you’ve got your pass to MusicLand. Welcome to the land of song!
|
||||
</p>
|
||||
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:#ffffff!important">
|
||||
You’re about to embark on an extraordinary adventure!
|
||||
</p>
|
||||
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:#ffffff!important">
|
||||
You’ll write your own lyrics, add a melody, shoot your music video… and upload your song to the MusicLand streaming platform.
|
||||
</p>
|
||||
<p style="margin:0 0 10px;font-size:16px;line-height:1.6;color:#ffffff!important">
|
||||
Three areas await you:
|
||||
</p>
|
||||
<ul style="margin:0 0 16px;padding-left:22px;font-size:16px;line-height:1.6;color:#ffffff!important">
|
||||
<li style="margin-bottom:8px">The Songwriting Area: Céline will help you write original lyrics.</li>
|
||||
<li style="margin-bottom:8px">The Studio: Malik will turn it into beautiful music.</li>
|
||||
<li>The Video Studio: With Théo, you’ll direct your first music video.</li>
|
||||
</ul>
|
||||
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:#ffffff!important">
|
||||
And don’t worry, you don’t need to know how to sing, you don’t need any musical or technical skills, and you’ll be blown away by the result.
|
||||
</p>
|
||||
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:#ffffff!important">
|
||||
See you soon in the halls of MusicLand,
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:16px;line-height:1.6;color:#ffffff!important">
|
||||
And… Feel the good vibe!
|
||||
</p>
|
||||
<p style="margin:0;font-size:16px;line-height:1.6;color:#ffffff!important">
|
||||
<strong>The MusicLand Team</strong>
|
||||
</p>`
|
||||
: `
|
||||
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:#ffffff!important">
|
||||
Ça y est : tu as ton pass pour MusicLand. Bienvenue au pays de la chanson !
|
||||
</p>
|
||||
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:#ffffff!important">
|
||||
Tu vas vivre une aventure extraordinaire!
|
||||
</p>
|
||||
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:#ffffff!important">
|
||||
Tu vas écrire tes paroles, habiller ta mélodie, tourner ton clip… et déposer <strong>ta</strong> chanson sur la plateforme de streaming de MusicLand.
|
||||
</p>
|
||||
<p style="margin:0 0 10px;font-size:16px;line-height:1.6;color:#ffffff!important">
|
||||
Trois espaces t'attendent:
|
||||
</p>
|
||||
<ul style="margin:0 0 16px;padding-left:22px;font-size:16px;line-height:1.6;color:#ffffff!important">
|
||||
<li style="margin-bottom:8px">L'espace Écriture: Céline va t’aider à composer un texte original.</li>
|
||||
<li style="margin-bottom:8px">Le studio: Malik va le sublimer en musique.</li>
|
||||
<li>Le studio vidéo: avec Théo tu réaliseras ton premier clip vidéo.</li>
|
||||
</ul>
|
||||
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:#ffffff!important">
|
||||
Et rassure toi, tu n’as pas besoin de savoir chanter, tu n'as besoin d’aucune compétence musicale ou technique et tu vas être bluffé par le résultat.
|
||||
</p>
|
||||
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:#ffffff!important">
|
||||
À très vite dans les allées de MusicLand,
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:16px;line-height:1.6;color:#ffffff!important">
|
||||
And… Feel the good vibe!
|
||||
</p>
|
||||
<p style="margin:0;font-size:16px;line-height:1.6;color:#ffffff!important">
|
||||
<strong>L'équipe MusicLand</strong>
|
||||
</p>`
|
||||
|
||||
return `<!doctype html><html lang="${language}" style="background-color:#0b0b10"><head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="supported-color-schemes" content="dark" />
|
||||
@@ -86,16 +182,7 @@ function welcomeTemplate({ firstName = '', lastName = '' }) {
|
||||
/>
|
||||
</div>
|
||||
<p style="margin:0 0 12px;font-size:16px;letter-spacing:0.2px;color:#ffffff!important">${greeting}</p>
|
||||
<h1 style="margin:0 0 16px;font-size:28px;line-height:1.2;color:#ffffff!important">Bienvenue sur MusicLand</h1>
|
||||
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:#ffffff!important">
|
||||
Nous sommes ravis de t'accueillir dans la communauté des artistes et passionnés de MusicLand.
|
||||
</p>
|
||||
<p style="margin:0 0 16px;font-size:15px;line-height:1.6;color:#ffffff!important">
|
||||
Explore les playlists collaboratives, dépose tes projets et découvre ce que prépare la scène émergente.
|
||||
</p>
|
||||
<p style="margin:0;font-size:15px;line-height:1.6;color:#ffffff!important">
|
||||
L'équipe MusicLand
|
||||
</p>
|
||||
${content}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -107,4 +194,5 @@ function welcomeTemplate({ firstName = '', lastName = '' }) {
|
||||
}
|
||||
|
||||
exports.basicTemplate = basicTemplate
|
||||
exports.welcomeEmailSubject = welcomeEmailSubject
|
||||
exports.welcomeTemplate = welcomeTemplate
|
||||
|
||||
+76
-31
@@ -7,8 +7,46 @@ const { setTimeout } = require('timers/promises')
|
||||
|
||||
// --- CONFIGURATION ---
|
||||
// Plus intelligente que le Flash original, ultra rapide, et stable sur l'API.
|
||||
const TEXT_MODEL_NAME = 'gemini-flash-latest'
|
||||
const TEXT_MODEL_NAME = 'gemini-3.5-flash'
|
||||
const TEXT_FALLBACK_MODEL_NAME = 'gemini-3.1-flash-lite'
|
||||
const IMAGE_MODEL_NAME = 'gemini-3-pro-image'
|
||||
const TEXT_GENERATION_MAX_ATTEMPTS = 3
|
||||
const TEXT_GENERATION_RETRY_DELAY_MS = 1000
|
||||
|
||||
const isRetryableTextGenerationError = (error) => {
|
||||
const status = Number(error?.status || error?.response?.status)
|
||||
return [429, 500, 502, 503, 504].includes(status)
|
||||
}
|
||||
|
||||
const generateTextWithRetry = async (ai, request, operation) => {
|
||||
let lastError = null
|
||||
|
||||
for (let attempt = 1; attempt <= TEXT_GENERATION_MAX_ATTEMPTS; attempt += 1) {
|
||||
const modelName =
|
||||
attempt === TEXT_GENERATION_MAX_ATTEMPTS ? TEXT_FALLBACK_MODEL_NAME : TEXT_MODEL_NAME
|
||||
|
||||
try {
|
||||
return await ai.generate({
|
||||
...request,
|
||||
model: googleAI.model(modelName),
|
||||
})
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
const shouldRetry =
|
||||
isRetryableTextGenerationError(error) && attempt < TEXT_GENERATION_MAX_ATTEMPTS
|
||||
|
||||
if (!shouldRetry) throw error
|
||||
|
||||
const delayMs = TEXT_GENERATION_RETRY_DELAY_MS * 2 ** (attempt - 1)
|
||||
console.warn(
|
||||
`🔄 [${operation}] ${modelName} indisponible (${error?.status || 'réseau'}), nouvelle tentative ${attempt + 1}/${TEXT_GENERATION_MAX_ATTEMPTS} dans ${delayMs}ms`
|
||||
)
|
||||
await setTimeout(delayMs)
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
// --- SINGLETON PATTERN (WARM START) ---
|
||||
// On stocke l'instance en dehors de la fonction pour la réutiliser
|
||||
@@ -39,15 +77,18 @@ exports.generateAI = async ({ system = '', prompt = '', schema }) => {
|
||||
const startedAt = Date.now()
|
||||
|
||||
try {
|
||||
const { output } = await ai.generate({
|
||||
model: googleAI.model(TEXT_MODEL_NAME),
|
||||
system,
|
||||
prompt,
|
||||
output: { schema },
|
||||
config: {
|
||||
temperature: 0.7, // Créativité équilibrée
|
||||
const { output } = await generateTextWithRetry(
|
||||
ai,
|
||||
{
|
||||
system,
|
||||
prompt,
|
||||
output: { schema },
|
||||
config: {
|
||||
temperature: 0.7, // Créativité équilibrée
|
||||
},
|
||||
},
|
||||
})
|
||||
'generateAI'
|
||||
)
|
||||
|
||||
return output
|
||||
} catch (error) {
|
||||
@@ -97,7 +138,7 @@ exports.analyseLyrics = async ({ title = '', lyrics }) => {
|
||||
flagged: z.boolean().describe('Vrai si le contenu nécessite un avertissement.'),
|
||||
blocked: z.boolean().describe('Vrai UNIQUEMENT si violation grave (Haine, Violence réelle).'),
|
||||
score: z.number().min(0).max(1).describe('Score de risque (0=Sûr, 1=Dangereux).'),
|
||||
reasons: z.array(z.string()).describe('Liste concise des raisons.'),
|
||||
reasons: z.array(z.string()).max(5).describe('Liste concise des raisons.'),
|
||||
excerpts: z
|
||||
.array(
|
||||
z.object({
|
||||
@@ -106,7 +147,7 @@ exports.analyseLyrics = async ({ title = '', lyrics }) => {
|
||||
severity: z.string(),
|
||||
})
|
||||
)
|
||||
.max(10),
|
||||
.max(3),
|
||||
success: z.boolean(),
|
||||
})
|
||||
|
||||
@@ -133,27 +174,31 @@ ${lyricsText}
|
||||
const startedAt = Date.now()
|
||||
|
||||
try {
|
||||
const { output } = await ai.generate({
|
||||
model: googleAI.model(TEXT_MODEL_NAME),
|
||||
system,
|
||||
prompt: userPrompt,
|
||||
output: { schema: moderationSchema },
|
||||
config: {
|
||||
// Paramètres de sécurité permissifs pour laisser l'IA voir et juger le contenu
|
||||
safetySettings: [
|
||||
{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' },
|
||||
{
|
||||
category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
|
||||
threshold: 'BLOCK_NONE',
|
||||
},
|
||||
{
|
||||
category: 'HARM_CATEGORY_DANGEROUS_CONTENT',
|
||||
threshold: 'BLOCK_NONE',
|
||||
},
|
||||
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' },
|
||||
],
|
||||
const { output } = await generateTextWithRetry(
|
||||
ai,
|
||||
{
|
||||
system,
|
||||
prompt: userPrompt,
|
||||
output: { schema: moderationSchema },
|
||||
config: {
|
||||
temperature: 0,
|
||||
// Paramètres de sécurité permissifs pour laisser l'IA voir et juger le contenu
|
||||
safetySettings: [
|
||||
{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' },
|
||||
{
|
||||
category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
|
||||
threshold: 'BLOCK_NONE',
|
||||
},
|
||||
{
|
||||
category: 'HARM_CATEGORY_DANGEROUS_CONTENT',
|
||||
threshold: 'BLOCK_NONE',
|
||||
},
|
||||
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
'analyseLyrics'
|
||||
)
|
||||
|
||||
if (!output) throw new Error("Échec de l'analyse de modération.")
|
||||
|
||||
|
||||
@@ -92,9 +92,13 @@ const buildCircularMask = (size) => {
|
||||
async function loadCoverIdentity(project) {
|
||||
const artistName = normalizeArtistName(project.userName)
|
||||
const userId = typeof project.userId === 'string' ? project.userId.trim() : ''
|
||||
const centerImageURL =
|
||||
typeof project.coverCenterImageURL === 'string' && project.coverCenterImageURL.trim()
|
||||
? project.coverCenterImageURL.trim()
|
||||
: null
|
||||
|
||||
if (!userId) {
|
||||
return { artistName, profilePictureURL: null }
|
||||
return { artistName, profilePictureURL: centerImageURL }
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -102,14 +106,14 @@ async function loadCoverIdentity(project) {
|
||||
const userData = userSnapshot.exists ? userSnapshot.data() : null
|
||||
return {
|
||||
artistName: normalizeArtistName(project.userName || userData?.userName),
|
||||
profilePictureURL: userData?.profilePictureURL || null,
|
||||
profilePictureURL: centerImageURL || userData?.profilePictureURL || null,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('[Cover] Unable to load profile picture', {
|
||||
projectId: project.id,
|
||||
error: error.message,
|
||||
})
|
||||
return { artistName, profilePictureURL: null }
|
||||
return { artistName, profilePictureURL: centerImageURL }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ const projectsRef = db.collection('projects')
|
||||
const REGION = process.env.FIREBASE_REGION || 'europe-west1'
|
||||
|
||||
const HEYGEN_BASE_URL = 'https://api.heygen.com/v3'
|
||||
const HEYGEN_REQUEST_TIMEOUT_MS = 45_000
|
||||
const PLAYBACK_STATUS = {
|
||||
GENERATING: 'GENERATING',
|
||||
DRAFT_READY: 'DRAFT_READY',
|
||||
@@ -65,9 +66,10 @@ const buildPlaybackReadyNotificationId = ({ projectId, videoId }) => {
|
||||
return `playback-ready-${digest}`
|
||||
}
|
||||
|
||||
const getHeygenHeaders = () => ({
|
||||
const getHeygenHeaders = (idempotencyKey = null) => ({
|
||||
'Content-Type': 'application/json',
|
||||
'X-Api-Key': getSecretValue(HEYGEN_API_KEY, 'HEYGEN_API_KEY'),
|
||||
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
|
||||
})
|
||||
|
||||
const getProjectSnapshot = async ({ projectId, uid }) => {
|
||||
@@ -120,6 +122,7 @@ const findProjectForWebhook = async ({ callbackId, videoId }) => {
|
||||
const fetchCanonicalVideo = async (videoId) => {
|
||||
const response = await axios.get(`${HEYGEN_BASE_URL}/videos/${videoId}`, {
|
||||
headers: getHeygenHeaders(),
|
||||
timeout: HEYGEN_REQUEST_TIMEOUT_MS,
|
||||
})
|
||||
|
||||
return response?.data?.data || null
|
||||
@@ -210,7 +213,8 @@ exports.startPlaybackGeneration = onCall(
|
||||
callback_id: callbackId,
|
||||
},
|
||||
{
|
||||
headers: getHeygenHeaders(),
|
||||
headers: getHeygenHeaders(callbackId),
|
||||
timeout: HEYGEN_REQUEST_TIMEOUT_MS,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+17
-9
@@ -5,7 +5,7 @@ const { SUNO_API_BASE, SUNO_TIMESTAMPED_LYRICS_PATH } = require('../config/suno'
|
||||
const { GEMINI_API_KEY, SUNO_API_KEY } = require('../config/secrets')
|
||||
const axios = require('axios')
|
||||
const { FieldValue } = require('firebase-admin/firestore')
|
||||
const { REGION, refList } = require('../index')
|
||||
const { refList } = require('../index')
|
||||
|
||||
// --- CONSTANTES DE STRUCTURE ---
|
||||
// On garde ces mappings car ils sont utiles pour normaliser l'input utilisateur
|
||||
@@ -59,13 +59,22 @@ const STRUCTURE_ALIASES = {
|
||||
soft_transition: 'transition_douce',
|
||||
}
|
||||
|
||||
const LYRICS_FUNCTION_OPTIONS = {
|
||||
region: REGION,
|
||||
const LYRICS_FUNCTION_BASE_OPTIONS = {
|
||||
cors: true,
|
||||
invoker: 'public',
|
||||
secrets: [GEMINI_API_KEY],
|
||||
}
|
||||
|
||||
const LYRICS_GENERATION_OPTIONS = {
|
||||
...LYRICS_FUNCTION_BASE_OPTIONS,
|
||||
region: 'us-central1',
|
||||
}
|
||||
|
||||
const LYRICS_MODERATION_OPTIONS = {
|
||||
...LYRICS_FUNCTION_BASE_OPTIONS,
|
||||
region: ['us-central1', 'europe-west1'],
|
||||
}
|
||||
|
||||
// --- UTILITAIRES ---
|
||||
|
||||
const normalizeStructureValue = (value) => {
|
||||
@@ -144,7 +153,7 @@ const buildModerationBrief = ({
|
||||
|
||||
// --- GENERATION DE PAROLES (MAIN) ---
|
||||
|
||||
exports.generateLyrics = onCall(LYRICS_FUNCTION_OPTIONS, async ({ auth = {}, data = {} }) => {
|
||||
exports.generateLyrics = onCall(LYRICS_GENERATION_OPTIONS, async ({ auth = {}, data = {} }) => {
|
||||
try {
|
||||
const {
|
||||
lang: rawLang,
|
||||
@@ -204,9 +213,8 @@ exports.generateLyrics = onCall(LYRICS_FUNCTION_OPTIONS, async ({ auth = {}, dat
|
||||
}
|
||||
} catch (moderationError) {
|
||||
if (moderationError instanceof HttpsError) throw moderationError
|
||||
console.error('⚠️ Moderation check error (fail open)', moderationError)
|
||||
// On continue si l'appel modération fail (fail open) ou on throw (fail closed) selon ta politique.
|
||||
// Ici je fail closed par sécurité pour une app publique.
|
||||
console.error('⚠️ Moderation check error (fail closed)', moderationError)
|
||||
// En cas d'indisponibilité du pré-contrôle, on bloque la génération par sécurité.
|
||||
throw new HttpsError('internal', 'Vérification de sécurité indisponible.')
|
||||
}
|
||||
|
||||
@@ -364,7 +372,7 @@ const softenModerationDecision = (rawResult = {}) => {
|
||||
}
|
||||
}
|
||||
|
||||
exports.analyseLyricsToxicity = onCall(LYRICS_FUNCTION_OPTIONS, async ({ data = {} }) => {
|
||||
exports.analyseLyricsToxicity = onCall(LYRICS_MODERATION_OPTIONS, async ({ data = {} }) => {
|
||||
try {
|
||||
const requestSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
@@ -440,7 +448,7 @@ async function getSunoTimestamps(projectId) {
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${SUNO_API_KEY.value()}`,
|
||||
Authorization: `Bearer ${SUNO_API_KEY.value().trim()}`,
|
||||
},
|
||||
timeout: 30000,
|
||||
}
|
||||
|
||||
+105
-8
@@ -2,7 +2,7 @@ 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 logger = require('firebase-functions/logger')
|
||||
const { pipeline } = require('stream/promises')
|
||||
const { randomUUID } = require('crypto')
|
||||
const { ALERT_TYPE, refList } = require('../index')
|
||||
@@ -516,8 +516,8 @@ exports.generateMusic = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} }
|
||||
// 3. Détection du genre vocal pour le param vocalGender (optimisation v3.5)
|
||||
// On regarde si on trouve 'male' ou 'female' dans les tags générés
|
||||
let vGender = null
|
||||
if (optimizedStyle.includes('Female')) vGender = 'female'
|
||||
else if (optimizedStyle.includes('Male')) vGender = 'male'
|
||||
if (optimizedStyle.includes('Female')) vGender = 'f'
|
||||
else if (optimizedStyle.includes('Male')) vGender = 'm'
|
||||
|
||||
console.log('🎵 [Suno Optimisation] Result:', {
|
||||
style: optimizedStyle,
|
||||
@@ -542,14 +542,36 @@ exports.generateMusic = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} }
|
||||
const response = await axios.post(`${SUNO_API_BASE}${SUNO_API_PATH}`, payload, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${SUNO_API_KEY.value()}`,
|
||||
Authorization: `Bearer ${SUNO_API_KEY.value().trim()}`,
|
||||
},
|
||||
})
|
||||
|
||||
const parsed = response.data
|
||||
if (parsed?.code !== 200) {
|
||||
throw new Error(parsed?.msg || 'Suno a refusé la demande de génération.')
|
||||
}
|
||||
const taskId = sanitizeField(parsed?.data?.taskId)
|
||||
|
||||
if (!taskId) {
|
||||
throw new Error("Suno n'a pas retourné d'identifiant de génération.")
|
||||
}
|
||||
|
||||
const projectId = sanitizeField(data?.projectId)
|
||||
if (projectId) {
|
||||
await refList.projects.doc(projectId).set(
|
||||
{
|
||||
sunoTaskId: taskId,
|
||||
musicStatus: 'GENERATING',
|
||||
musicError: FieldValue.delete(),
|
||||
generationStartAt: FieldValue.serverTimestamp(),
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true }
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
success: !!parsed?.data?.taskId,
|
||||
success: true,
|
||||
request: {
|
||||
model: payload.model,
|
||||
title: safeTitle,
|
||||
@@ -570,19 +592,94 @@ exports.generateMusic = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} }
|
||||
}
|
||||
})
|
||||
|
||||
// GET STATUS (inchangé mais inclus pour complétude)
|
||||
const reconcileSunoStatus = async ({ taskId, statusData }) => {
|
||||
const status = statusData?.status
|
||||
const failedStatuses = new Set([
|
||||
'CREATE_TASK_FAILED',
|
||||
'GENERATE_AUDIO_FAILED',
|
||||
'CALLBACK_EXCEPTION',
|
||||
'SENSITIVE_WORD_ERROR',
|
||||
])
|
||||
|
||||
if (status === 'SUCCESS') {
|
||||
const tracks = Array.isArray(statusData?.response?.sunoData)
|
||||
? statusData.response.sunoData
|
||||
: []
|
||||
const audioUrls = tracks
|
||||
.map((track) => track?.audioUrl)
|
||||
.filter((url) => typeof url === 'string' && url.trim())
|
||||
.slice(0, 2)
|
||||
|
||||
if (!audioUrls.length) throw new Error('SUNO_AUDIO_URLS_MISSING')
|
||||
|
||||
const { projectId, projectData, projectRef } = await fetchProjectByTaskId(taskId)
|
||||
const existingUrls = sanitizeMusicUrls(projectData?.musicUrls)
|
||||
if (projectData?.musicStatus === 'GENERATED' && existingUrls.length) {
|
||||
return { reconciled: true, projectId, musicUrls: existingUrls }
|
||||
}
|
||||
|
||||
const { userId, projectTitle } = formatProjectMeta(projectData)
|
||||
const storedUrls = await saveTracksToStorage(audioUrls, {
|
||||
userId,
|
||||
projectId,
|
||||
taskId,
|
||||
})
|
||||
if (!storedUrls.length) throw new Error('SUNO_AUDIO_STORAGE_FAILED')
|
||||
|
||||
const musicUrls = await mergeMusicUrls(projectRef, storedUrls)
|
||||
if (userId) {
|
||||
try {
|
||||
await sendNotification({
|
||||
sender: 'SYSTEM',
|
||||
receiver: userId,
|
||||
receiverCollection: 'users',
|
||||
title: 'Musique prête',
|
||||
message: `Ta musique pour "${projectTitle}" est prête.`,
|
||||
data: {
|
||||
type: ALERT_TYPE?.MUSIC_GENERATION_SUCCESS,
|
||||
projectId,
|
||||
projectTitle,
|
||||
musicUrls,
|
||||
taskId,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Music status notification failed', error)
|
||||
}
|
||||
}
|
||||
return { reconciled: true, projectId, musicUrls }
|
||||
}
|
||||
|
||||
if (failedStatuses.has(status)) {
|
||||
const { projectId } = await fetchProjectByTaskId(taskId)
|
||||
return {
|
||||
reconciled: false,
|
||||
failed: true,
|
||||
projectId,
|
||||
musicUrls: [],
|
||||
errorMessage: statusData?.errorMessage || 'La génération Suno a échoué.',
|
||||
}
|
||||
}
|
||||
|
||||
return { reconciled: false, failed: false, projectId: null, musicUrls: [] }
|
||||
}
|
||||
|
||||
// GET STATUS — secours lorsque le callback Suno n'arrive pas.
|
||||
exports.getSunoStatus = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} }) => {
|
||||
const { taskId } = data
|
||||
if (!taskId) throw new Error('TaskId manquant')
|
||||
try {
|
||||
const response = await axios.get(`${SUNO_API_BASE}${SUNO_STATUS_PATH}?taskId=${taskId}`, {
|
||||
headers: { Authorization: `Bearer ${SUNO_API_KEY.value()}` },
|
||||
headers: { Authorization: `Bearer ${SUNO_API_KEY.value().trim()}` },
|
||||
timeout: 30000,
|
||||
})
|
||||
const statusData = response.data?.data
|
||||
const reconciliation = await reconcileSunoStatus({ taskId, statusData })
|
||||
return {
|
||||
success: true,
|
||||
taskId,
|
||||
data: response.data?.data || response.data,
|
||||
status: statusData?.status || null,
|
||||
...reconciliation,
|
||||
}
|
||||
} catch (error) {
|
||||
if (taskId.includes('test'))
|
||||
|
||||
@@ -5,7 +5,7 @@ const { refList } = require('../index')
|
||||
const { ORDER_TYPES, createOrderDocument } = require('./helpers/orders')
|
||||
const { deleteFolder } = require('../helpers/firebase')
|
||||
const { Resend } = require('resend')
|
||||
const { welcomeTemplate } = require('../helpers/email')
|
||||
const { welcomeEmailSubject, welcomeTemplate } = require('../helpers/email')
|
||||
const { RESEND_API_KEY } = require('../config/secrets')
|
||||
const { onRequest } = require('firebase-functions/https')
|
||||
|
||||
@@ -19,7 +19,6 @@ const getResendClient = () => {
|
||||
}
|
||||
|
||||
const WELCOME_EMAIL_FROM = 'MusicLand <musicland@musicland.ai>'
|
||||
const WELCOME_EMAIL_SUBJECT = 'Bienvenue sur MusicLand'
|
||||
const USER_CREATED_OPTIONS = {
|
||||
document: 'users/{userID}',
|
||||
secrets: [RESEND_API_KEY],
|
||||
@@ -33,12 +32,12 @@ exports.testWelcomMail = onRequest({ secrets: [RESEND_API_KEY] }, async (req, re
|
||||
try {
|
||||
const targetEmail = req.query.email || 'tdtomthomas@gmail.com'
|
||||
const firstName = req.query.firstName || 'Toto'
|
||||
const lastName = req.query.lastName || 'Test'
|
||||
const preferredLanguage = req.query.preferredLanguage === 'en' ? 'en' : 'fr'
|
||||
const { data, error } = await getResendClient().emails.send({
|
||||
from: WELCOME_EMAIL_FROM,
|
||||
to: [targetEmail],
|
||||
subject: WELCOME_EMAIL_SUBJECT,
|
||||
html: welcomeTemplate({ firstName, lastName }),
|
||||
subject: welcomeEmailSubject({ firstName, preferredLanguage }),
|
||||
html: welcomeTemplate({ firstName, preferredLanguage }),
|
||||
})
|
||||
if (error) {
|
||||
console.log('Failed to send welcome email:', error)
|
||||
@@ -54,7 +53,7 @@ exports.testWelcomMail = onRequest({ secrets: [RESEND_API_KEY] }, async (req, re
|
||||
|
||||
exports.onUserCreated = onDocumentCreated(USER_CREATED_OPTIONS, async (event) => {
|
||||
try {
|
||||
const { email = '', firstName = '', lastName = '' } = event?.data?.data() || {}
|
||||
const { email = '', firstName = '', preferredLanguage = 'fr' } = event?.data?.data() || {}
|
||||
|
||||
const userId = event?.params?.userID
|
||||
|
||||
@@ -79,8 +78,8 @@ exports.onUserCreated = onDocumentCreated(USER_CREATED_OPTIONS, async (event) =>
|
||||
const { data, error } = await getResendClient().emails.send({
|
||||
from: WELCOME_EMAIL_FROM,
|
||||
to: [email],
|
||||
subject: WELCOME_EMAIL_SUBJECT,
|
||||
html: welcomeTemplate({ firstName, lastName }),
|
||||
subject: welcomeEmailSubject({ firstName, preferredLanguage }),
|
||||
html: welcomeTemplate({ firstName, preferredLanguage }),
|
||||
})
|
||||
if (error) {
|
||||
console.log('Failed to send welcome email:', error)
|
||||
|
||||
Reference in New Issue
Block a user