Files
Socialflow/server/routes/reels.ts
T
Claude 94e961d0f1 perf(api): alléger les requêtes du calendrier, des stats et de la médiathèque
Trois défauts se cumulaient : des réponses bien plus larges que nécessaire,
aucun index en base, et des boucles de requêtes là où une seule suffisait.

Réponses
- /api/scheduled-posts renvoyait la ligne `social_pages` entière, jetons
  d'accès Facebook et TikTok compris, jusque dans le navigateur. Seules les
  colonnes réellement affichées sont désormais sélectionnées.
- Le filtre de dates est poussé en SQL. Il était appliqué en JavaScript après
  avoir chargé tout l'historique — que le client ne bornait jamais. Le
  calendrier demande maintenant les six semaines affichées, le sélecteur de
  date les créneaux à venir, le bloc « publications récentes » ses 90 jours.
- /api/media accepte `limit` et `offset` (sans eux, réponse inchangée : des
  écrans doivent retrouver un média par son identifiant). La médiathèque
  pagine côté serveur au lieu de tout charger pour afficher cinq vignettes, et
  l'écran de création ne demande que les douze médias qu'il propose. Le total
  voyage dans l'en-tête X-Total-Count pour garder le compteur juste.

Index
- server/migrate.ts est le seul script exécuté au démarrage et n'en créait
  aucun ; ceux de migration-docker.sql ne servaient à rien, ce fichier n'étant
  monté nulle part. Postgres n'indexant pas les clés étrangères, le calendrier,
  l'historique et le scheduler parcouraient les tables entières. S'y ajoutent
  deux index partiels pour les boucles de fond : publications en attente et
  suivi asynchrone TikTok.

Requêtes
- /api/stats lisait quatre tables entières pour en compter les lignes : c'est
  désormais quatre COUNT filtrés.
- Les vues d'administration listaient les utilisateurs puis interrogeaient les
  pages de chacun ; une seule requête les remplace.
- getPostWithMedia et l'API externe chargeaient les médias un par un.
- /api/reels/ongoing chargeait tous les posts de l'utilisateur pour n'en garder
  que ceux en cours de génération, toutes les trois secondes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qns6Gcpd95bCHWUxySh4Ew
2026-09-10 06:39:16 +00:00

938 lines
33 KiB
TypeScript

