+ {sidebarOpen && (
+
setSidebarOpen(false)}
+ />
+ )}
+
+
+ setSidebarOpen(false)} />
+
+
+
+ setSidebarOpen(!sidebarOpen)} />
+
+
+
+
+
+ Créer un Reel
+
+
+ Créez un Reel Facebook avec musique et texte overlay
+
+
+
+ {/* Stepper */}
+
+ {(['video', 'music', 'text', 'publish'] as Step[]).map((step, index) => (
+
+
+ {index < 3 && }
+
+ ))}
+
+
+ {/* Contenu par étape */}
+
+ {/* Colonne principale */}
+
+
+ {/* ÉTAPE 1: Vidéo */}
+ {currentStep === 'video' && (
+
+
+
+
+ Sélectionnez une Vidéo
+
+
+ Choisissez une vidéo existante ou capturez-en une nouvelle
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {videoList.length === 0 ? (
+
+
+
+ {isDragActive ? "Déposez votre vidéo ici" : "Aucune vidéo disponible"}
+
+
+ ) : (
+
+ {videoList.map((media) => (
+
+ ))}
+
+ )}
+
+
+ {selectedVideo && (
+
+
+
+ )}
+
+
+ )}
+
+ {/* ÉTAPE 2: Musique */}
+ {currentStep === 'music' && (
+
+
+
+
+ Choisissez une Musique
+
+
+ Sélectionnez une musique libre de droits (optionnel)
+
+
+
+
+
+ )}
+
+ {/* ÉTAPE 3: Texte */}
+ {currentStep === 'text' && (
+ <>
+
+
+
+
+ Générer avec l'IA
+
+
+ Décrivez votre produit pour générer des textes
+
+
+
+
+
+
+ {generatedVariants.length > 0 && (
+
+
+ Variations générées
+
+
+ {generatedVariants.map((variant, index) => (
+
+
+
+
+ {variant.variant || `Version ${index + 1}`}
+
+
+ {variant.text}
+
+
+
+
+
+ ))}
+
+
+ )}
+
+
+
+
+
+ Texte Overlay
+
+
+ Ce texte s'affichera au centre de votre Reel (style TikTok)
+
+
+
+
+
+ >
+ )}
+
+ {/* ÉTAPE 4: Publication */}
+ {currentStep === 'publish' && (
+ <>
+
+
+ Pages cibles
+ Sélectionnez les pages où publier
+
+
+ {pages.filter(p => p.platform === 'facebook').length === 0 ? (
+
+
+ Aucune page Facebook connectée
+
+
+
+ ) : (
+
+ {pages.filter(p => p.platform === 'facebook').map((page) => (
+
+ {
+ if (checked) {
+ setSelectedPages([...selectedPages, page.id]);
+ } else {
+ setSelectedPages(selectedPages.filter(id => id !== page.id));
+ }
+ }}
+ />
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+ Planification
+
+
+ Programmez la publication (optionnel)
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+
+ {/* Colonne de prévisualisation */}
+
+
+
+ Aperçu
+
+
+ {selectedVideo ? (
+
+
+ {overlayText && (
+
+ )}
+ {selectedTrack && (
+
+
+
+ {selectedTrack.title} - {selectedTrack.artist}
+
+
+ )}
+
+ ) : (
+
+
+
+
Sélectionnez une vidéo
+
+
+ )}
+
+
+
+ {/* Résumé */}
+
+
+ Résumé
+
+
+
+ Vidéo
+ {selectedVideo ? '✓' : '—'}
+
+
+ Musique
+ {selectedTrack ? selectedTrack.title : 'Aucune'}
+
+
+ Texte
+ {overlayText ? '✓' : '—'}
+
+
+ Pages
+ {selectedPages.length} sélectionnée(s)
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/server/routes.ts b/server/routes.ts
index d82cf92..888c6ca 100644
--- a/server/routes.ts
+++ b/server/routes.ts
@@ -11,6 +11,7 @@ import { cloudinaryService } from "./services/cloudinary";
import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema, updateCloudinaryConfigSchema, insertOpenrouterConfigSchema, updateOpenrouterConfigSchema, insertUserSchema, postMedia, type SocialPage } from "@shared/schema";
import type { User, InsertUser, ScheduledPost } from "@shared/schema";
import { analyticsRouter } from "./routes/analytics";
+import { reelsRouter } from "./routes/reels";
// Types MIME autorisés pour les uploads
const ALLOWED_MIME_TYPES = [
@@ -59,6 +60,9 @@ export async function registerRoutes(app: Express): Promise
{
// Analytics Routes
app.use("/api/analytics", requireAuth, analyticsRouter);
+ // Reels & Music Routes
+ app.use("/api", requireAuth, reelsRouter);
+
// Routes d'authentification
app.post("/api/auth/login", (req, res, next) => {
passport.authenticate("local", (err: any, user: User | false, info: any) => {
diff --git a/server/routes/reels.ts b/server/routes/reels.ts
new file mode 100644
index 0000000..5d32710
--- /dev/null
+++ b/server/routes/reels.ts
@@ -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' });
+ }
+});
diff --git a/server/services/facebook.ts b/server/services/facebook.ts
index 5a59815..35bd30f 100644
--- a/server/services/facebook.ts
+++ b/server/services/facebook.ts
@@ -43,27 +43,27 @@ export class FacebookService {
if (postType === 'both') {
throw new Error('postType "both" is not supported. Posts should be split into separate feed and story entries at creation.');
}
-
+
if (postType === 'story') {
// Publish as story - one story per media
if (mediaList.length === 0) {
throw new Error('Stories require media (photo or video)');
}
-
+
// Publish each media as a separate story
const storyIds: string[] = [];
for (const media of mediaList) {
const storyId = await this.publishStory(post, page, media);
storyIds.push(storyId);
}
-
+
// Return all story IDs joined together
return storyIds.join(',');
} else {
// Default to feed
const imageMedia = mediaList.filter(m => m.type === 'image');
const videoMedia = mediaList.filter(m => m.type === 'video');
-
+
if (imageMedia.length > 1) {
// Multi-photo carousel (images only)
return await this.publishMultiPhotoPost(post, page, mediaList);
@@ -135,7 +135,7 @@ export class FacebookService {
// Check if this is a Cloudinary URL
const cloudinaryPattern = /https:\/\/res\.cloudinary\.com\/([^\/]+)\/video\/upload\//;
const match = originalUrl.match(cloudinaryPattern);
-
+
if (!match) {
// Not a Cloudinary URL, return as-is (may fail on Facebook)
console.warn('Video URL is not from Cloudinary, cannot apply transformations:', originalUrl);
@@ -144,20 +144,20 @@ export class FacebookService {
const [fullMatch, cloudName] = match;
const videoPath = originalUrl.replace(fullMatch, '');
-
+
// Apply transformations for Facebook compatibility:
// - vc_h264: Video codec H.264
// - ac_aac: Audio codec AAC
// - f_mp4: Force MP4 format
// - q_auto: Automatic quality optimization
const transformations = 'vc_h264,ac_aac,f_mp4,q_auto';
-
+
const transformedUrl = `https://res.cloudinary.com/${cloudName}/video/upload/${transformations}/${videoPath}`;
-
+
console.log('🎬 Transforming video for Facebook compatibility:');
console.log(' Original:', originalUrl);
console.log(' Transformed:', transformedUrl);
-
+
return transformedUrl;
}
@@ -193,7 +193,7 @@ export class FacebookService {
if (imageMedia.length === 0) {
throw new Error('Multi-photo posts require at least one image');
}
-
+
if (imageMedia.length !== mediaList.length) {
console.warn(`Filtered out ${mediaList.length - imageMedia.length} non-image media from multi-photo post`);
}
@@ -204,7 +204,7 @@ export class FacebookService {
for (const media of imageMedia) {
// Use original URL - Facebook handles the cropping for carousel
const photoUrl = media.originalUrl;
-
+
if (!photoUrl) {
console.warn(`Skipping media ${media.id} - no valid URL found`);
continue;
@@ -230,7 +230,7 @@ export class FacebookService {
const uploadData = await uploadResponse.json() as FacebookPhotoResponse;
photoIds.push(uploadData.id);
}
-
+
if (photoIds.length === 0) {
throw new Error('No photos were successfully uploaded');
}
@@ -347,7 +347,7 @@ export class FacebookService {
if (media.type === 'video') {
return this.publishVideoStory(post, page, media);
}
-
+
// Stories require a 2-step process:
// Step 1: Upload photo as unpublished to get photo_id
// Step 2: Publish the photo as a story using the photo_id
@@ -372,7 +372,7 @@ export class FacebookService {
});
const uploadUrl = `${this.baseUrl}/${page.pageId}/photos?${uploadParams.toString()}`;
-
+
const uploadResponse = await fetch(uploadUrl, {
method: 'POST',
});
@@ -411,31 +411,31 @@ export class FacebookService {
// 1. START phase - Initialize the upload and get upload_url
// 2. UPLOAD phase - Upload the video to upload_url using file_url header
// 3. FINISH phase - Finalize and publish the story
-
+
const videoUrl = media.originalUrl;
const accessToken = page.accessToken!;
-
+
// Phase 1: START - Initialize the upload
const startParams = new URLSearchParams({
access_token: accessToken,
upload_phase: 'start',
});
-
+
const startUrl = `${this.baseUrl}/${page.pageId}/video_stories?${startParams.toString()}`;
-
+
const startResponse = await fetch(startUrl, {
method: 'POST',
});
-
+
if (!startResponse.ok) {
const error = await startResponse.json() as FacebookError;
throw new Error(`Facebook API error (START phase): ${error.error.message} (code: ${error.error.code})`);
}
-
+
const startData = await startResponse.json() as { video_id: string; upload_url: string };
const videoId = startData.video_id;
const uploadUrl = startData.upload_url;
-
+
// Phase 2: UPLOAD - Upload the video using the upload_url
// IMPORTANT: file_url must be passed as a header, not a query parameter
const uploadResponse = await fetch(uploadUrl, {
@@ -445,35 +445,254 @@ export class FacebookService {
'file_url': videoUrl,
},
});
-
+
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text();
throw new Error(`Facebook API error (UPLOAD phase): ${errorText}`);
}
-
+
// Phase 3: FINISH - Finalize and publish the story
const finishParams = new URLSearchParams({
access_token: accessToken,
upload_phase: 'finish',
video_id: videoId,
});
-
+
const finishUrl = `${this.baseUrl}/${page.pageId}/video_stories?${finishParams.toString()}`;
-
+
const finishResponse = await fetch(finishUrl, {
method: 'POST',
});
-
+
if (!finishResponse.ok) {
const error = await finishResponse.json() as FacebookError;
throw new Error(`Facebook API error (FINISH phase): ${error.error.message} (code: ${error.error.code})`);
}
-
+
const finishData = await finishResponse.json() as { success: boolean; post_id?: string };
-
+
// Return the post_id from the finish response
return finishData.post_id || videoId;
}
+
+ /**
+ * Publie une vidéo comme Reel Facebook
+ *
+ * Utilise l'endpoint /{page-id}/video_reels avec un processus en 3 phases :
+ * 1. START - Initialise l'upload et récupère l'upload_url
+ * 2. UPLOAD - Upload la vidéo vers rupload.facebook.com
+ * 3. FINISH - Finalise et publie le Reel
+ *
+ * Spécifications vidéo requises :
+ * - Format : MP4, H.264
+ * - Ratio : 9:16 (vertical)
+ * - Durée : 3-90 secondes
+ * - Résolution recommandée : 1080x1920
+ *
+ * @param page - Page Facebook cible
+ * @param videoUrl - URL de la vidéo à publier
+ * @param description - Description du Reel (optionnel)
+ * @returns ID du Reel publié
+ */
+ async publishReel(
+ page: SocialPage,
+ videoUrl: string,
+ description?: string
+ ): Promise {
+ if (page.platform !== 'facebook') {
+ throw new Error('This method only supports Facebook pages');
+ }
+
+ if (!page.accessToken) {
+ throw new Error('No access token found for this page');
+ }
+
+ const accessToken = page.accessToken;
+
+ console.log('🎬 Publishing Reel to Facebook:', {
+ pageId: page.pageId,
+ pageName: page.pageName,
+ hasDescription: !!description,
+ });
+
+ // Phase 1: START - Initialize the upload
+ const startParams = new URLSearchParams({
+ access_token: accessToken,
+ upload_phase: 'start',
+ });
+
+ const startUrl = `${this.baseUrl}/${page.pageId}/video_reels?${startParams.toString()}`;
+
+ console.log('📤 Phase 1: START - Initializing Reel upload');
+ const startResponse = await fetch(startUrl, {
+ method: 'POST',
+ });
+
+ if (!startResponse.ok) {
+ const error = await startResponse.json() as FacebookError;
+ throw new Error(`Facebook Reel API error (START phase): ${error.error.message} (code: ${error.error.code})`);
+ }
+
+ const startData = await startResponse.json() as { video_id: string; upload_url: string };
+ const videoId = startData.video_id;
+ const uploadUrl = startData.upload_url;
+
+ console.log('✅ START phase complete, video_id:', videoId);
+
+ // Phase 2: UPLOAD - Upload the video to rupload.facebook.com
+ // IMPORTANT: file_url must be passed as a header for URL-based uploads
+ console.log('📤 Phase 2: UPLOAD - Uploading video to:', uploadUrl);
+
+ const uploadResponse = await fetch(uploadUrl, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `OAuth ${accessToken}`,
+ 'file_url': videoUrl,
+ },
+ });
+
+ if (!uploadResponse.ok) {
+ const errorText = await uploadResponse.text();
+ console.error('❌ UPLOAD phase failed:', errorText);
+ throw new Error(`Facebook Reel API error (UPLOAD phase): ${errorText}`);
+ }
+
+ console.log('✅ UPLOAD phase complete');
+
+ // Phase 3: FINISH - Finalize and publish the Reel
+ const finishParams = new URLSearchParams({
+ access_token: accessToken,
+ upload_phase: 'finish',
+ video_id: videoId,
+ video_state: 'PUBLISHED',
+ });
+
+ // Add description if provided
+ if (description) {
+ finishParams.append('description', description);
+ }
+
+ const finishUrl = `${this.baseUrl}/${page.pageId}/video_reels?${finishParams.toString()}`;
+
+ console.log('📤 Phase 3: FINISH - Publishing Reel');
+ const finishResponse = await fetch(finishUrl, {
+ method: 'POST',
+ });
+
+ if (!finishResponse.ok) {
+ const error = await finishResponse.json() as FacebookError;
+ throw new Error(`Facebook Reel API error (FINISH phase): ${error.error.message} (code: ${error.error.code})`);
+ }
+
+ const finishData = await finishResponse.json() as { success: boolean; post_id?: string };
+
+ if (!finishData.success) {
+ throw new Error('Facebook Reel publishing failed - success was false');
+ }
+
+ const reelId = finishData.post_id || videoId;
+ console.log('✅ Reel published successfully! ID:', reelId);
+
+ return reelId;
+ }
+
+ /**
+ * Publie une vidéo comme Reel Facebook en uploadant directement les données binaires
+ * Utile quand on a la vidéo en base64 (après traitement FFmpeg)
+ *
+ * @param page - Page Facebook cible
+ * @param videoBuffer - Buffer de la vidéo
+ * @param description - Description du Reel (optionnel)
+ * @returns ID du Reel publié
+ */
+ async publishReelFromBuffer(
+ page: SocialPage,
+ videoBuffer: ArrayBuffer,
+ description?: string
+ ): Promise {
+ if (page.platform !== 'facebook') {
+ throw new Error('This method only supports Facebook pages');
+ }
+
+ if (!page.accessToken) {
+ throw new Error('No access token found for this page');
+ }
+
+ const accessToken = page.accessToken;
+ const fileSize = videoBuffer.byteLength;
+
+ console.log('🎬 Publishing Reel from buffer:', {
+ pageId: page.pageId,
+ fileSize: `${(fileSize / 1024 / 1024).toFixed(2)} MB`,
+ });
+
+ // Phase 1: START
+ const startParams = new URLSearchParams({
+ access_token: accessToken,
+ upload_phase: 'start',
+ });
+
+ const startUrl = `${this.baseUrl}/${page.pageId}/video_reels?${startParams.toString()}`;
+
+ const startResponse = await fetch(startUrl, {
+ method: 'POST',
+ });
+
+ if (!startResponse.ok) {
+ const error = await startResponse.json() as FacebookError;
+ throw new Error(`Facebook Reel API error (START phase): ${error.error.message} (code: ${error.error.code})`);
+ }
+
+ const startData = await startResponse.json() as { video_id: string; upload_url: string };
+ const videoId = startData.video_id;
+ const uploadUrl = startData.upload_url;
+
+ // Phase 2: UPLOAD - Upload binary data directly
+ const videoBlob = new Blob([videoBuffer], { type: 'video/mp4' });
+ const uploadResponse = await fetch(uploadUrl, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `OAuth ${accessToken}`,
+ 'Content-Type': 'application/octet-stream',
+ 'offset': '0',
+ 'file_size': fileSize.toString(),
+ },
+ body: videoBlob,
+ });
+
+ if (!uploadResponse.ok) {
+ const errorText = await uploadResponse.text();
+ throw new Error(`Facebook Reel API error (UPLOAD phase): ${errorText}`);
+ }
+
+ // Phase 3: FINISH
+ const finishParams = new URLSearchParams({
+ access_token: accessToken,
+ upload_phase: 'finish',
+ video_id: videoId,
+ video_state: 'PUBLISHED',
+ });
+
+ if (description) {
+ finishParams.append('description', description);
+ }
+
+ const finishUrl = `${this.baseUrl}/${page.pageId}/video_reels?${finishParams.toString()}`;
+
+ const finishResponse = await fetch(finishUrl, {
+ method: 'POST',
+ });
+
+ if (!finishResponse.ok) {
+ const error = await finishResponse.json() as FacebookError;
+ throw new Error(`Facebook Reel API error (FINISH phase): ${error.error.message} (code: ${error.error.code})`);
+ }
+
+ const finishData = await finishResponse.json() as { success: boolean; post_id?: string };
+
+ return finishData.post_id || videoId;
+ }
}
export const facebookService = new FacebookService();
+
diff --git a/server/services/ffmpeg.ts b/server/services/ffmpeg.ts
new file mode 100644
index 0000000..04c65af
--- /dev/null
+++ b/server/services/ffmpeg.ts
@@ -0,0 +1,245 @@
+/**
+ * FFmpeg Docker API Service
+ *
+ * Intégration avec l'API FFmpeg Docker locale pour le traitement vidéo des Reels.
+ * L'API attend une vidéo en base64 et retourne la vidéo traitée en base64.
+ */
+
+interface FFmpegReelRequest {
+ video_base64?: string; // Vidéo source en base64
+ video_url?: string; // OU URL de la vidéo source
+ text?: string; // Texte overlay style TikTok
+ music_id?: string; // ID de la musique (catalogue FFmpeg)
+ music_url?: string; // OU URL directe de la musique
+ word_duration?: number; // Durée par mot (default: 0.6s)
+ font_size?: number; // Taille police (default: 60)
+ music_volume?: number; // Volume musique (default: 0.25)
+}
+
+interface FFmpegReelResponse {
+ success: boolean;
+ output_base64?: string; // Vidéo traitée en base64
+ duration?: number; // Durée de la vidéo en secondes
+ detail?: string; // Message d'erreur si échec
+}
+
+interface FFmpegConfig {
+ apiUrl: string;
+ apiKey: string;
+}
+
+export class FFmpegService {
+ private config: FFmpegConfig | null = null;
+
+ /**
+ * Configure le service avec l'URL et la clé API
+ */
+ configure(apiUrl: string, apiKey: string): void {
+ this.config = { apiUrl, apiKey };
+ console.log('🎬 FFmpeg Service configured:', apiUrl);
+ }
+
+ /**
+ * Vérifie que le service est configuré
+ */
+ private ensureConfigured(): FFmpegConfig {
+ if (!this.config) {
+ throw new Error('FFmpeg Service not configured. Call configure() first.');
+ }
+ return this.config;
+ }
+
+ /**
+ * Traite une vidéo pour créer un Reel avec musique et texte overlay
+ *
+ * @param videoBase64 - Vidéo source encodée en base64
+ * @param options - Options de traitement (texte, musique, etc.)
+ * @returns Vidéo traitée en base64
+ */
+ async processReelVideo(
+ videoBase64: string,
+ options: {
+ text?: string;
+ musicId?: string;
+ musicUrl?: string;
+ wordDuration?: number;
+ fontSize?: number;
+ musicVolume?: number;
+ } = {}
+ ): Promise<{ success: boolean; videoBase64?: string; duration?: number; error?: string }> {
+ const config = this.ensureConfigured();
+
+ const requestBody: FFmpegReelRequest = {
+ video_base64: videoBase64,
+ text: options.text,
+ music_id: options.musicId,
+ music_url: options.musicUrl,
+ word_duration: options.wordDuration ?? 0.6,
+ font_size: options.fontSize ?? 60,
+ music_volume: options.musicVolume ?? 0.25,
+ };
+
+ // Remove undefined values
+ Object.keys(requestBody).forEach(key => {
+ if (requestBody[key as keyof FFmpegReelRequest] === undefined) {
+ delete requestBody[key as keyof FFmpegReelRequest];
+ }
+ });
+
+ console.log('🎬 Processing Reel video:', {
+ hasVideo: !!videoBase64,
+ hasText: !!options.text,
+ hasMusicId: !!options.musicId,
+ hasMusicUrl: !!options.musicUrl,
+ });
+
+ try {
+ const response = await fetch(`${config.apiUrl}/process-reel`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-API-Key': config.apiKey,
+ },
+ body: JSON.stringify(requestBody),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error('❌ FFmpeg API error:', response.status, errorText);
+ return {
+ success: false,
+ error: `FFmpeg API error: ${response.status} - ${errorText}`,
+ };
+ }
+
+ const data = await response.json() as FFmpegReelResponse;
+
+ if (!data.success) {
+ console.error('❌ FFmpeg processing failed:', data.detail);
+ return {
+ success: false,
+ error: data.detail || 'Unknown FFmpeg processing error',
+ };
+ }
+
+ console.log('✅ Reel video processed successfully, duration:', data.duration);
+ return {
+ success: true,
+ videoBase64: data.output_base64,
+ duration: data.duration,
+ };
+
+ } catch (error) {
+ console.error('❌ FFmpeg Service error:', error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ };
+ }
+ }
+
+ /**
+ * Traite une vidéo depuis une URL (télécharge, traite, retourne base64)
+ */
+ async processReelFromUrl(
+ videoUrl: string,
+ options: {
+ text?: string;
+ musicId?: string;
+ musicUrl?: string;
+ wordDuration?: number;
+ fontSize?: number;
+ musicVolume?: number;
+ } = {}
+ ): Promise<{ success: boolean; videoBase64?: string; duration?: number; error?: string }> {
+ const config = this.ensureConfigured();
+
+ const requestBody: FFmpegReelRequest = {
+ video_url: videoUrl,
+ text: options.text,
+ music_id: options.musicId,
+ music_url: options.musicUrl,
+ word_duration: options.wordDuration ?? 0.6,
+ font_size: options.fontSize ?? 60,
+ music_volume: options.musicVolume ?? 0.25,
+ };
+
+ // Remove undefined values
+ Object.keys(requestBody).forEach(key => {
+ if (requestBody[key as keyof FFmpegReelRequest] === undefined) {
+ delete requestBody[key as keyof FFmpegReelRequest];
+ }
+ });
+
+ console.log('🎬 Processing Reel from URL:', {
+ videoUrl,
+ hasText: !!options.text,
+ hasMusicId: !!options.musicId,
+ hasMusicUrl: !!options.musicUrl,
+ });
+
+ try {
+ const response = await fetch(`${config.apiUrl}/process-reel`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-API-Key': config.apiKey,
+ },
+ body: JSON.stringify(requestBody),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error('❌ FFmpeg API error:', response.status, errorText);
+ return {
+ success: false,
+ error: `FFmpeg API error: ${response.status} - ${errorText}`,
+ };
+ }
+
+ const data = await response.json() as FFmpegReelResponse;
+
+ if (!data.success) {
+ console.error('❌ FFmpeg processing failed:', data.detail);
+ return {
+ success: false,
+ error: data.detail || 'Unknown FFmpeg processing error',
+ };
+ }
+
+ console.log('✅ Reel video processed successfully from URL');
+ return {
+ success: true,
+ videoBase64: data.output_base64,
+ duration: data.duration,
+ };
+
+ } catch (error) {
+ console.error('❌ FFmpeg Service error:', error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ };
+ }
+ }
+
+ /**
+ * Vérifie la santé de l'API FFmpeg
+ */
+ async healthCheck(): Promise {
+ try {
+ const config = this.ensureConfigured();
+ const response = await fetch(`${config.apiUrl}/health`, {
+ method: 'GET',
+ headers: {
+ 'X-API-Key': config.apiKey,
+ },
+ });
+ return response.ok;
+ } catch {
+ return false;
+ }
+ }
+}
+
+export const ffmpegService = new FFmpegService();
diff --git a/server/services/jamendo.ts b/server/services/jamendo.ts
new file mode 100644
index 0000000..34e1541
--- /dev/null
+++ b/server/services/jamendo.ts
@@ -0,0 +1,296 @@
+/**
+ * Jamendo Music API Service
+ *
+ * Intégration avec l'API Jamendo pour rechercher et récupérer des musiques
+ * libres de droits pour les Reels.
+ *
+ * Documentation: https://developer.jamendo.com/v3.0
+ */
+
+interface JamendoTrack {
+ id: string;
+ name: string;
+ duration: number; // en secondes
+ artist_id: string;
+ artist_name: string;
+ album_name: string;
+ album_id: string;
+ license_ccurl: string;
+ position: number;
+ releasedate: string;
+ album_image: string;
+ audio: string; // URL du fichier audio complet
+ audiodownload: string; // URL de téléchargement
+ prourl: string;
+ shorturl: string;
+ shareurl: string;
+ waveform: string;
+ image: string;
+ audiodownload_allowed: boolean;
+}
+
+interface JamendoResponse {
+ headers: {
+ status: string;
+ code: number;
+ error_message: string;
+ warnings: string;
+ results_count: number;
+ };
+ results: JamendoTrack[];
+}
+
+export interface MusicTrack {
+ id: string;
+ title: string;
+ artist: string;
+ albumName: string;
+ duration: number; // en secondes
+ previewUrl: string; // URL audio streaming
+ downloadUrl: string; // URL téléchargement
+ imageUrl: string; // Pochette album
+ license: string;
+}
+
+interface MusicSearchParams {
+ minDuration?: number; // Durée min en secondes
+ maxDuration?: number; // Durée max en secondes
+ genre?: string; // Genre musical (ex: "pop", "rock", "electronic")
+ mood?: string; // Ambiance (ex: "happy", "sad", "energetic")
+ limit?: number; // Nombre de résultats (default: 10)
+ offset?: number; // Offset pour pagination
+ search?: string; // Recherche texte libre
+}
+
+interface JamendoConfig {
+ clientId: string;
+}
+
+const JAMENDO_API_BASE = 'https://api.jamendo.com/v3.0';
+
+// Mapping des genres Jamendo
+const GENRE_MAPPING: Record = {
+ 'pop': 'pop',
+ 'rock': 'rock',
+ 'electronic': 'electronic',
+ 'hiphop': 'hiphop',
+ 'jazz': 'jazz',
+ 'classical': 'classical',
+ 'ambient': 'ambient',
+ 'chill': 'chillout',
+ 'dance': 'dance',
+ 'indie': 'indie',
+};
+
+export class JamendoService {
+ private config: JamendoConfig | null = null;
+
+ /**
+ * Configure le service avec le Client ID Jamendo
+ */
+ configure(clientId: string): void {
+ this.config = { clientId };
+ console.log('🎵 Jamendo Service configured');
+ }
+
+ /**
+ * Vérifie que le service est configuré
+ */
+ private ensureConfigured(): JamendoConfig {
+ if (!this.config) {
+ throw new Error('Jamendo Service not configured. Call configure() first.');
+ }
+ return this.config;
+ }
+
+ /**
+ * Convertit un track Jamendo en format MusicTrack unifié
+ */
+ private mapTrack(track: JamendoTrack): MusicTrack {
+ return {
+ id: track.id,
+ title: track.name,
+ artist: track.artist_name,
+ albumName: track.album_name,
+ duration: track.duration,
+ previewUrl: track.audio,
+ downloadUrl: track.audiodownload,
+ imageUrl: track.album_image || track.image,
+ license: track.license_ccurl,
+ };
+ }
+
+ /**
+ * Recherche des musiques par durée
+ *
+ * @param params - Paramètres de recherche
+ * @returns Liste de tracks correspondants
+ */
+ async searchMusicByDuration(params: MusicSearchParams = {}): Promise {
+ const config = this.ensureConfigured();
+
+ const {
+ minDuration = 10,
+ maxDuration = 120,
+ genre,
+ limit = 10,
+ offset = 0,
+ search,
+ } = params;
+
+ // Construction de l'URL avec les paramètres
+ const queryParams = new URLSearchParams({
+ client_id: config.clientId,
+ format: 'json',
+ limit: limit.toString(),
+ offset: offset.toString(),
+ include: 'musicinfo',
+ audioformat: 'mp32', // MP3 320kbps
+ durationbetween: `${minDuration}_${maxDuration}`,
+ order: 'popularity_total',
+ });
+
+ // Ajouter le genre si spécifié
+ if (genre && GENRE_MAPPING[genre.toLowerCase()]) {
+ queryParams.append('tags', GENRE_MAPPING[genre.toLowerCase()]);
+ } else if (genre) {
+ queryParams.append('tags', genre.toLowerCase());
+ }
+
+ // Ajouter la recherche texte si spécifiée
+ if (search) {
+ queryParams.append('namesearch', search);
+ }
+
+ const url = `${JAMENDO_API_BASE}/tracks/?${queryParams.toString()}`;
+
+ console.log('🎵 Searching Jamendo:', {
+ duration: `${minDuration}-${maxDuration}s`,
+ genre,
+ limit,
+ offset,
+ });
+
+ try {
+ const response = await fetch(url);
+
+ if (!response.ok) {
+ throw new Error(`Jamendo API error: ${response.status}`);
+ }
+
+ const data = await response.json() as JamendoResponse;
+
+ if (data.headers.code !== 0) {
+ throw new Error(`Jamendo API error: ${data.headers.error_message}`);
+ }
+
+ const tracks = data.results.map(track => this.mapTrack(track));
+
+ console.log(`✅ Found ${tracks.length} tracks on Jamendo`);
+ return tracks;
+
+ } catch (error) {
+ console.error('❌ Jamendo search error:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * Récupère les détails d'un track par son ID
+ */
+ async getMusicDetails(trackId: string): Promise {
+ const config = this.ensureConfigured();
+
+ const queryParams = new URLSearchParams({
+ client_id: config.clientId,
+ format: 'json',
+ id: trackId,
+ include: 'musicinfo',
+ audioformat: 'mp32',
+ });
+
+ const url = `${JAMENDO_API_BASE}/tracks/?${queryParams.toString()}`;
+
+ try {
+ const response = await fetch(url);
+
+ if (!response.ok) {
+ throw new Error(`Jamendo API error: ${response.status}`);
+ }
+
+ const data = await response.json() as JamendoResponse;
+
+ if (data.headers.code !== 0 || data.results.length === 0) {
+ return null;
+ }
+
+ return this.mapTrack(data.results[0]);
+
+ } catch (error) {
+ console.error('❌ Jamendo getMusicDetails error:', error);
+ return null;
+ }
+ }
+
+ /**
+ * Récupère des tracks populaires (utilisé comme fallback ou découverte)
+ */
+ async getPopularTracks(limit: number = 10): Promise {
+ const config = this.ensureConfigured();
+
+ const queryParams = new URLSearchParams({
+ client_id: config.clientId,
+ format: 'json',
+ limit: limit.toString(),
+ include: 'musicinfo',
+ audioformat: 'mp32',
+ order: 'popularity_week',
+ durationbetween: '15_90', // Durée idéale pour Reels
+ });
+
+ const url = `${JAMENDO_API_BASE}/tracks/?${queryParams.toString()}`;
+
+ try {
+ const response = await fetch(url);
+
+ if (!response.ok) {
+ throw new Error(`Jamendo API error: ${response.status}`);
+ }
+
+ const data = await response.json() as JamendoResponse;
+
+ if (data.headers.code !== 0) {
+ throw new Error(`Jamendo API error: ${data.headers.error_message}`);
+ }
+
+ return data.results.map(track => this.mapTrack(track));
+
+ } catch (error) {
+ console.error('❌ Jamendo getPopularTracks error:', error);
+ return [];
+ }
+ }
+
+ /**
+ * Télécharge l'URL du fichier audio (avec redirection si nécessaire)
+ * Note: Jamendo fournit directement les URLs de téléchargement
+ */
+ getDownloadUrl(track: MusicTrack): string {
+ // L'URL de téléchargement Jamendo est directement utilisable
+ return track.downloadUrl;
+ }
+
+ /**
+ * Vérifie la validité de la configuration
+ */
+ async testConnection(): Promise {
+ try {
+ const tracks = await this.getPopularTracks(1);
+ return tracks.length > 0;
+ } catch {
+ return false;
+ }
+ }
+}
+
+export const jamendoService = new JamendoService();
diff --git a/shared/schema.ts b/shared/schema.ts
index 42b032f..e810209 100644
--- a/shared/schema.ts
+++ b/shared/schema.ts
@@ -5,7 +5,7 @@ import { createInsertSchema } from "drizzle-zod";
import { z } from "zod";
export const platformEnum = pgEnum("platform", ["facebook", "instagram"]);
-export const postTypeEnum = pgEnum("post_type", ["feed", "story", "both"]);
+export const postTypeEnum = pgEnum("post_type", ["feed", "story", "both", "reel"]);
export const postStatusEnum = pgEnum("post_status", ["draft", "scheduled", "published", "failed"]);
export const mediaTypeEnum = pgEnum("media_type", ["image", "video"]);
export const userRoleEnum = pgEnum("user_role", ["admin", "user"]);
diff --git a/task.md b/task.md
index 5659d63..d65c433 100644
--- a/task.md
+++ b/task.md
@@ -1,50 +1,51 @@
-# Task: Socialflow - Analytics & Token Management
+# Task: Socialflow - Facebook Reels avec Musique et Texte
## Context
-Implementation of a robust Facebook Page Token management system (automatic renewal) and a new Facebook Analytics module (ingestion & dashboard) to improve platform reliability and value.
+Ajout d'une fonctionnalité complète de création de Reels Facebook permettant de capturer une vidéo, ajouter une musique libre de droits (Jamendo API), superposer du texte style TikTok, et publier via l'API Graph Facebook. Le traitement vidéo sera effectué via une API FFmpeg Docker locale.
## Current Focus
-Planning and Architecture Definition.
+**Phase: COMPLETED** - Implémentation terminée, prêt pour configuration et tests.
## Master Plan
-### Phase 1: Foundation & Data
+### Phase 0: Clarifications ✅
-- [x] Requirements & Architecture Analysis
-- [x] **Review Implementation Plan**
-- [x] [BACKEND] Update Database Schema (Analytics tables, SocialPages extension)
-- [ ] [BACKEND] Run Drizzle Migration (Pending DB connectivity)
+- [x] Confirmer source de musique → Jamendo API
+- [x] Confirmer URL et format API FFmpeg Docker → `/process-reel`, base64, X-API-Key
+- [x] Style texte → TikTok (animation mot par mot)
-### Phase 2: Core Services
+### Phase 1: Backend Services ✅
-- [x] [NEW] `server/services/token_manager.ts` (Encryption + Refresh Logic)
-- [x] [NEW] `server/services/analytics.ts` (Graph API Fetch, DB Store)
-- [x] [NEW] `server/utils/graph_client.ts` (Typed Graph API Client)
-- [x] [MODIFY] `server/routes.ts` - Register analytics routes
-- [x] [NEW] `server/cron.ts` - Setup Daily Token Check
+- [x] `server/services/ffmpeg.ts` - Intégration API FFmpeg Docker
+- [x] `server/services/jamendo.ts` - Service recherche musique Jamendo
+- [x] `server/services/facebook.ts` - Méthodes `publishReel()` et `publishReelFromBuffer()`
-### Phase 3: Frontend
+### Phase 2: Database & Routes ✅
-- [x] [NEW] `client/src/components/analytics/AnalyticsDashboard.tsx`
-- [x] [NEW] `client/src/components/analytics/MetricsCard.tsx`
-- [x] [NEW] `client/src/pages/AnalyticsPage.tsx` & Mobile Version
-- [x] [MODIFY] `client/src/App.tsx` - Add /analytics route
-- [x] [MODIFY] `client/src/components/sidebar.tsx` - Add navigation link
+- [x] `shared/schema.ts` - Ajout type "reel" à postTypeEnum
+- [x] `server/routes/reels.ts` - Routes API complètes
+- [x] `server/routes.ts` - Enregistrement du reelsRouter
-### Phase 4: Verification
+### Phase 3: Frontend ✅
-### Phase 4: Verification & Stabilization
+- [x] `client/src/pages/new-reel.tsx` - Page création Reel (workflow 4 étapes)
+- [x] `client/src/App.tsx` - Route `/reel`
+- [x] `client/src/components/sidebar.tsx` - Lien navigation "Nouveau Reel"
-- [x] [TEST] Verify implementation builds correctly (`npm run build`)
-- [x] [FIX] **Deployment**: Implemented automated safe DB migration on startup
-- [x] [FIX] **Encryption**: Unified TokenManager encryption with storage service (Fixed Invalid Token error)
-- [x] [FIX] **Resilience**: Added auto-expiration for invalid tokens in `AnalyticsService`
-- [x] [FIX] **Feedback**: Improved error reporting in `AnalyticsService`
-- [ ] [MANUAL] Validate Analytics Data accuracy through UI
+### Phase 4: Configuration Requise ⏳
+
+- [ ] Configurer Jamendo Client ID (variable d'environnement)
+- [ ] Configurer FFmpeg API URL et clé (variable d'environnement)
+- [ ] Ajouter la valeur 'reel' à l'enum post_type en base de données
## Progress Log
-- **20 Jan 2026** - Architecture defined: Service-based approach, Encryption for tokens, Node-cron for scheduling.
-- **20 Jan 2026** - **Build Fixed**: Resolved circular dependencies (`db` import) and frontend type errors.
+- **22 Jan 2026** - Analyse complète de l'application Socialflow effectuée
+- **22 Jan 2026** - PRD créé avec architecture proposée
+- **22 Jan 2026** - Spécifications confirmées : Jamendo API, FFmpeg Docker, style TikTok
+- **22 Jan 2026** - Backend complet : ffmpeg.ts, jamendo.ts, facebook.ts (publishReel)
+- **22 Jan 2026** - Routes API créées dans server/routes/reels.ts
+- **22 Jan 2026** - Frontend créé : new-reel.tsx avec workflow 4 étapes
+- **22 Jan 2026** - Navigation mise à jour : route et lien sidebar