mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
Facebook resumable upload START/FINISH phases require multipart/form-data (not application/x-www-form-urlencoded). Add full raw response logging for each phase to diagnose any remaining issues (subcode, etc). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
747 lines
26 KiB
TypeScript
747 lines
26 KiB
TypeScript
import fs from 'fs';
|
|
import path from 'path';
|
|
import { storage } from '../storage';
|
|
import type { Post, SocialPage, Media } from '@shared/schema';
|
|
import { imageProcessor } from './imageProcessor';
|
|
import { cloudinaryService } from './cloudinary';
|
|
import { resolvePublicUrl } from './minio';
|
|
|
|
interface FacebookPhotoResponse {
|
|
id: string;
|
|
post_id: string;
|
|
}
|
|
|
|
interface FacebookFeedResponse {
|
|
id: string;
|
|
}
|
|
|
|
interface FacebookVideoResponse {
|
|
id: string;
|
|
post_id?: string;
|
|
}
|
|
|
|
interface FacebookError {
|
|
error: {
|
|
message: string;
|
|
type: string;
|
|
code: number;
|
|
};
|
|
}
|
|
|
|
export class FacebookService {
|
|
private baseUrl = 'https://graph.facebook.com/v23.0';
|
|
|
|
async publishPost(post: Post, page: SocialPage, postType: string, mediaList: Media[] = []): Promise<string> {
|
|
if (page.platform !== 'facebook') {
|
|
throw new Error('This service only supports Facebook pages');
|
|
}
|
|
|
|
if (!page.accessToken) {
|
|
throw new Error('No access token found for this page');
|
|
}
|
|
|
|
// Handle different post types
|
|
// Note: "both" type should never reach here - new posts are split at creation
|
|
// If it does reach here (legacy data), throw an error
|
|
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 if (postType === 'reel') {
|
|
// Publish as Reel
|
|
if (mediaList.length === 0) {
|
|
throw new Error('Reels require a video');
|
|
}
|
|
|
|
const videoMedia = mediaList.find(m => m.type === 'video');
|
|
if (!videoMedia) {
|
|
throw new Error('Reels require a video media');
|
|
}
|
|
|
|
// Upload video bytes directly — Facebook cannot fetch local/Docker URLs
|
|
const localPath = videoMedia.originalUrl.startsWith('/')
|
|
? path.join(process.cwd(), videoMedia.originalUrl)
|
|
: videoMedia.originalUrl;
|
|
const nodeBuffer = await fs.promises.readFile(localPath);
|
|
return await this.publishVideoFromBuffer(page, nodeBuffer, post.content);
|
|
} 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);
|
|
} else if (imageMedia.length === 1) {
|
|
// Single image (even if mixed with videos, prioritize the image)
|
|
return await this.publishPhotoPost(post, page, imageMedia[0]);
|
|
} else if (videoMedia.length >= 1) {
|
|
// Video post - use video endpoint
|
|
return await this.publishVideoPost(post, page, videoMedia[0]);
|
|
} else {
|
|
// Text only
|
|
return await this.publishTextPost(post, page);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async publishTextPost(post: Post, page: SocialPage): Promise<string> {
|
|
const params = new URLSearchParams({
|
|
access_token: page.accessToken!,
|
|
message: post.content,
|
|
});
|
|
|
|
const url = `${this.baseUrl}/${page.pageId}/feed?${params.toString()}`;
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json() as FacebookError;
|
|
throw new Error(`Facebook API error: ${error.error.message} (code: ${error.error.code})`);
|
|
}
|
|
|
|
const data = await response.json() as FacebookFeedResponse;
|
|
return data.id;
|
|
}
|
|
|
|
private async publishPhotoPost(post: Post, page: SocialPage, media: Media): Promise<string> {
|
|
// Use original URL - Facebook handles the cropping
|
|
const photoUrl = resolvePublicUrl(media.originalUrl);
|
|
|
|
const params = new URLSearchParams({
|
|
access_token: page.accessToken!,
|
|
message: post.content,
|
|
url: photoUrl,
|
|
});
|
|
|
|
const url = `${this.baseUrl}/${page.pageId}/photos?${params.toString()}`;
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json() as FacebookError;
|
|
throw new Error(`Facebook API error: ${error.error.message} (code: ${error.error.code})`);
|
|
}
|
|
|
|
const data = await response.json() as FacebookPhotoResponse;
|
|
// Return the post_id which is the full post identifier
|
|
return data.post_id || data.id;
|
|
}
|
|
|
|
/**
|
|
* Generates a Facebook-compatible video URL with Cloudinary transformations
|
|
* Facebook requires: H.264 video codec, AAC audio codec, MP4 container
|
|
*/
|
|
private getFacebookCompatibleVideoUrl(originalUrl: string): string {
|
|
// 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);
|
|
return originalUrl;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private async publishVideoPost(post: Post, page: SocialPage, media: Media): Promise<string> {
|
|
// Transform video URL to ensure Facebook compatibility (H.264/AAC/MP4)
|
|
const videoUrl = this.getFacebookCompatibleVideoUrl(resolvePublicUrl(media.originalUrl));
|
|
|
|
const params = new URLSearchParams({
|
|
access_token: page.accessToken!,
|
|
description: post.content,
|
|
file_url: videoUrl,
|
|
});
|
|
|
|
const url = `${this.baseUrl}/${page.pageId}/videos?${params.toString()}`;
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json() as FacebookError;
|
|
throw new Error(`Facebook API error publishing video: ${error.error.message} (code: ${error.error.code})`);
|
|
}
|
|
|
|
const data = await response.json() as FacebookVideoResponse;
|
|
// Return the post_id or id
|
|
return data.post_id || data.id;
|
|
}
|
|
|
|
private async publishMultiPhotoPost(post: Post, page: SocialPage, mediaList: Media[]): Promise<string> {
|
|
// Validate that all media are images
|
|
const imageMedia = mediaList.filter(m => m.type === 'image');
|
|
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`);
|
|
}
|
|
|
|
// Step 1: Upload all photos as unpublished and collect photo IDs
|
|
const photoIds: string[] = [];
|
|
|
|
for (const media of imageMedia) {
|
|
// Use original URL - Facebook handles the cropping for carousel
|
|
const photoUrl = resolvePublicUrl(media.originalUrl);
|
|
|
|
if (!photoUrl) {
|
|
console.warn(`Skipping media ${media.id} - no valid URL found`);
|
|
continue;
|
|
}
|
|
|
|
const uploadParams = new URLSearchParams({
|
|
access_token: page.accessToken!,
|
|
url: photoUrl,
|
|
published: 'false',
|
|
});
|
|
|
|
const uploadUrl = `${this.baseUrl}/${page.pageId}/photos?${uploadParams.toString()}`;
|
|
|
|
const uploadResponse = await fetch(uploadUrl, {
|
|
method: 'POST',
|
|
});
|
|
|
|
if (!uploadResponse.ok) {
|
|
const error = await uploadResponse.json() as FacebookError;
|
|
throw new Error(`Facebook API error uploading photo: ${error.error.message} (code: ${error.error.code})`);
|
|
}
|
|
|
|
const uploadData = await uploadResponse.json() as FacebookPhotoResponse;
|
|
photoIds.push(uploadData.id);
|
|
}
|
|
|
|
if (photoIds.length === 0) {
|
|
throw new Error('No photos were successfully uploaded');
|
|
}
|
|
|
|
// Step 2: Create feed post with all attached photos
|
|
const feedParams = new URLSearchParams({
|
|
access_token: page.accessToken!,
|
|
message: post.content,
|
|
});
|
|
|
|
// Add each photo as attached_media
|
|
photoIds.forEach((photoId, index) => {
|
|
feedParams.append(`attached_media[${index}]`, JSON.stringify({ media_fbid: photoId }));
|
|
});
|
|
|
|
const feedUrl = `${this.baseUrl}/${page.pageId}/feed?${feedParams.toString()}`;
|
|
|
|
const feedResponse = await fetch(feedUrl, {
|
|
method: 'POST',
|
|
});
|
|
|
|
if (!feedResponse.ok) {
|
|
const error = await feedResponse.json() as FacebookError;
|
|
throw new Error(`Facebook API error creating feed post: ${error.error.message} (code: ${error.error.code})`);
|
|
}
|
|
|
|
const feedData = await feedResponse.json() as FacebookFeedResponse;
|
|
return feedData.id;
|
|
}
|
|
|
|
async schedulePost(post: Post, page: SocialPage, scheduledTime: Date, media?: Media): Promise<string> {
|
|
if (page.platform !== 'facebook') {
|
|
throw new Error('This service only supports Facebook pages');
|
|
}
|
|
|
|
if (!page.accessToken) {
|
|
throw new Error('No access token found for this page');
|
|
}
|
|
|
|
// Facebook requires Unix timestamp for scheduled posts
|
|
const scheduledTimestamp = Math.floor(scheduledTime.getTime() / 1000);
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
// Facebook requires scheduled time to be at least 10 minutes in the future
|
|
if (scheduledTimestamp < now + 600) {
|
|
throw new Error('Scheduled time must be at least 10 minutes in the future');
|
|
}
|
|
|
|
// Maximum 6 months in the future
|
|
const sixMonthsFromNow = now + (6 * 30 * 24 * 60 * 60);
|
|
if (scheduledTimestamp > sixMonthsFromNow) {
|
|
throw new Error('Scheduled time cannot be more than 6 months in the future');
|
|
}
|
|
|
|
// For scheduled posts with photos
|
|
if (media) {
|
|
return await this.schedulePhotoPost(post, page, scheduledTimestamp, media);
|
|
} else {
|
|
return await this.scheduleTextPost(post, page, scheduledTimestamp);
|
|
}
|
|
}
|
|
|
|
private async scheduleTextPost(post: Post, page: SocialPage, scheduledTimestamp: number): Promise<string> {
|
|
const params = new URLSearchParams({
|
|
access_token: page.accessToken!,
|
|
message: post.content,
|
|
published: '0',
|
|
scheduled_publish_time: scheduledTimestamp.toString(),
|
|
});
|
|
|
|
const url = `${this.baseUrl}/${page.pageId}/feed?${params.toString()}`;
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json() as FacebookError;
|
|
throw new Error(`Facebook API error: ${error.error.message} (code: ${error.error.code})`);
|
|
}
|
|
|
|
const data = await response.json() as FacebookFeedResponse;
|
|
return data.id;
|
|
}
|
|
|
|
private async schedulePhotoPost(post: Post, page: SocialPage, scheduledTimestamp: number, media: Media): Promise<string> {
|
|
// Use original URL - Facebook handles the cropping
|
|
const photoUrl = resolvePublicUrl(media.originalUrl);
|
|
|
|
const params = new URLSearchParams({
|
|
access_token: page.accessToken!,
|
|
message: post.content,
|
|
url: photoUrl,
|
|
published: '0',
|
|
scheduled_publish_time: scheduledTimestamp.toString(),
|
|
});
|
|
|
|
const url = `${this.baseUrl}/${page.pageId}/photos?${params.toString()}`;
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json() as FacebookError;
|
|
throw new Error(`Facebook API error: ${error.error.message} (code: ${error.error.code})`);
|
|
}
|
|
|
|
const data = await response.json() as FacebookPhotoResponse;
|
|
return data.post_id || data.id;
|
|
}
|
|
|
|
private async publishStory(post: Post, page: SocialPage, media: Media): Promise<string> {
|
|
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
|
|
|
|
let photoUrl = resolvePublicUrl(media.originalUrl);
|
|
|
|
if (post.content && post.content.trim().length > 0) {
|
|
try {
|
|
const imageWithText = await imageProcessor.addTextToStoryImage(resolvePublicUrl(media.originalUrl), post.content);
|
|
photoUrl = await cloudinaryService.uploadStoryImageWithText(imageWithText, `story-${media.id}.png`);
|
|
console.log('Story image with text generated:', photoUrl);
|
|
} catch (error) {
|
|
console.error('Error generating story image with text, using original:', error);
|
|
}
|
|
}
|
|
|
|
// Step 1: Upload photo as unpublished
|
|
const uploadParams = new URLSearchParams({
|
|
access_token: page.accessToken!,
|
|
url: photoUrl,
|
|
published: 'false', // Important: must be unpublished first
|
|
});
|
|
|
|
const uploadUrl = `${this.baseUrl}/${page.pageId}/photos?${uploadParams.toString()}`;
|
|
|
|
const uploadResponse = await fetch(uploadUrl, {
|
|
method: 'POST',
|
|
});
|
|
|
|
if (!uploadResponse.ok) {
|
|
const error = await uploadResponse.json() as FacebookError;
|
|
throw new Error(`Facebook API error uploading photo for story: ${error.error.message} (code: ${error.error.code})`);
|
|
}
|
|
|
|
const uploadData = await uploadResponse.json() as FacebookPhotoResponse;
|
|
const photoId = uploadData.id;
|
|
|
|
// Step 2: Publish the photo as a story
|
|
const storyParams = new URLSearchParams({
|
|
access_token: page.accessToken!,
|
|
photo_id: photoId,
|
|
});
|
|
|
|
const storyUrl = `${this.baseUrl}/${page.pageId}/photo_stories?${storyParams.toString()}`;
|
|
|
|
const storyResponse = await fetch(storyUrl, {
|
|
method: 'POST',
|
|
});
|
|
|
|
if (!storyResponse.ok) {
|
|
const error = await storyResponse.json() as FacebookError;
|
|
throw new Error(`Facebook API error publishing story: ${error.error.message} (code: ${error.error.code})`);
|
|
}
|
|
|
|
const storyData = await storyResponse.json() as { success: boolean; post_id: string };
|
|
return storyData.post_id;
|
|
}
|
|
|
|
private async publishVideoStory(post: Post, page: SocialPage, media: Media): Promise<string> {
|
|
// Video stories require a 3-phase upload process:
|
|
// 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 = resolvePublicUrl(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, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `OAuth ${accessToken}`,
|
|
'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');
|
|
}
|
|
|
|
console.log('🎬 Publishing Reel to Facebook Feed & Reels via /videos endpoint:', {
|
|
pageId: page.pageId,
|
|
pageName: page.pageName,
|
|
hasDescription: !!description,
|
|
});
|
|
|
|
// Use the /videos endpoint instead of /video_reels
|
|
// This ensures the video appears on the Page Timeline (Feed) AND in Reels (if 9:16)
|
|
const params = new URLSearchParams({
|
|
access_token: page.accessToken,
|
|
description: description || '',
|
|
file_url: videoUrl,
|
|
});
|
|
|
|
const url = `${this.baseUrl}/${page.pageId}/videos?${params.toString()}`;
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json() as FacebookError;
|
|
throw new Error(`Facebook API error publishing video/reel: ${error.error.message} (code: ${error.error.code})`);
|
|
}
|
|
|
|
const data = await response.json() as FacebookVideoResponse;
|
|
const postId = data.post_id || data.id;
|
|
|
|
console.log('✅ Reel published successfully to Feed & Reels! ID:', postId);
|
|
|
|
return postId;
|
|
}
|
|
|
|
/**
|
|
* Publie une vidéo sur Facebook via l'API Resumable Upload (start → transfer → finish).
|
|
* Obligatoire pour les fichiers > ~50 MB. Plus fiable que l'upload multipart simple.
|
|
*/
|
|
async publishVideoFromBuffer(
|
|
page: SocialPage,
|
|
videoBuffer: Buffer | ArrayBuffer,
|
|
description?: string
|
|
): Promise<string> {
|
|
if (!page.accessToken) throw new Error('No access token found for this page');
|
|
|
|
const buf = Buffer.isBuffer(videoBuffer) ? videoBuffer : Buffer.from(videoBuffer);
|
|
const fileSize = buf.length;
|
|
const endpoint = `${this.baseUrl}/${page.pageId}/videos`;
|
|
|
|
console.log('🎬 Publishing video via Resumable Upload API:', {
|
|
pageId: page.pageId,
|
|
pageName: page.pageName,
|
|
size: `${(fileSize / 1024 / 1024).toFixed(2)} MB`,
|
|
});
|
|
|
|
// ── Phase 1: Start (multipart/form-data, as required by FB docs) ──────────
|
|
const startForm = new FormData();
|
|
startForm.append('access_token', page.accessToken);
|
|
startForm.append('upload_phase', 'start');
|
|
startForm.append('file_size', fileSize.toString());
|
|
|
|
const startRes = await fetch(endpoint, { method: 'POST', body: startForm });
|
|
const startRaw = await startRes.text();
|
|
console.log('📤 Facebook START response:', startRaw);
|
|
const startData = JSON.parse(startRaw) as any;
|
|
if (!startRes.ok || startData.error) {
|
|
throw new Error(`Facebook upload START error: ${startData.error?.message ?? startRaw} (code: ${startData.error?.code}, subcode: ${startData.error?.error_subcode})`);
|
|
}
|
|
const { upload_session_id, video_id } = startData;
|
|
let startOffset: number = parseInt(startData.start_offset ?? '0', 10);
|
|
let endOffset: number = parseInt(startData.end_offset ?? fileSize.toString(), 10);
|
|
console.log(`📤 Upload session: ${upload_session_id}, video_id: ${video_id}, first chunk: ${startOffset}-${endOffset}`);
|
|
|
|
// ── Phase 2: Transfer (chunked) ───────────────────────────────────────────
|
|
while (startOffset < fileSize) {
|
|
const chunk = buf.slice(startOffset, endOffset);
|
|
const chunkForm = new FormData();
|
|
chunkForm.append('access_token', page.accessToken);
|
|
chunkForm.append('upload_phase', 'transfer');
|
|
chunkForm.append('upload_session_id', upload_session_id);
|
|
chunkForm.append('start_offset', startOffset.toString());
|
|
chunkForm.append('video_file_chunk', new Blob([chunk], { type: 'video/mp4' }), 'chunk.mp4');
|
|
|
|
const transferRes = await fetch(endpoint, { method: 'POST', body: chunkForm });
|
|
const transferRaw = await transferRes.text();
|
|
const transferData = JSON.parse(transferRaw) as any;
|
|
if (!transferRes.ok || transferData.error) {
|
|
throw new Error(`Facebook upload TRANSFER error at offset ${startOffset}: ${transferData.error?.message ?? transferRaw}`);
|
|
}
|
|
|
|
const nextStart = parseInt(transferData.start_offset ?? fileSize.toString(), 10);
|
|
const nextEnd = parseInt(transferData.end_offset ?? fileSize.toString(), 10);
|
|
console.log(`📤 Transferred up to ${endOffset}/${fileSize} bytes, next: ${nextStart}-${nextEnd}`);
|
|
if (nextStart >= fileSize || nextStart === startOffset) break;
|
|
startOffset = nextStart;
|
|
endOffset = nextEnd;
|
|
}
|
|
|
|
// ── Phase 3: Finish (multipart/form-data) ─────────────────────────────────
|
|
const finishForm = new FormData();
|
|
finishForm.append('access_token', page.accessToken);
|
|
finishForm.append('upload_phase', 'finish');
|
|
finishForm.append('upload_session_id', upload_session_id);
|
|
if (description) finishForm.append('description', description);
|
|
|
|
const finishRes = await fetch(endpoint, { method: 'POST', body: finishForm });
|
|
const finishRaw = await finishRes.text();
|
|
console.log('📤 Facebook FINISH response:', finishRaw);
|
|
const finishData = JSON.parse(finishRaw) as any;
|
|
if (!finishRes.ok || finishData.error) {
|
|
throw new Error(`Facebook upload FINISH error: ${finishData.error?.message ?? finishRaw} (code: ${finishData.error?.code})`);
|
|
}
|
|
|
|
const postId = finishData.post_id || finishData.video_id || video_id;
|
|
console.log('✅ Video published via Resumable Upload! ID:', postId);
|
|
return postId;
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
|