/**
* Routes API pour les Reels Facebook et la musique Jamendo
*/
import { Router, Request, Response } from 'express';
import type { User, Media, SocialPage } from '@shared/schema';
import { storage } from '../storage';
import { ffmpegService } from '../services/ffmpeg';
import { facebookService } from '../services/facebook';
import { tiktokService } from '../services/tiktok';
import { createVideoThumbnail } from '../services/thumbnail';
import { minioService as cloudinaryService, buildMinioUrl, resolveInternalUrl } from '../services/minio';
import { openRouterService, describeGenerationError } from '../services/openrouter';
import { db } from '../db';
import { cloudinaryConfig } from '@shared/schema';
import { eq } from 'drizzle-orm';
import { ttsSyncService } from '../services/ttsSync';
/** Piste musicale telle qu'attendue par le client. */
interface MusicTrack {
id: string;
title: string;
artist: string;
albumName: string;
duration: number;
previewUrl: string;
downloadUrl: string;
imageUrl: string;
license: string;
}
export const reelsRouter = Router();
// ============================================
// ROUTES MUSIQUE JAMENDO
// ============================================
/**
* Recherche de musiques par durée
* GET /api/music/search?minDuration=10&maxDuration=60&genre=pop&limit=10
*/
reelsRouter.get('/music/search', async (req: Request, res: Response) => {
try {
const {
minDuration = '10',
maxDuration = '120',
genre,
limit = '10',
offset = '0',
search,
} = req.query;
res.json({
tracks: [],
pagination: {
offset: parseInt(offset as string),
limit: parseInt(limit as string),
hasMore: false,
},
});
} catch (error) {
console.error('❌ Error searching music:', error);
res.status(500).json({ error: 'Erreur lors de la recherche de musique' });
}
});
/**
* Voir plus de musiques (pagination)
* GET /api/music/more?offset=10&minDuration=10&maxDuration=60
*/
reelsRouter.get('/music/more', async (req: Request, res: Response) => {
try {
const {
minDuration = '10',
maxDuration = '120',
genre,
limit = '10',
offset = '0',
search,
} = req.query;
res.json({
tracks: [],
pagination: {
offset: parseInt(offset as string),
limit: parseInt(limit as string),
hasMore: false,
},
});
} catch (error) {
console.error('❌ Error loading more music:', error);
res.status(500).json({ error: 'Erreur lors du chargement des musiques' });
}
});
/**
* Obtenir les tracks populaires
* GET /api/music/popular
*/
reelsRouter.get('/music/popular', async (req: Request, res: Response) => {
try {
res.json({ tracks: [] });
} catch (error) {
console.error('❌ Error fetching popular music:', error);
res.status(500).json({ error: 'Erreur lors de la récupération des musiques populaires' });
}
});
// ============================================
// ROUTES FAVORIS MUSIQUE
// ============================================
/**
* Obtenir les favoris de l'utilisateur
* GET /api/music/favorites
*/
reelsRouter.get('/music/favorites', async (req: Request, res: Response) => {
try {
const user = req.user as User;
const favorites = await storage.getMusicFavorites(user.id);
// Convertir en format MusicTrack pour le client
const tracks: MusicTrack[] = favorites.map(f => ({
id: f.trackId,
title: f.title,
artist: f.artist,
albumName: f.albumName || '',
duration: f.duration,
previewUrl: f.previewUrl,
downloadUrl: f.downloadUrl,
imageUrl: f.imageUrl || '',
license: f.license || '',
}));
res.json({ tracks, isFavorites: true });
} catch (error) {
console.error('❌ Error fetching music favorites:', error);
res.status(500).json({ error: 'Erreur lors de la récupération des favoris' });
}
});
/**
* Vérifier si un track est en favori
* GET /api/music/favorites/check/:trackId
*/
reelsRouter.get('/music/favorites/check/:trackId', async (req: Request, res: Response) => {
try {
const user = req.user as User;
const { trackId } = req.params;
const isFavorite = await storage.isMusicFavorite(user.id, trackId);
res.json({ isFavorite });
} catch (error) {
console.error('❌ Error checking music favorite:', error);
res.status(500).json({ error: 'Erreur lors de la vérification du favori' });
}
});
/**
* Ajouter un favori
* POST /api/music/favorites
*/
reelsRouter.post('/music/favorites', async (req: Request, res: Response) => {
try {
const user = req.user as User;
const { trackId, title, artist, albumName, duration, previewUrl, downloadUrl, imageUrl, license } = req.body;
if (!trackId || !title) {
return res.status(400).json({ error: 'trackId et title requis' });
}
// Vérifier si déjà en favori
const alreadyFavorite = await storage.isMusicFavorite(user.id, trackId);
if (alreadyFavorite) {
return res.json({ success: true, message: 'Déjà en favori' });
}
await storage.addMusicFavorite({
userId: user.id,
trackId,
title,
artist: artist || '',
albumName: albumName || null,
duration: duration || 0,
previewUrl: previewUrl || '',
downloadUrl: downloadUrl || '',
imageUrl: imageUrl || null,
license: license || null,
});
res.json({ success: true });
} catch (error) {
console.error('❌ Error adding music favorite:', error);
res.status(500).json({ error: 'Erreur lors de l\'ajout du favori' });
}
});
/**
* Supprimer un favori
* DELETE /api/music/favorites/:trackId
*/
reelsRouter.delete('/music/favorites/:trackId', async (req: Request, res: Response) => {
try {
const user = req.user as User;
const { trackId } = req.params;
await storage.removeMusicFavorite(user.id, trackId);
res.json({ success: true });
} catch (error) {
console.error('❌ Error removing music favorite:', error);
res.status(500).json({ error: 'Erreur lors de la suppression du favori' });
}
});
// ============================================
// ROUTE DÉTAILS TRACK (après les routes /music/favorites et /music/popular)
// ============================================
/**
* Détails d'un track
* GET /api/music/:trackId
*/
reelsRouter.get('/music/:trackId', async (req: Request, res: Response) => {
try {
const { trackId } = req.params;
return res.status(404).json({ error: 'Musique non trouvée' });
} catch (error) {
console.error('❌ Error fetching music details:', error);
res.status(500).json({ error: 'Erreur lors de la récupération des détails' });
}
});
// ============================================
// ROUTES REELS
// ============================================
/**
* Générer des textes IA pour un Reel (utilise le même service que les posts)
* POST /api/reels/generate-text
*/
reelsRouter.post('/reels/generate-text', async (req: Request, res: Response) => {
try {
const user = req.user as User;
const { productInfo, model } = req.body;
if (!productInfo) {
return res.status(400).json({ error: 'Informations produit requises' });
}
// Convertir la chaîne de texte simple en objet ProductInfo
// Si l'utilisateur envoie une chaîne, on l'utilise comme description du produit
const productInfoObject = typeof productInfo === 'string'
? {
name: productInfo.trim(), // Utiliser le texte comme nom
description: productInfo.trim(), // Et comme description
}
: productInfo;
const generatedTexts = await openRouterService.generatePostText(productInfoObject, user.id, model);
// Sauvegarder la génération
await storage.createAiGeneration({
userId: user.id,
productInfo: productInfoObject,
generatedTexts,
});
res.json({ variants: generatedTexts });
} catch (error) {
console.error('❌ Error generating Reel text:', error);
const { status, message } = describeGenerationError(error);
res.status(status).json({ error: message });
}
});
/**
* Prévisualiser un Reel (traitement sans publication)
* POST /api/reels/preview
*/
reelsRouter.post('/reels/preview', async (req: Request, res: Response) => {
try {
const user = req.user as User;
const {
videoMediaId,
musicTrackId,
musicUrl,
overlayText,
ttsEnabled,
ttsVoice,
ttsEngine,
wordDuration = 0.6,
fontSize = 64,
musicVolume = 0.25,
drawText = true,
stabilize = false,
enableEndingEffect = true,
} = req.body;
// Récupérer le média vidéo
const media = await storage.getMediaById(videoMediaId);
if (!media) {
return res.status(404).json({ error: 'Vidéo non trouvée' });
}
if (media.type !== 'video') {
return res.status(400).json({ error: 'Le média doit être une vidéo' });
}
// Récupérer l'URL de la musique si trackId fourni
let finalMusicUrl = musicUrl;
if (musicTrackId && !musicUrl) {
if (musicTrackId.startsWith('internal_')) {
const internalId = musicTrackId.replace('internal_', '');
const track = await storage.getAudioTrack(internalId);
if (track) {
// Utiliser INTERNAL_APP_URL pour que le container ffmpeg (réseau Docker interne)
// puisse télécharger le fichier audio. APP_URL (HTTPS public) n'est pas accessible
// depuis le réseau internal Docker.
const internalBaseUrl = process.env.INTERNAL_APP_URL || process.env.APP_URL || 'http://localhost:5555';
finalMusicUrl = track.url.startsWith('http')
? track.url
: `${internalBaseUrl}${track.url}`;
console.log(`🎵 Audio URL for ffmpeg: ${finalMusicUrl}`);
}
} else {
}
}
let watermarkUrl: string | undefined = undefined;
try {
const config = await storage.getCloudinaryConfig();
if (config && config.logoPublicId) {
watermarkUrl = resolveInternalUrl(buildMinioUrl(config.cloudName, config.logoPublicId, config.publicUrl));
}
} catch (e) {
console.error('Error fetching watermark configuration', e);
}
// Get Gemini API key if using Gemini TTS
let geminiApiKey: string | undefined = undefined;
if (ttsEngine === "gemini") {
const appCfg = await storage.getAppConfig();
geminiApiKey = appCfg?.geminiApiKey ?? process.env.GEMINI_API_KEY ?? undefined;
}
const finalWordDuration = wordDuration;
// Traiter la vidéo via FFmpeg
const result = await ffmpegService.processReelFromUrl(resolveInternalUrl(media.originalUrl), {
text: overlayText,
musicUrl: finalMusicUrl,
ttsEnabled,
ttsVoice,
ttsEngine,
geminiApiKey,
wordDuration: finalWordDuration,
fontSize,
musicVolume,
drawText,
stabilize,
watermarkUrl,
enableEndingEffect,
});
if (!result.success) {
return res.status(500).json({ error: result.error || 'Erreur de traitement vidéo' });
}
// Retourner la vidéo en base64 pour prévisualisation
res.json({
success: true,
videoBase64: result.videoBase64,
duration: result.duration,
});
} catch (error) {
console.error('❌ Error previewing Reel:', error);
res.status(500).json({ error: 'Erreur lors de la prévisualisation du Reel' });
}
});
/**
* Prévisualiser la voix TTS
* POST /api/reels/tts-preview
*/
reelsRouter.post('/reels/tts-preview', async (req: Request, res: Response) => {
try {
const user = req.user as User;
const { text, ttsVoice, ttsEngine } = req.body;
if (!text) {
return res.status(400).json({ error: 'Texte requis' });
}
let geminiApiKey: string | undefined = undefined;
if (ttsEngine === "gemini") {
const appCfg = await storage.getAppConfig();
geminiApiKey = appCfg?.geminiApiKey ?? process.env.GEMINI_API_KEY ?? undefined;
}
const result = await ffmpegService.previewTTS(text, ttsVoice, ttsEngine, geminiApiKey);
if (!result.success) {
return res.status(500).json({ error: result.error || 'Erreur de génération TTS' });
}
res.json({ success: true, audioBase64: result.audioBase64 });
} catch (error) {
console.error('❌ Error generating TTS preview:', error);
res.status(500).json({ error: 'Erreur lors de la génération de la voix' });
}
});
/**
* Calculer la synchronisation texte/voix TTS
* POST /api/reels/sync-info
*/
reelsRouter.post('/reels/sync-info', async (req: Request, res: Response) => {
try {
const { text, ttsVoice, ttsEngine } = req.body;
if (!text || !ttsVoice) {
return res.status(400).json({ error: 'Texte et voix requis' });
}
const sync = await ttsSyncService.calculateSyncTiming(text, ttsVoice, ttsEngine);
res.json(sync);
} catch (error) {
console.error('❌ Error calculating sync:', error);
res.status(500).json({ error: 'Erreur de calcul de synchronisation' });
}
});
/**
* Traitement d'arrière-plan pour les Reels
* Gère le pipeline FFmpeg -> Cloudinary -> Facebook de manière asynchrone
*/
/**
* Vérifie la file d'attente et lance le prochain job si disponible
*/
async function checkQueueAndProcessNext() {
try {
const nextPost = await storage.getNextPendingReel();
if (nextPost) {
console.log(`📥 [Queue] Found pending job: ${nextPost.id}. Starting processing...`);
// Marquer comme processing immédiatement
await storage.updatePostGenerationStatus(nextPost.id, 'processing', 0);
// Reconstruire le payload depuis les données stockées (si stockées) ou les defaults
// Note: En mode "pending", on a perdu le body de la requête initiale car on ne stocke pas tout dans Post.
// Pour une solution robuste, il faudrait stocker les paramètres de génération dans une table 'reel_jobs'.
// ICI: HACK PROVISOIRE -> On suppose que les données sont stockées dans 'content' ou 'productInfo' mais ce n'est pas le cas.
// SOLUTION: On ne peut pas relancer processReelBackground sans les arguments (ttsVoice, music, etc).
//
// FIX: Pour le MVP, comme on n'a pas de table 'jobs', on va devoir stocker les paramètres requis dans 'productInfo' (jsonb) du Post
// lors de la création en mode 'pending'.
// Récupérer les paramètres stockés
const jobData = nextPost.productInfo as any;
if (!jobData || !jobData.videoMediaId) {
console.error(`❌ [Queue] Job ${nextPost.id} has no stored job data in productInfo.`);
await storage.updatePostGenerationStatus(nextPost.id, 'failed', 0, "Données de job manquantes");
return;
}
// Lancer le traitement
processReelBackground(nextPost.userId, nextPost.id, jobData)
.catch(err => console.error('🔥 [Queue] Unhandled error starting queued job:', err));
} else {
console.log('🏁 [Queue] No more pending jobs.');
}
} catch (error) {
console.error('❌ [Queue] Error checking queue:', error);
}
}
/**
* Traitement d'arrière-plan pour les Reels
* Gère le pipeline FFmpeg -> Cloudinary -> Facebook de manière asynchrone
*/
async function processReelBackground(
userId: string,
postId: string,
data: {
videoMediaId: string;
musicTrackId?: string;
musicUrl?: string;
overlayText?: string;
description?: string;
ttsEnabled?: boolean;
ttsVoice?: string;
ttsEngine?: string;
pageIds: string[];
scheduledFor?: string;
wordDuration?: number;
fontSize?: number;
musicVolume?: number;
drawText?: boolean;
stabilize?: boolean;
enableEndingEffect?: boolean;
},
storeName?: string
) {
const {
videoMediaId,
musicTrackId,
musicUrl,
overlayText,
description,
ttsEnabled,
ttsVoice,
ttsEngine,
pageIds,
scheduledFor,
wordDuration,
fontSize,
musicVolume,
drawText,
stabilize,
enableEndingEffect,
} = data;
console.log(`🔄 [Background] Starting processing for Post ${postId}`);
// Helper to update generation progress
const updateProgress = async (progress: number, status: string = 'processing') => {
try {
await storage.updatePostGenerationStatus(postId, status, progress);
} catch (e) {
console.error(`⚠️ [Background] Failed to update progress for ${postId}:`, e);
}
};
try {
await updateProgress(5);
// 1. Récupérer le média vidéo source
const media = await storage.getMediaById(videoMediaId);
if (!media || media.type !== 'video') {
throw new Error('Vidéo source introuvable ou invalide');
}
// 2. Récupérer l'URL de la musique
let finalMusicUrl = musicUrl;
if (musicTrackId && !musicUrl) {
if (musicTrackId.startsWith('internal_')) {
try {
const internalId = musicTrackId.replace('internal_', '');
const track = await storage.getAudioTrack(internalId);
if (track) {
const internalBaseUrl = process.env.INTERNAL_APP_URL || process.env.APP_URL || 'http://localhost:5555';
finalMusicUrl = track.url.startsWith('http')
? track.url
: `${internalBaseUrl}${track.url}`;
console.log(`🎵 [Background] Audio URL for ffmpeg: ${finalMusicUrl}`);
}
} catch (e) { console.error('Error fetching internal track', e); }
}
}
console.log('🎬 [Background] FFmpeg Processing:', {
postId,
videoUrl: media.originalUrl,
hasMusic: !!finalMusicUrl, // Log boolean to avoid long URL
hasText: !!overlayText
});
// 3. Traiter la vidéo via FFmpeg
await updateProgress(15);
const startTime = Date.now();
let watermarkUrl: string | undefined = undefined;
try {
const config = await storage.getCloudinaryConfig();
if (config && config.logoPublicId) {
watermarkUrl = resolveInternalUrl(buildMinioUrl(config.cloudName, config.logoPublicId, config.publicUrl));
}
} catch (e) {
console.error('Error fetching watermark configuration', e);
}
// Get Gemini API key if using Gemini TTS
let geminiApiKey: string | undefined = undefined;
if (ttsEngine === "gemini") {
const appCfg = await storage.getAppConfig();
geminiApiKey = appCfg?.geminiApiKey ?? process.env.GEMINI_API_KEY ?? undefined;
}
const finalWordDuration = wordDuration ?? 0.6;
console.log('🔊 [Background] TTS config:', { ttsEnabled, ttsVoice, ttsEngine });
const ffmpegResult = await ffmpegService.processReelFromUrl(resolveInternalUrl(media.originalUrl), {
text: overlayText,
musicUrl: finalMusicUrl,
ttsEnabled,
ttsVoice,
ttsEngine,
geminiApiKey,
wordDuration: finalWordDuration,
fontSize,
musicVolume,
drawText,
stabilize,
watermarkUrl,
storeName,
enableEndingEffect,
});
console.log(`⏱️ [Background] FFmpeg took ${(Date.now() - startTime) / 1000}s`);
if (ffmpegResult.ttsError) {
console.error(`❌ [Background] TTS FAILED: ${ffmpegResult.ttsError}`);
await storage.updatePost(postId, { generationError: `TTS échoué: ${ffmpegResult.ttsError}` });
}
if (!ffmpegResult.success || !ffmpegResult.videoBase64) {
throw new Error(ffmpegResult.error || 'Erreur de traitement vidéo FFmpeg');
}
// 4. Upload sur Cloudinary
await updateProgress(65);
console.log('☁️ [Background] Uploading to Cloudinary...');
const videoBuffer = Buffer.from(ffmpegResult.videoBase64, 'base64');
const cloudinaryResult = await cloudinaryService.uploadMedia(
videoBuffer,
`reel-${Date.now()}.mp4`,
userId,
'video/mp4'
);
console.log('✅ [Background] Uploaded:', cloudinaryResult.originalUrl);
// 5. Créer l'enregistrement Media pour la vidéo traitée
// La vignette est extraite maintenant : la vidéo sera supprimée du disque
// après publication, mais l'historique doit rester illustré.
const thumbnailUrl = await createVideoThumbnail(videoBuffer);
const processedMedia = await storage.createMedia({
userId: userId,
type: 'video',
cloudinaryPublicId: cloudinaryResult.publicId,
originalUrl: cloudinaryResult.originalUrl,
facebookFeedUrl: cloudinaryResult.facebookFeedUrl || null,
instagramFeedUrl: cloudinaryResult.instagramFeedUrl || null,
instagramStoryUrl: cloudinaryResult.instagramStoryUrl || null,
thumbnailUrl,
fileName: `reel-processed-${Date.now()}.mp4`,
fileSize: videoBuffer.length,
});
// 6. Lier le média traité au Post existant
await storage.updatePostMedia(postId, [processedMedia.id]);
await updateProgress(85);
// 7. Publier sur les pages
await updateProgress(90);
const results: { pageId: string; success: boolean; reelId?: string; error?: string }[] = [];
for (const pageId of pageIds) {
try {
const page = await storage.getSocialPage(pageId);
if (!page) {
results.push({ pageId, success: false, error: 'Page non trouvée' });
continue;
}
if (page.platform !== 'facebook' && page.platform !== 'tiktok') {
results.push({
pageId,
success: false,
error: `Plateforme non supportée pour les reels : ${page.platform}`,
});
continue;
}
// Créer l'entrée scheduled_post (log de publication)
const scheduledPost = await storage.createScheduledPost({
postId: postId,
pageId: page.id,
postType: 'reel',
scheduledAt: scheduledFor ? new Date(scheduledFor) : new Date(),
});
if (!scheduledFor) {
// Publication immédiate — la même vidéo part sur chaque destination
console.log(`🚀 [Background] Publishing to ${page.platform} ${page.pageName}...`);
const finalDescription = description || overlayText || '';
if (page.platform === 'facebook') {
const reelId = await facebookService.publishReel(
page,
cloudinaryResult.originalUrl,
finalDescription
);
// Mise à jour succès
await storage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(),
externalPostId: reelId,
});
results.push({ pageId, success: true, reelId });
} else {
const publishId = await tiktokService.publishVideoFromBuffer(
page,
videoBuffer,
finalDescription
);
// TikTok finalise la publication de son côté : on marque l'envoi
// comme effectué pour ne pas republier, et le poller de statut
// renseignera l'identifiant définitif du post.
await storage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(),
publishId,
publishStatus: 'PROCESSING_UPLOAD',
});
results.push({ pageId, success: true, reelId: publishId });
}
} else {
// Planifié
results.push({ pageId, success: true, reelId: 'scheduled' });
}
} catch (pageError: any) {
console.error(`❌ [Background] Error publishing to page ${pageId}:`, pageError);
results.push({
pageId,
success: false,
error: pageError.message || 'Erreur inconnue',
});
}
}
// 8. Mettre à jour le statut global du Post
const allSuccess = results.every(r => r.success);
const anySuccess = results.some(r => r.success);
// Si au moins une réussite, on considère "published" (ou partial), sinon "failed"
// Si planifié, reste "scheduled".
let finalStatus: 'failed' | 'scheduled' | 'published' = 'failed';
if (scheduledFor) {
finalStatus = 'scheduled';
} else if (allSuccess) {
finalStatus = 'published';
} else if (anySuccess) {
finalStatus = 'published'; // Partiellement publié
}
await storage.updatePost(postId, {
status: finalStatus,
});
await updateProgress(100, 'completed');
console.log(`✅ [Background] Processing complete for Post ${postId}. Status: ${finalStatus}`);
} catch (error: any) {
console.error(`❌ [Background] Critical error for Post ${postId}:`, error);
await storage.updatePost(postId, {
status: 'failed',
});
try {
await storage.updatePostGenerationStatus(
postId, 'failed', 0,
error?.message || 'Erreur inconnue lors du traitement'
);
} catch (e) {
console.error(`⚠️ [Background] Failed to update error status for ${postId}:`, e);
}
} finally {
// IMPORTANT: Toujours vérifier la file d'attente à la fin (succès ou échec)
await checkQueueAndProcessNext();
}
}
/**
* Créer et publier un Reel (Asynchrone avec File d'Attente)
* POST /api/reels
*/
reelsRouter.post('/reels', async (req: Request, res: Response) => {
try {
const user = req.user as User;
const {
videoMediaId,
musicTrackId,
musicUrl,
overlayText,
description,
ttsEnabled,
ttsVoice,
ttsEngine,
pageIds,
scheduledFor,
wordDuration = 0.6,
fontSize = 64,
musicVolume = 0.25,
drawText = true,
stabilize = false,
enableEndingEffect = true,
} = req.body;
// Validation immédiate
if (!videoMediaId) {
return res.status(400).json({ error: 'Vidéo requise' });
}
if (!pageIds || pageIds.length === 0) {
return res.status(400).json({ error: 'Au moins une page requise' });
}
// Vérifier le nombre de jobs en cours
const processingCount = await storage.countProcessingReels();
// MAX_CONCURRENT = 1
const isQueueBusy = processingCount >= 1;
const initialStatus = isQueueBusy ? 'pending' : 'processing';
const initialMessage = isQueueBusy
? "File d'attente pleine. Votre vidéo sera traitée dès que possible."
: "Traitement démarré en arrière-plan.";
// Récupérer le nom de la première page pour l'utiliser comme storeName
let storeName: string | undefined = undefined;
try {
const page = await storage.getSocialPage(pageIds[0]);
if (page) {
storeName = page.pageName;
}
} catch (e) {
console.error('Erreur récupération nom de page', e);
}
// Créer immédiatement le Post en base
// ON STOCKE LES PARAMS DU JOB DANS productInfo POUR POUVOIR LE REPRENDRE PLUS TARD
// C'est un hack car on n'a pas de table params_job, mais ça marche car productInfo est jsonb
const post = await storage.createPost({
userId: user.id,
content: description || overlayText || '',
aiGenerated: 'false',
status: scheduledFor ? 'scheduled' : 'draft',
scheduledFor: scheduledFor ? new Date(scheduledFor) : undefined,
generationStatus: initialStatus,
generationProgress: 0,
productInfo: req.body, // Stockage complet des paramètres
});
console.log(`✨ Reel Request accepted. Post ID: ${post.id}. Status: ${initialStatus}`);
if (!isQueueBusy) {
// Démarrer le traitement en arrière-plan (Fire & Forget)
processReelBackground(user.id, post.id, req.body, storeName).catch(err => {
console.error('🔥 Unhandled background error:', err);
});
} else {
console.log(`⏳ [Queue] Worker busy (count=${processingCount}). Job ${post.id} is queued.`);
}
// Réponse immédiate au client
res.json({
success: true,
postId: post.id,
message: initialMessage,
queued: isQueueBusy,
results: [],
videoUrl: ""
});
} catch (error) {
console.error('❌ Error initiating Reel:', error);
res.status(500).json({
error: error instanceof Error ? error.message : 'Erreur lors de l\'initialisation du Reel',
});
}
});
/**
* Obtenir la configuration des services Reels
* GET /api/reels/config
*/
reelsRouter.get('/reels/config', async (req: Request, res: Response) => {
try {
const ffmpegConfigured = await ffmpegService.healthCheck().catch(() => false);
res.json({
jamendo: { configured: false },
freesound: { configured: false },
ffmpeg: { configured: ffmpegConfigured },
});
} catch (error) {
console.error('❌ Error fetching Reels config:', error);
res.status(500).json({ error: 'Erreur lors de la récupération de la configuration' });
}
});
/**
* Reels en cours de génération
* GET /api/reels/ongoing
*/
reelsRouter.get('/reels/ongoing', async (req: Request, res: Response) => {
try {
const user = req.user as User;
const ongoing = await storage.getOngoingReelPosts(user.id);
res.json(ongoing);
} catch (error) {
console.error('❌ Error fetching ongoing reels:', error);
res.status(500).json({ error: 'Erreur lors de la récupération des reels en cours' });
}
});
/**
* Supprimer un Reel
* DELETE /api/reels/:id
*/
reelsRouter.delete('/reels/:id', async (req: Request, res: Response) => {
try {
const user = req.user as User;
const { id } = req.params;
// Vérifier si le post existe
const post = await storage.getPost(id);
if (!post) {
return res.status(404).json({ error: 'Reel non trouvé' });
}
// Vérifier les permissions (admin ou propriétaire)
if (user.role !== 'admin' && post.userId !== user.id) {
return res.status(403).json({ error: 'Non autorisé à supprimer ce Reel' });
}
// Supprimer le post (cascade supprimera scheduled_posts et liens media)
await storage.deletePost(id);
res.json({ success: true, message: 'Reel supprimé avec succès' });
} catch (error) {
console.error('❌ Error deleting Reel:', error);
res.status(500).json({ error: 'Erreur lors de la suppression du Reel' });
}
});