mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-21 22:18:12 +02:00
feat: Add Facebook Reels with music and text overlay - Add ffmpeg.ts service for Docker FFmpeg API integration - Add jamendo.ts service for royalty-free music search - Add publishReel methods to facebook.ts - Add reels API routes (music search, AI text generation, create/preview) - Add new-reel.tsx frontend with 4-step workflow - Add 'reel' type to postTypeEnum - Update navigation with Nouveau Reel link
This commit is contained in:
1 parent
7b39b34afb
commit
3d494d4056
10 files changed
+2069
-60
No files matched your search
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* 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 { jamendoService, type MusicTrack } from '../services/jamendo';
|
||||
import { ffmpegService } from '../services/ffmpeg';
|
||||
import { facebookService } from '../services/facebook';
|
||||
import { cloudinaryService } from '../services/cloudinary';
|
||||
import { openRouterService } from '../services/openrouter';
|
||||
|
||||
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;
|
||||
|
||||
const tracks = await jamendoService.searchMusicByDuration({
|
||||
minDuration: parseInt(minDuration as string),
|
||||
maxDuration: parseInt(maxDuration as string),
|
||||
genre: genre as string | undefined,
|
||||
limit: parseInt(limit as string),
|
||||
offset: parseInt(offset as string),
|
||||
search: search as string | undefined,
|
||||
});
|
||||
|
||||
res.json({
|
||||
tracks,
|
||||
pagination: {
|
||||
offset: parseInt(offset as string),
|
||||
limit: parseInt(limit as string),
|
||||
hasMore: tracks.length === parseInt(limit as string),
|
||||
},
|
||||
});
|
||||
} 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;
|
||||
|
||||
const tracks = await jamendoService.searchMusicByDuration({
|
||||
minDuration: parseInt(minDuration as string),
|
||||
maxDuration: parseInt(maxDuration as string),
|
||||
genre: genre as string | undefined,
|
||||
limit: parseInt(limit as string),
|
||||
offset: parseInt(offset as string),
|
||||
search: search as string | undefined,
|
||||
});
|
||||
|
||||
res.json({
|
||||
tracks,
|
||||
pagination: {
|
||||
offset: parseInt(offset as string),
|
||||
limit: parseInt(limit as string),
|
||||
hasMore: tracks.length === parseInt(limit as string),
|
||||
},
|
||||
});
|
||||
} 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 {
|
||||
const limit = parseInt(req.query.limit as string) || 10;
|
||||
const tracks = await jamendoService.getPopularTracks(limit);
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Détails d'un track
|
||||
* GET /api/music/:trackId
|
||||
*/
|
||||
reelsRouter.get('/music/:trackId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { trackId } = req.params;
|
||||
const track = await jamendoService.getMusicDetails(trackId);
|
||||
|
||||
if (!track) {
|
||||
return res.status(404).json({ error: 'Musique non trouvée' });
|
||||
}
|
||||
|
||||
res.json(track);
|
||||
} 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' });
|
||||
}
|
||||
|
||||
const generatedTexts = await openRouterService.generatePostText(productInfo, user.id, model);
|
||||
|
||||
// Sauvegarder la génération
|
||||
await storage.createAiGeneration({
|
||||
userId: user.id,
|
||||
productInfo,
|
||||
generatedTexts,
|
||||
});
|
||||
|
||||
res.json({ variants: generatedTexts });
|
||||
} catch (error) {
|
||||
console.error('❌ Error generating Reel text:', error);
|
||||
res.status(500).json({ error: 'Erreur lors de la génération du texte' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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,
|
||||
wordDuration = 0.6,
|
||||
fontSize = 60,
|
||||
musicVolume = 0.25,
|
||||
} = 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) {
|
||||
const track = await jamendoService.getMusicDetails(musicTrackId);
|
||||
if (track) {
|
||||
finalMusicUrl = track.downloadUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// Traiter la vidéo via FFmpeg
|
||||
const result = await ffmpegService.processReelFromUrl(media.originalUrl, {
|
||||
text: overlayText,
|
||||
musicUrl: finalMusicUrl,
|
||||
wordDuration,
|
||||
fontSize,
|
||||
musicVolume,
|
||||
});
|
||||
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Créer et publier un Reel
|
||||
* POST /api/reels
|
||||
*/
|
||||
reelsRouter.post('/reels', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const user = req.user as User;
|
||||
const {
|
||||
videoMediaId,
|
||||
musicTrackId,
|
||||
musicUrl,
|
||||
overlayText,
|
||||
description,
|
||||
pageIds,
|
||||
scheduledFor,
|
||||
wordDuration = 0.6,
|
||||
fontSize = 60,
|
||||
musicVolume = 0.25,
|
||||
} = req.body;
|
||||
|
||||
// Validation
|
||||
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' });
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const track = await jamendoService.getMusicDetails(musicTrackId);
|
||||
if (track) {
|
||||
finalMusicUrl = track.downloadUrl;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🎬 Processing Reel:', {
|
||||
videoMediaId,
|
||||
hasMusic: !!finalMusicUrl,
|
||||
hasText: !!overlayText,
|
||||
pageCount: pageIds.length,
|
||||
});
|
||||
|
||||
// Traiter la vidéo via FFmpeg
|
||||
const ffmpegResult = await ffmpegService.processReelFromUrl(media.originalUrl, {
|
||||
text: overlayText,
|
||||
musicUrl: finalMusicUrl,
|
||||
wordDuration,
|
||||
fontSize,
|
||||
musicVolume,
|
||||
});
|
||||
|
||||
if (!ffmpegResult.success || !ffmpegResult.videoBase64) {
|
||||
return res.status(500).json({
|
||||
error: ffmpegResult.error || 'Erreur de traitement vidéo',
|
||||
});
|
||||
}
|
||||
|
||||
// Upload la vidéo traitée sur Cloudinary
|
||||
const videoBuffer = Buffer.from(ffmpegResult.videoBase64, 'base64');
|
||||
const cloudinaryResult = await cloudinaryService.uploadMedia(
|
||||
videoBuffer,
|
||||
`reel-${Date.now()}.mp4`,
|
||||
user.id,
|
||||
'video/mp4'
|
||||
);
|
||||
|
||||
console.log('✅ Reel uploaded to Cloudinary:', cloudinaryResult.originalUrl);
|
||||
|
||||
// Créer le post en base
|
||||
const post = await storage.createPost({
|
||||
userId: user.id,
|
||||
content: description || overlayText || '',
|
||||
aiGenerated: 'false',
|
||||
status: scheduledFor ? 'scheduled' : 'draft',
|
||||
scheduledFor: scheduledFor ? new Date(scheduledFor) : undefined,
|
||||
});
|
||||
|
||||
// Publier sur chaque page sélectionnée
|
||||
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') {
|
||||
results.push({ pageId, success: false, error: 'Seules les pages Facebook sont supportées pour les Reels' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Créer l'entrée scheduled_post
|
||||
const scheduledPost = await storage.createScheduledPost({
|
||||
postId: post.id,
|
||||
pageId: page.id,
|
||||
postType: 'reel',
|
||||
scheduledAt: scheduledFor ? new Date(scheduledFor) : new Date(),
|
||||
});
|
||||
|
||||
// Si pas de planification, publier immédiatement
|
||||
if (!scheduledFor) {
|
||||
const reelId = await facebookService.publishReel(
|
||||
page,
|
||||
cloudinaryResult.originalUrl,
|
||||
description
|
||||
);
|
||||
|
||||
// Mettre à jour le scheduled_post avec l'ID externe
|
||||
await storage.updateScheduledPost(scheduledPost.id, {
|
||||
publishedAt: new Date(),
|
||||
externalPostId: reelId,
|
||||
});
|
||||
|
||||
results.push({ pageId, success: true, reelId });
|
||||
} else {
|
||||
results.push({ pageId, success: true, reelId: 'scheduled' });
|
||||
}
|
||||
} catch (pageError) {
|
||||
console.error(`❌ Error publishing Reel to page ${pageId}:`, pageError);
|
||||
results.push({
|
||||
pageId,
|
||||
success: false,
|
||||
error: pageError instanceof Error ? pageError.message : 'Erreur inconnue',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour le statut du post
|
||||
const allSuccess = results.every(r => r.success);
|
||||
await storage.updatePost(post.id, {
|
||||
status: scheduledFor ? 'scheduled' : (allSuccess ? 'published' : 'failed'),
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: allSuccess,
|
||||
postId: post.id,
|
||||
results,
|
||||
videoUrl: cloudinaryResult.originalUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Error creating Reel:', error);
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Erreur lors de la création du Reel',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Obtenir la configuration des services Reels
|
||||
* GET /api/reels/config
|
||||
*/
|
||||
reelsRouter.get('/reels/config', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const jamendoConfigured = await jamendoService.testConnection().catch(() => false);
|
||||
const ffmpegConfigured = await ffmpegService.healthCheck().catch(() => false);
|
||||
|
||||
res.json({
|
||||
jamendo: { configured: jamendoConfigured },
|
||||
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' });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user