import { createClient } from '@supabase/supabase-js'; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL || "https://supabase-ferrovia.mrpatator.fr"; const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || process.env.SUPABASE_ANON_KEY || "sb_publishable_jSS9wIDj3BWGBpxtv5LwGQ_1P_KBV_N"; const supabase = createClient(supabaseUrl, supabaseKey); function checkIfHoraireRunsOnDate(horaire, date = new Date()) { const yyyy = date.getFullYear(); const mm = String(date.getMonth() + 1).padStart(2, '0'); const dd = String(date.getDate()).padStart(2, '0'); const dateStr = `${yyyy}-${mm}-${dd}`; // 1. Jours personnalisés (override) let joursPerso = []; try { joursPerso = typeof horaire.jours_personnalises === 'string' ? JSON.parse(horaire.jours_personnalises) : (horaire.jours_personnalises || []); } catch(e) {} const matchPerso = joursPerso.find(jp => jp.date === dateStr); if (matchPerso) return matchPerso.circule; // 2. Jours non circulation (override) let joursNonCirc = []; try { joursNonCirc = typeof horaire.jours_non_circulation === 'string' ? JSON.parse(horaire.jours_non_circulation) : (horaire.jours_non_circulation || []); } catch(e) {} if (joursNonCirc.includes(dateStr)) return false; // 3. Jours de la semaine const day = date.getDay(); // 0 = Dimanche, 1 = Lundi, ... switch(day) { case 0: return !!horaire.circule_dimanche; case 1: return !!horaire.circule_lundi; case 2: return !!horaire.circule_mardi; case 3: return !!horaire.circule_mercredi; case 4: return !!horaire.circule_jeudi; case 5: return !!horaire.circule_vendredi; case 6: return !!horaire.circule_samedi; } return false; } function getTrainDetailsForGare(horaire, gareId, type) { // 1. Rechercher en priorité dans la liste gares_desservies const stops = horaire.gares_desservies || []; const stop = stops.find(s => s.gare_id === gareId); if (stop) { if (type === 'depart') { return { time: stop.heure_depart || stop.heure_arrivee || '', quai: stop.quai_reel || stop.quai || '' }; } else { return { time: stop.heure_arrivee || stop.heure_depart || '', quai: stop.quai_reel || stop.quai || '' }; } } // 2. Fallback direct sur l'objet horaire if (type === 'depart' && horaire.gare_depart_id === gareId) { return { time: horaire.heure_depart || '', quai: horaire.quai_reel || horaire.quai || '' }; } if (type === 'arrivee' && horaire.gare_arrivee_id === gareId) { return { time: horaire.heure_arrivee || '', quai: horaire.quai_reel || horaire.quai || '' }; } return { time: type === 'depart' ? horaire.heure_depart : horaire.heure_arrivee, quai: horaire.quai_reel || horaire.quai || '' }; } function generateNomFichierAnnonce(typeAnnonce, ext = 'mp3') { const date = new Date().toISOString().slice(0, 10); const shortId = Math.random().toString(36).slice(2, 8); return `${typeAnnonce}_${date}_${shortId}.${ext}`; } function concatenateAudio(buffers) { if (buffers.length === 0) return { payload: null, format: 'mp3' }; if (buffers.length === 1) { const isWav = buffers[0].subarray(0, 4).toString('ascii') === 'RIFF'; return { payload: buffers[0], format: isWav ? 'wav' : 'mp3' }; } const isWav = buffers[0].subarray(0, 4).toString('ascii') === 'RIFF'; if (isWav) { // Logic for WAV concatenation let totalPcmLength = 0; for (let i = 0; i < buffers.length; i++) { const hasRiff = buffers[i].subarray(0, 4).toString('ascii') === 'RIFF'; totalPcmLength += hasRiff ? (buffers[i].length - 44) : buffers[i].length; } const header = Buffer.alloc(44); buffers[0].copy(header, 0, 0, 44); const totalFileSize = 44 + totalPcmLength; header.writeUInt32LE(totalFileSize - 8, 4); header.writeUInt32LE(totalPcmLength, 40); const combinedBuffer = Buffer.alloc(totalFileSize); header.copy(combinedBuffer, 0, 0, 44); let offset = 44; for (let i = 0; i < buffers.length; i++) { const hasRiff = buffers[i].subarray(0, 4).toString('ascii') === 'RIFF'; const dataPayload = hasRiff ? buffers[i].subarray(44) : buffers[i]; dataPayload.copy(combinedBuffer, offset); offset += dataPayload.length; } return { payload: combinedBuffer, format: 'wav' }; } else { // Logic for MP3 concatenation (streamable, direct binary append) return { payload: Buffer.concat(buffers), format: 'mp3' }; } } export default async function handler(req, res) { // Authentification double : CRON_SECRET (Vercel Cron) ou Supabase session token (browser) let isAuthorized = false; const authHeader = req.headers.authorization; if (process.env.CRON_SECRET && authHeader === `Bearer ${process.env.CRON_SECRET}`) { isAuthorized = true; } else if (authHeader && authHeader.startsWith('Bearer ')) { const token = authHeader.replace('Bearer ', ''); try { const { data: { user } } = await supabase.auth.getUser(token); if (user) { isAuthorized = true; } } catch (e) { console.error('[Cron] User token auth failed:', e); } } else if (!process.env.CRON_SECRET) { // En dev local, si CRON_SECRET n'est pas configuré isAuthorized = true; } if (!isAuthorized) { return res.status(401).end('Unauthorized'); } try { const now = new Date(); const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0); const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999); const regenerate = req.query.regenerate === 'true'; console.log(`[CronAnnonces] Running at ${now.toISOString()} | Regenerate: ${regenerate}`); // --- ETAPE 1 : RECUPERATION DES DONNEES --- const [ { data: gares }, { data: horaires }, { data: templates }, { data: mots }, { data: parametres }, { data: todayAnnonces } ] = await Promise.all([ supabase.from('gares').select('*'), supabase.from('horaires').select('*'), supabase.from('annonces_templates').select('*').eq('actif', true), supabase.from('banque_mots').select('*').eq('actif', true), supabase.from('parametres').select('*'), supabase.from('annonces_enregistrees') .select('*') .gte('date_diffusion', todayStart.toISOString()) .lte('date_diffusion', todayEnd.toISOString()) ]); // Si régénération demandée, supprimer de la BDD et du Storage toutes les annonces d'aujourd'hui const regenerateParam = (parametres || []).find(p => p.cle === 'global_regenerate_requested'); const isGlobalRegenerate = regenerate || (regenerateParam && regenerateParam.valeur === 'true'); if (isGlobalRegenerate && todayAnnonces && todayAnnonces.length > 0) { console.log(`[CronAnnonces] Deleting ${todayAnnonces.length} existing announcements for today...`); const pathsToDelete = todayAnnonces.map(a => a.storage_path).filter(Boolean); if (pathsToDelete.length > 0) { await supabase.storage.from('banque-annonces').remove(pathsToDelete); } await supabase .from('annonces_enregistrees') .delete() .in('id', todayAnnonces.map(a => a.id)); // Vider la liste pour forcer la régénération todayAnnonces.length = 0; } // Réinitialiser le drapeau de régénération en base de données si nécessaire if (regenerateParam && regenerateParam.valeur === 'true') { await supabase .from('parametres') .update({ valeur: 'false', updated_at: now.toISOString() }) .eq('cle', 'global_regenerate_requested'); console.log('[CronAnnonces] Reset global_regenerate_requested parameter in DB.'); } const settingsByGare = {}; const dijonGare = (gares || []).find(g => g.nom && g.nom.toLowerCase().trim() === 'dijon'); const dijonSettingsParam = dijonGare ? (parametres || []).find(p => p.cle === `diffusion_settings_${dijonGare.id}`) : null; const dijonSettings = dijonSettingsParam ? dijonSettingsParam.valeur : null; (parametres || []).forEach(p => { if (p.cle.startsWith('diffusion_settings_')) { const gareId = p.cle.replace('diffusion_settings_', ''); settingsByGare[gareId] = p.valeur; } }); const runningHorairesGlobal = (horaires || []).filter(h => checkIfHoraireRunsOnDate(h, now)); // --- ETAPE 2 : COMPTER LE NOMBRE TOTAL DE TACHES A GENERER --- let totalTasksCount = 0; const garesToProcess = (gares || []).filter(gare => { let rule = settingsByGare[gare.id]; if (!rule && dijonSettings && dijonGare && dijonGare.id !== gare.id) { rule = dijonSettings; } return !!rule; }); const tasksToExecute = []; for (const gare of garesToProcess) { let rule = settingsByGare[gare.id]; if (!rule && dijonSettings && dijonGare && dijonGare.id !== gare.id) { rule = dijonSettings; } // Filtrer et trier les horaires pour cette gare const runningHorairesForGare = runningHorairesGlobal .filter(h => { if (h.gare_depart_id === gare.id || h.gare_arrivee_id === gare.id) return true; const stops = h.gares_desservies || []; return stops.some(s => s.gare_id === gare.id); }) .sort((a, b) => { const timeA = getTrainDetailsForGare(a, gare.id, 'depart').time || getTrainDetailsForGare(a, gare.id, 'arrivee').time || '00:00'; const timeB = getTrainDetailsForGare(b, gare.id, 'depart').time || getTrainDetailsForGare(b, gare.id, 'arrivee').time || '00:00'; return timeA.localeCompare(timeB); }); for (const horaire of runningHorairesForGare) { const isDepart = horaire.gare_depart_id === gare.id || (horaire.gares_desservies || []).some(s => s.gare_id === gare.id && s.heure_depart); const isArrivee = horaire.gare_arrivee_id === gare.id || (horaire.gares_desservies || []).some(s => s.gare_id === gare.id && s.heure_arrivee); if (isDepart) { const typeRule = rule.depart; if (typeRule && typeRule.templateId) { const template = (templates || []).find(t => t.id === typeRule.templateId); if (template) { // Vérifier si cette annonce existe déjà const exists = (todayAnnonces || []).some(a => a.gare_id === gare.id && a.type_annonce === 'depart' && a.variables_values?.numero_train === horaire.numero_train ); if (!exists) { tasksToExecute.push({ gare, typeAnnonce: 'depart', typeRule, template, horaire }); } } } } if (isArrivee) { const typeRule = rule.arrivee; if (typeRule && typeRule.templateId) { const template = (templates || []).find(t => t.id === typeRule.templateId); if (template) { // Vérifier si cette annonce existe déjà const exists = (todayAnnonces || []).some(a => a.gare_id === gare.id && a.type_annonce === 'arrivee' && a.variables_values?.numero_train === horaire.numero_train ); if (!exists) { tasksToExecute.push({ gare, typeAnnonce: 'arrivee', typeRule, template, horaire }); } } } } } } totalTasksCount = tasksToExecute.length; console.log(`[CronAnnonces] Total tasks to generate: ${totalTasksCount}`); // Publier l'état initial await supabase.from('parametres').upsert({ cle: 'global_generation_progress', valeur: JSON.stringify({ status: totalTasksCount > 0 ? 'running' : 'done', current: 0, total: totalTasksCount, progress: totalTasksCount > 0 ? 0 : 100, message: totalTasksCount > 0 ? `Préparation de la génération... (0/${totalTasksCount})` : 'Toutes les annonces sont déjà générées.' }), updated_at: new Date().toISOString() }, { onConflict: 'cle' }); // Exécuter séquentiellement et publier la progression let currentTaskIndex = 0; for (const task of tasksToExecute) { const { gare, typeAnnonce, typeRule, template, horaire } = task; // Mettre à jour la progression avant chaque tâche const pct = Math.round((currentTaskIndex / totalTasksCount) * 100); await supabase.from('parametres').upsert({ cle: 'global_generation_progress', valeur: JSON.stringify({ status: 'running', current: currentTaskIndex, total: totalTasksCount, progress: pct, message: `Génération : ${gare.nom} - Train ${horaire.numero_train || ''} (${typeAnnonce === 'depart' ? 'Départ' : 'Arrivée'}) (${currentTaskIndex + 1}/${totalTasksCount})` }), updated_at: new Date().toISOString() }, { onConflict: 'cle' }); try { await generateAnnonce(gare, typeAnnonce, typeRule, template, horaire); } catch (err) { console.error(`[Cron] Error generating task for ${gare.nom}:`, err); } currentTaskIndex++; } // Publier l'état final await supabase.from('parametres').upsert({ cle: 'global_generation_progress', valeur: JSON.stringify({ status: 'done', current: totalTasksCount, total: totalTasksCount, progress: 100, message: totalTasksCount > 0 ? 'Génération globale terminée avec succès !' : 'Toutes les annonces sont déjà générées.' }), updated_at: new Date().toISOString() }, { onConflict: 'cle' }); // Fonction interne de génération séquentielle async function generateAnnonce(gare, typeAnnonce, typeRule, template, horaire) { const { time: trainTime, quai: trainQuai } = getTrainDetailsForGare(horaire, gare.id, typeAnnonce); if (!trainTime) return; const [hStr, mStr] = trainTime.split(':'); const trainHour = parseInt(hStr, 10); const trainMin = parseInt(mStr, 10); const scheduledDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), trainHour, trainMin); scheduledDate.setMinutes(scheduledDate.getMinutes() - parseInt(typeRule.offset || 0, 10)); const isPast = scheduledDate <= now; const direction = typeAnnonce === 'depart' ? `à destination de ${horaire.gare_arrivee_nom}` : `en provenance de ${horaire.gare_depart_nom}`; const name = `Auto: ${horaire.numero_train} (${typeAnnonce}) ${direction} - ${trainTime}`; const stopsList = (horaire.snapshot_gares_desservies || horaire.gares_desservies || []); const intermediateStops = stopsList .filter(g => !g.is_depart && !g.is_arrivee) .map(g => g.gare_nom) .join(', '); const resolvedVars = { numero_train: horaire.numero_train || '', destination: horaire.gare_arrivee_nom || '', provenance: horaire.gare_depart_nom || '', heure_depart: horaire.heure_depart || '', heure_arrivee: horaire.heure_arrivee || '', heure: trainTime, quai: trainQuai, voie: trainQuai, arrets_intermediaires: intermediateStops, afficheurs: typeRule.afficheurs || [], type_train: horaire.type_train || '' }; // Construire le texte TTS let ttsText = template.tts_texte || ''; if (!ttsText) { ttsText = (template.sequence || []) .map(s => { if (s.type === 'mot') return s.label; if (s.type === 'variable') return resolvedVars[s.variable] || ''; return ''; }) .filter(Boolean) .join(' '); } else { Object.entries(resolvedVars).forEach(([key, val]) => { ttsText = ttsText.replaceAll(`{${key}}`, Array.isArray(val) ? val.join(', ') : val); }); } resolvedVars.tts_texte = ttsText; const mode = typeRule.mode || ((template.nom?.toLowerCase().includes('sive') || template.description?.toLowerCase().includes('sive')) ? 'concatenation' : 'tts'); let audioPayload = Buffer.from('TTS_ONLY'); let format = 'mp3'; if (mode === 'concatenation') { const targetGareSousType = template.nom?.toLowerCase().includes('sive') ? 'sive' : 'quai'; const buffers = []; for (let idxInSeq = 0; idxInSeq < (template.sequence || []).length; idxInSeq++) { const item = template.sequence[idxInSeq]; let motToPlay = null; if (item.type === 'mot') { motToPlay = mots.find(m => m.id === item.mot_id); } else if (item.type === 'variable') { const val = resolvedVars[item.variable]; if (val) { const normValue = String(val).toLowerCase().trim(); if (item.variable === 'destination' || item.variable === 'provenance') { const gMots = mots.filter(m => m.categorie === 'gare'); for (const m of gMots) { try { const parsed = JSON.parse(m.description); if (parsed.gare_nom?.toLowerCase().trim() === normValue || m.label?.toLowerCase().trim() === normValue) { if (parsed.sous_type === targetGareSousType) { motToPlay = m; break; } if (!motToPlay) motToPlay = m; } } catch (_) {} } } else if (item.variable === 'quai' || item.variable === 'voie') { const isLast = idxInSeq === template.sequence.length - 1; const targetQuaiSousType = isLast ? 'descendant' : 'montant'; const qMots = mots.filter(m => m.categorie === 'quai'); for (const m of qMots) { try { const parsed = JSON.parse(m.description); if (parsed.quai_nom?.toLowerCase().trim() === normValue || m.label?.toLowerCase().trim() === normValue) { if (parsed.sous_type === targetQuaiSousType) { motToPlay = m; break; } if (!motToPlay) motToPlay = m; } } catch (_) {} } } else if (item.variable === 'type_train') { const ttMots = mots.filter(m => m.categorie === 'type_train'); for (const m of ttMots) { try { const parsed = JSON.parse(m.description); if (parsed.type_train_nom?.toLowerCase().trim() === normValue || m.label?.toLowerCase().trim() === normValue) { motToPlay = m; break; } } catch (_) {} } } if (!motToPlay) { motToPlay = mots.find(m => m.label?.toLowerCase().trim() === normValue); } } } if (motToPlay) { try { const { data: fileData, error: dlError } = await supabase.storage.from('banque-annonces').download(motToPlay.storage_path); if (!dlError && fileData) { const arrayBuffer = await fileData.arrayBuffer(); buffers.push(Buffer.from(arrayBuffer)); } } catch (e) { console.warn(`[Cron] download failed for ${motToPlay.storage_path}`, e); } } } if (buffers.length > 0) { const resAudio = concatenateAudio(buffers); if (resAudio.payload) { audioPayload = resAudio.payload; format = resAudio.format; } } } // Upload to storage const ext = format; const nomFichier = generateNomFichierAnnonce(typeAnnonce, ext); const storagePath = `annonces/${nomFichier}`; const { error: uploadError } = await supabase.storage .from('banque-annonces') .upload(storagePath, audioPayload, { cacheControl: '3600', upsert: false, contentType: ext === 'wav' ? 'audio/wav' : 'audio/mpeg' }); if (uploadError) { console.error('[Cron] Upload error:', uploadError); return; } // Save in BDD const { error: dbError } = await supabase .from('annonces_enregistrees') .insert({ nom: name, type_annonce: typeAnnonce, template_id: template.id, storage_path: storagePath, nom_fichier: nomFichier, variables_values: resolvedVars, mode_generation: mode, duree_ms: null, taille_bytes: audioPayload.length, gare_id: gare.id, notes: `Programmed Auto Cron: ${typeAnnonce} | Afficheurs: ${(resolvedVars.afficheurs || []).join(', ')}`, date_diffusion: scheduledDate.toISOString(), diffuse: isPast }); if (dbError) { console.error('[Cron] DB error inserting annonce:', dbError); await supabase.storage.from('banque-annonces').remove([storagePath]); } else { console.log(`[Cron] Generated and saved annonce for train ${horaire.numero_train} at ${gare.nom}`); } } // --- ETAPE 3 : DIFFUSION DES ANNONCES DUES --- const { data: dueAnnonces, error: dueError } = await supabase .from('annonces_enregistrees') .select('*') .eq('diffuse', false) .lte('date_diffusion', now.toISOString()); if (dueError) { console.error('[Cron] dueAnnonces fetch error:', dueError); } else if (dueAnnonces && dueAnnonces.length > 0) { console.log(`[Cron] Found ${dueAnnonces.length} due announcements to broadcast.`); const broadcastPromises = dueAnnonces.map(async (annonce) => { try { // 1. Diffuser sur la table BDD en direct pour les afficheurs const { error: diffError } = await supabase .from('annonces_diffusions') .insert([ { annonce_id: annonce.id, gare_id: annonce.gare_id, audio_path: annonce.mode_generation === 'tts' ? null : annonce.storage_path, tts_texte: annonce.mode_generation === 'tts' ? (annonce.variables_values?.tts_texte || 'Attention, train en approche.') : null } ]); if (diffError) { console.error(`[Cron] Error broadcasting annonce ${annonce.id}:`, diffError); } else { // 2. Mettre à jour l'annonce dans la base comme diffusée await supabase .from('annonces_enregistrees') .update({ diffuse: true, updated_at: now.toISOString() }) .eq('id', annonce.id); console.log(`[Cron] Broadcasted and updated annonce ${annonce.id} (nom: ${annonce.nom})`); } } catch (broadcastErr) { console.error(`[Cron] Broadcast error for annonce ${annonce.id}:`, broadcastErr); } }); await Promise.all(broadcastPromises); } return res.status(200).json({ success: true, message: "Génération et diffusion crons exécutées." }); } catch (err) { console.error('[Cron] Global exception:', err); return res.status(500).json({ error: err.message }); } }