mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-19 13:08: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
+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