mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-18 12:46:43 +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
@@ -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<Server> {
|
||||
// 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) => {
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
+247
-28
@@ -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<string> {
|
||||
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<string> {
|
||||
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();
|
||||
|
||||
@@ -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<boolean> {
|
||||
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();
|
||||
@@ -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<string, string> = {
|
||||
'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<MusicTrack[]> {
|
||||
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<MusicTrack | null> {
|
||||
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<MusicTrack[]> {
|
||||
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<boolean> {
|
||||
try {
|
||||
const tracks = await this.getPopularTracks(1);
|
||||
return tracks.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const jamendoService = new JamendoService();
|
||||
Reference in new issue
Block a user