Retrait de l'ancien serveur Node/TypeScript

Le serveur Rust (server/src) le remplace entièrement : routes, services,
migrations Drizzle et scripts SQL d'initialisation sont supprimés. Le
schéma est désormais créé et migré par server/src/migrate.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ
This commit is contained in:
Claude committed 2026-10-12 17:49:03 +00:00
1 parent a0f800101c
commit b451826ec5
54 files changed
+2 -13174

No files matched your search

-297
View File
@@ -1,297 +0,0 @@
import { db } from '../db';
import {
posts,
scheduledPosts,
postAnalytics,
pageAnalyticsHistory,
socialPages,
tokenStatusEnum
} from '../../shared/schema';
import { eq, and, isNotNull, desc, gte, lt } from 'drizzle-orm';
import { GraphAPIClient } from '../utils/graph_client';
import { TokenManager } from './token_manager';
export class AnalyticsService {
/**
* Fetches latest metrics for a specific post from Facebook/Instagram
* and updates the local postAnalytics table.
* Handles aggregation if a single Post content was published to multiple destinations.
*/
static async syncPostAnalytics(postId: string) {
console.log(`[AnalyticsService] Syncing post ${postId}`);
// Find all published instances of this post with an external ID
const publishedInstances = await db.query.scheduledPosts.findMany({
where: and(
eq(scheduledPosts.postId, postId),
isNotNull(scheduledPosts.externalPostId)
),
with: {
page: true
}
});
if (publishedInstances.length === 0) {
console.log(`[AnalyticsService] No published external posts found for ${postId}`);
return;
}
let totalImpressions = 0;
let totalReach = 0;
let totalEngagement = 0;
let totalReactions = 0;
let totalComments = 0;
let totalShares = 0;
let totalClicks = 0;
const rawDataList: any[] = [];
for (const instance of publishedInstances) {
if (!instance.externalPostId || !instance.page) continue;
// Les métriques TikTok ne sont pas exposées par la Graph API
if (instance.page.platform === 'tiktok') continue;
try {
// We need the page access token
let accessToken = instance.page.accessToken;
// Decrypt if needed
if (accessToken.includes(':')) {
try { accessToken = TokenManager.decrypt(accessToken); } catch { }
}
// Determine fields based on platform (assuming Facbeook for now as per context)
// providing a generic list of fields
const fields = 'insights.metric(post_impressions,post_impressions_unique,post_engaged_users,post_clicks),shares,comments.summary(true),reactions.summary(true)';
const result = await GraphAPIClient.get<any>(instance.externalPostId, {
accessToken,
params: { fields }
});
rawDataList.push({ id: instance.externalPostId, data: result });
// Parse Metrics
// Note: Graph API structure for insights is complex (data array).
// For simplicity in this implementation, we will try to extract what we can.
// This is a simplified mapper.
const insights = result.insights?.data || [];
const findMetric = (name: string) => {
const m = insights.find((i: any) => i.name === name);
return m ? (m.values[0]?.value || 0) : 0;
};
const impressions = findMetric('post_impressions');
const reach = findMetric('post_impressions_unique');
const engagement = findMetric('post_engaged_users');
const clicks = findMetric('post_clicks'); // might be 'post_clicks_by_type'
const reactionCount = result.reactions?.summary?.total_count || 0;
const commentCount = result.comments?.summary?.total_count || 0;
const shareCount = result.shares?.count || 0;
totalImpressions += impressions;
totalReach += reach;
totalEngagement += engagement;
totalClicks += clicks; // Aggregation might be complex for unique values, but sum is okay for now
totalReactions += reactionCount;
totalComments += commentCount;
totalShares += shareCount;
} catch (error) {
console.error(`[AnalyticsService] Failed to fetch for instance ${instance.id}:`, error);
}
}
// Update DB
// Check if exists
const existing = await db.query.postAnalytics.findFirst({
where: eq(postAnalytics.postId, postId)
});
if (existing) {
await db.update(postAnalytics)
.set({
fetchedAt: new Date(),
impressions: totalImpressions,
reach: totalReach,
engagement: totalEngagement,
reactions: totalReactions,
comments: totalComments,
shares: totalShares,
clicks: totalClicks,
rawData: rawDataList
})
.where(eq(postAnalytics.id, existing.id));
} else {
await db.insert(postAnalytics).values({
postId,
fetchedAt: new Date(),
impressions: totalImpressions,
reach: totalReach,
engagement: totalEngagement,
reactions: totalReactions,
comments: totalComments,
shares: totalShares,
clicks: totalClicks,
rawData: rawDataList
});
}
}
/**
* Syncs page-level analytics (Followers, Reach, Engagement, Views)
* Each metric is fetched independently for maximum resilience.
*/
static async syncPageAnalytics(pageId: string) {
console.log(`[AnalyticsService] Syncing page ${pageId}`);
const page = await db.query.socialPages.findFirst({
where: eq(socialPages.id, pageId)
});
if (!page) return;
let accessToken = page.accessToken;
if (accessToken.includes(':')) {
try { accessToken = TokenManager.decrypt(accessToken); } catch { }
}
// 1. Fetch Basic Page Info (Followers/Fans) — Essential
// If this fails, the token is likely invalid → abort
let followers = page.followersCount || 0;
try {
const basicInfo = await GraphAPIClient.get<any>(page.pageId, {
accessToken,
params: { fields: 'fan_count' }
});
if (basicInfo.fan_count !== undefined) {
followers = basicInfo.fan_count;
}
} catch (error: any) {
console.error(`[AnalyticsService] Failed to fetch basic info for ${pageId}:`, error);
const tokenPreview = accessToken ? `${accessToken.substring(0, 10)}...` : 'undefined';
console.log(`[AnalyticsService] DEBUG: Token used: ${tokenPreview}`);
// Auth errors → mark token as expired and abort
if (error.code === 190 || error.code === 463 || error.code === 467) {
console.log(`[AnalyticsService] Marking token as EXPIRED for page ${pageId}`);
await db.update(socialPages)
.set({
tokenStatus: "expired",
lastTokenCheck: new Date()
})
.where(eq(socialPages.id, pageId));
return;
}
throw new Error(`Failed to fetch Basic Info: ${error.message || error}`);
}
// Helper: fetch a single page insight metric safely
const fetchInsight = async (metricName: string): Promise<number> => {
try {
const result = await GraphAPIClient.get<any>(
`${page.pageId}/insights/${metricName}`,
{
accessToken,
params: { period: 'day' }
}
);
const data = result.data || [];
if (data.length > 0) {
const values = data[0].values || [];
// Take the most recent value (last entry = yesterday)
return values.length > 0
? (values[values.length - 1]?.value || 0)
: 0;
}
return 0;
} catch (error: any) {
if (error.code === 100) {
console.warn(`[AnalyticsService] Metric "${metricName}" unavailable for ${pageId} (Code 100). Skipping.`);
} else {
console.warn(`[AnalyticsService] Failed to fetch "${metricName}" for ${pageId}:`, error.message || error);
}
return 0;
}
};
// 2. Fetch each insight independently (resilient)
const pageReach = await fetchInsight('page_impressions_unique');
const pageEngagement = await fetchInsight('page_post_engagements');
const pageViews = await fetchInsight('page_views_total');
console.log(`[AnalyticsService] Metrics: followers=${followers}, reach=${pageReach}, engagement=${pageEngagement}, views=${pageViews}`);
// 3. Update Page — auto-heal token status
await db.update(socialPages)
.set({
followersCount: followers,
tokenStatus: 'valid',
lastTokenCheck: new Date()
})
.where(eq(socialPages.id, pageId));
// 4. Upsert History snapshot — one entry per day per page
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const existingEntry = await db.query.pageAnalyticsHistory.findFirst({
where: and(
eq(pageAnalyticsHistory.pageId, pageId),
gte(pageAnalyticsHistory.date, today),
lt(pageAnalyticsHistory.date, tomorrow)
)
});
if (existingEntry) {
// Update today's existing entry with latest values
await db.update(pageAnalyticsHistory)
.set({
followersCount: followers,
pageReach,
pageViews,
pageEngagement,
date: new Date(),
})
.where(eq(pageAnalyticsHistory.id, existingEntry.id));
} else {
// Insert new entry for today
await db.insert(pageAnalyticsHistory).values({
pageId,
date: new Date(),
followersCount: followers,
pageReach,
pageViews,
pageEngagement,
});
}
console.log(`[AnalyticsService] Synced page ${pageId}: ${followers} followers, ${pageReach} reach, ${pageEngagement} engagement, ${pageViews} views`);
}
/**
* Syncs analytics for ALL active pages.
* Called by the CRON job.
*/
static async syncAllPages(): Promise<void> {
console.log('[AnalyticsService] Starting sync for all pages...');
// Les statistiques passent par la Graph API : les comptes TikTok en sont exclus
const allPages = (await db.query.socialPages.findMany())
.filter(page => page.platform !== 'tiktok');
for (const page of allPages) {
try {
await this.syncPageAnalytics(page.id);
} catch (error: any) {
console.error(`[AnalyticsService] Failed to sync page ${page.id}:`, error.message);
}
}
console.log(`[AnalyticsService] Sync complete for ${allPages.length} pages.`);
}
}
-204
View File
@@ -1,204 +0,0 @@
import { v2 as cloudinary } from 'cloudinary';
import { storage } from '../storage';
class CloudinaryService {
async uploadMedia(
file: Buffer | string,
fileName: string,
userId: string,
mimeType: string
): Promise<{
publicId: string;
originalUrl: string;
facebookFeedUrl: string | null;
instagramFeedUrl: string | null;
instagramStoryUrl: string | null;
}> {
// Get any available Cloudinary config (shared across all users)
const config = await storage.getCloudinaryConfig();
if (!config) {
throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.');
}
// Configure Cloudinary with user's credentials
cloudinary.config({
cloud_name: config.cloudName,
api_key: config.apiKey,
api_secret: config.apiSecret,
secure: true,
});
const isVideo = mimeType.startsWith('video/');
// Define eager transformations for images (generated during upload)
// Note: Eager transformations are not applied to videos in this flow to avoid timeouts or huge processing
const eagerTransformations = !isVideo ? [
{ width: 1080, height: 1080, crop: 'fill', gravity: 'center' }, // Facebook Feed (square, no borders)
{ width: 1080, height: 1080, crop: 'pad', gravity: 'center', background: 'auto' }, // Instagram Feed
{ width: 1080, height: 1920, crop: 'pad', gravity: 'center', background: 'auto' } // Instagram Story
] : undefined;
const uploadOptions = {
folder: 'social-flow',
public_id: `${Date.now()}-${fileName.replace(/\.[^/.]+$/, '')}`,
resource_type: isVideo ? 'video' : 'image',
eager: eagerTransformations,
eager_async: false, // Generate transformations immediately
};
let uploadResult;
if (typeof file === 'string') {
// Upload from file path (streamed by SDK)
uploadResult = await cloudinary.uploader.upload(file, {
...uploadOptions,
resource_type: isVideo ? 'video' : 'image', // Type assertion for SDK
} as any);
} else {
// Upload from buffer (memory)
uploadResult = await new Promise<any>((resolve, reject) => {
const uploadStream = cloudinary.uploader.upload_stream(
uploadOptions as any,
(error, result) => {
if (error) reject(error);
else resolve(result);
}
);
uploadStream.end(file);
});
}
const publicId = uploadResult.public_id;
const originalUrl = uploadResult.secure_url;
// Extract eager transformation URLs (generated by Cloudinary during upload)
let facebookFeedUrl = null;
let instagramFeedUrl = null;
let instagramStoryUrl = null;
if (!isVideo && uploadResult.eager && uploadResult.eager.length === 3) {
facebookFeedUrl = uploadResult.eager[0].secure_url; // Facebook Feed (1200x630)
instagramFeedUrl = uploadResult.eager[1].secure_url; // Instagram Feed (1080x1080)
instagramStoryUrl = uploadResult.eager[2].secure_url; // Instagram Story (1080x1920)
}
return {
publicId,
originalUrl,
facebookFeedUrl,
instagramFeedUrl,
instagramStoryUrl,
};
}
async deleteMedia(publicId: string, userId: string, mediaType: 'image' | 'video'): Promise<void> {
// Get any available Cloudinary config (shared across all users)
const config = await storage.getCloudinaryConfig();
if (!config) {
throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.');
}
cloudinary.config({
cloud_name: config.cloudName,
api_key: config.apiKey,
api_secret: config.apiSecret,
secure: true,
});
await cloudinary.uploader.destroy(publicId, {
resource_type: mediaType,
});
}
async uploadStoryImageWithText(buffer: Buffer, originalFileName: string): Promise<string> {
const config = await storage.getCloudinaryConfig();
if (!config) {
throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.');
}
cloudinary.config({
cloud_name: config.cloudName,
api_key: config.apiKey,
api_secret: config.apiSecret,
secure: true,
});
const uploadResult = await new Promise<any>((resolve, reject) => {
const uploadStream = cloudinary.uploader.upload_stream(
{
folder: 'social-flow/stories',
public_id: `story-${Date.now()}-${originalFileName.replace(/\.[^/.]+$/, '')}`,
resource_type: 'image',
},
(error, result) => {
if (error) reject(error);
else resolve(result);
}
);
uploadStream.end(buffer);
});
return uploadResult.secure_url;
}
async uploadLogo(buffer: Buffer, fileName: string): Promise<{ publicId: string; url: string }> {
const config = await storage.getCloudinaryConfig();
if (!config) {
throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.');
}
cloudinary.config({
cloud_name: config.cloudName,
api_key: config.apiKey,
api_secret: config.apiSecret,
secure: true,
});
const uploadResult = await new Promise<any>((resolve, reject) => {
const uploadStream = cloudinary.uploader.upload_stream(
{
folder: 'social-flow/logos',
public_id: `logo-${Date.now()}-${fileName.replace(/\.[^/.]+$/, '')}`,
resource_type: 'image',
},
(error, result) => {
if (error) reject(error);
else resolve(result);
}
);
uploadStream.end(buffer);
});
return {
publicId: uploadResult.public_id,
url: uploadResult.secure_url,
};
}
async deleteLogo(publicId: string): Promise<void> {
const config = await storage.getCloudinaryConfig();
if (!config) {
throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.');
}
cloudinary.config({
cloud_name: config.cloudName,
api_key: config.apiKey,
api_secret: config.apiSecret,
secure: true,
});
await cloudinary.uploader.destroy(publicId, {
resource_type: 'image',
});
}
}
export const cloudinaryService = new CloudinaryService();
-238
View File
@@ -1,238 +0,0 @@
import { parse } from 'twemoji-parser';
import { createCanvas, loadImage, CanvasRenderingContext2D } from 'canvas';
import sharp from 'sharp';
interface EmojiSegment {
type: 'text' | 'emoji';
content: string;
url?: string;
}
/**
* Service to render text with emojis on canvas using Twemoji
* Converts emojis to images and renders them alongside text
*/
export class EmojiRenderer {
private emojiCache = new Map<string, Buffer>();
/**
* Parse text into segments of text and emojis
*/
private parseTextWithEmojis(text: string): EmojiSegment[] {
const emojis = parse(text);
if (emojis.length === 0) {
return [{ type: 'text', content: text }];
}
const segments: EmojiSegment[] = [];
let lastIndex = 0;
for (const emoji of emojis) {
// Add text before emoji
if (emoji.indices[0] > lastIndex) {
const textContent = text.substring(lastIndex, emoji.indices[0]);
if (textContent) {
segments.push({ type: 'text', content: textContent });
}
}
// Add emoji
segments.push({
type: 'emoji',
content: emoji.text,
url: emoji.url
});
lastIndex = emoji.indices[1];
}
// Add remaining text
if (lastIndex < text.length) {
const textContent = text.substring(lastIndex);
if (textContent) {
segments.push({ type: 'text', content: textContent });
}
}
return segments;
}
/**
* Download and cache emoji image
*/
async getEmojiImage(url: string): Promise<Buffer> {
if (this.emojiCache.has(url)) {
return this.emojiCache.get(url)!;
}
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch emoji: ${response.status} ${response.statusText}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
// Convert SVG to PNG using sharp because node-canvas struggles with SVGs lacking dimensions
const pngBuffer = await sharp(buffer).png().toBuffer();
this.emojiCache.set(url, pngBuffer);
return pngBuffer;
} catch (error) {
console.error(`Error downloading emoji from ${url}:`, error);
throw error;
}
}
/**
* Measure the width of text with emojis
*/
async measureText(text: string, ctx: CanvasRenderingContext2D, fontSize: number): Promise<number> {
const segments = this.parseTextWithEmojis(text);
let totalWidth = 0;
for (const segment of segments) {
if (segment.type === 'text') {
totalWidth += ctx.measureText(segment.content).width;
} else {
// Emoji width is approximately equal to font size
totalWidth += fontSize;
}
}
return totalWidth;
}
/**
* Draw text with emojis on canvas
*/
async drawText(
text: string,
ctx: CanvasRenderingContext2D,
x: number,
y: number,
fontSize: number
): Promise<void> {
const segments = this.parseTextWithEmojis(text);
let currentX = x;
for (const segment of segments) {
if (segment.type === 'text') {
ctx.fillText(segment.content, currentX, y);
currentX += ctx.measureText(segment.content).width;
} else if (segment.url) {
try {
const emojiBuffer = await this.getEmojiImage(segment.url);
const emojiImage = await loadImage(emojiBuffer);
// Draw emoji slightly above baseline to align with text
const emojiSize = fontSize;
const emojiY = y - fontSize * 0.8; // Adjust vertical position
ctx.drawImage(emojiImage, currentX, emojiY, emojiSize, emojiSize);
currentX += emojiSize;
} catch (error) {
console.error('Error loading emoji:', error);
// Fallback: draw text representation
ctx.fillText(segment.content, currentX, y);
currentX += ctx.measureText(segment.content).width;
}
}
}
}
/**
* Break a long word that exceeds maxWidth into chunks
*/
private async breakLongWord(
word: string,
ctx: CanvasRenderingContext2D,
maxWidth: number,
fontSize: number
): Promise<string[]> {
const chunks: string[] = [];
let currentChunk = '';
// Split word into individual characters (graphemes for emoji support)
const chars = Array.from(word);
for (const char of chars) {
const testChunk = currentChunk + char;
const width = await this.measureText(testChunk, ctx, fontSize);
if (width > maxWidth && currentChunk) {
chunks.push(currentChunk);
currentChunk = char;
} else {
currentChunk = testChunk;
}
}
if (currentChunk) {
chunks.push(currentChunk);
}
return chunks;
}
/**
* Wrap text with emojis into multiple lines
* Handles long words/emoji sequences by breaking them when needed
*/
async wrapText(
text: string,
ctx: CanvasRenderingContext2D,
maxWidth: number,
fontSize: number
): Promise<string[]> {
const words = text.split(' ');
const lines: string[] = [];
let currentLine = '';
for (const word of words) {
const testLine = currentLine ? `${currentLine} ${word}` : word;
const testWidth = await this.measureText(testLine, ctx, fontSize);
if (testWidth > maxWidth && currentLine) {
// Current line would exceed width, push it and start new line
lines.push(currentLine);
currentLine = word;
// Check if the word itself is too long for a single line
const wordWidth = await this.measureText(word, ctx, fontSize);
if (wordWidth > maxWidth) {
// Word is too long, need to break it into chunks
const chunks = await this.breakLongWord(word, ctx, maxWidth, fontSize);
for (let i = 0; i < chunks.length - 1; i++) {
lines.push(chunks[i]);
}
currentLine = chunks[chunks.length - 1];
}
} else if (testWidth > maxWidth && !currentLine) {
// First word is too long, need to break it
const chunks = await this.breakLongWord(word, ctx, maxWidth, fontSize);
for (let i = 0; i < chunks.length - 1; i++) {
lines.push(chunks[i]);
}
currentLine = chunks[chunks.length - 1];
} else {
currentLine = testLine;
}
}
if (currentLine) {
lines.push(currentLine);
}
return lines;
}
/**
* Clear emoji cache
*/
clearCache(): void {
this.emojiCache.clear();
}
}
// Singleton instance
export const emojiRenderer = new EmojiRenderer();
-840
View File
@@ -1,840 +0,0 @@
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 { minioService, 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');
}
// Use public URL so getMediaBuffer can fetch via HTTP if local file is missing
const publicUrl = resolvePublicUrl(videoMedia.originalUrl);
const nodeBuffer = await this.getMediaBuffer(publicUrl);
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 getMediaBuffer(originalUrl: string): Promise<Buffer> {
const localPath = originalUrl.startsWith('/')
? path.join(process.cwd(), originalUrl)
: originalUrl;
if (localPath.startsWith('http')) {
const response = await fetch(localPath);
if (!response.ok) throw new Error(`Failed to fetch media from ${localPath}`);
return Buffer.from(await response.arrayBuffer());
}
return fs.promises.readFile(localPath);
}
private async publishPhotoPost(post: Post, page: SocialPage, media: Media): Promise<string> {
const buffer = await this.getMediaBuffer(media.originalUrl);
const mimeType = media.originalUrl.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
const blob = new Blob([buffer], { type: mimeType });
const formData = new FormData();
formData.append('access_token', page.accessToken!);
if (post.content) {
formData.append('message', post.content);
}
formData.append('source', blob, path.basename(media.originalUrl));
const url = `${this.baseUrl}/${page.pageId}/photos`;
const response = await fetch(url, {
method: 'POST',
body: formData,
});
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) {
if (!media.originalUrl) {
console.warn(`Skipping media ${media.id} - no valid URL found`);
continue;
}
const buffer = await this.getMediaBuffer(media.originalUrl);
const mimeType = media.originalUrl.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
const blob = new Blob([buffer], { type: mimeType });
const formData = new FormData();
formData.append('access_token', page.accessToken!);
formData.append('published', 'false');
formData.append('source', blob, path.basename(media.originalUrl));
const uploadUrl = `${this.baseUrl}/${page.pageId}/photos`;
const uploadResponse = await fetch(uploadUrl, {
method: 'POST',
body: formData,
});
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> {
const buffer = await this.getMediaBuffer(media.originalUrl);
const mimeType = media.originalUrl.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
const blob = new Blob([buffer], { type: mimeType });
const formData = new FormData();
formData.append('access_token', page.accessToken!);
if (post.content) {
formData.append('message', post.content);
}
formData.append('published', '0');
formData.append('scheduled_publish_time', scheduledTimestamp.toString());
formData.append('source', blob, path.basename(media.originalUrl));
const url = `${this.baseUrl}/${page.pageId}/photos`;
const response = await fetch(url, {
method: 'POST',
body: formData,
});
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);
}
let photoBuffer = await this.getMediaBuffer(media.originalUrl);
let originalFilename = path.basename(media.originalUrl);
if (post.content && post.content.trim().length > 0) {
try {
const imageWithText = await imageProcessor.addTextToStoryImage(resolvePublicUrl(media.originalUrl), post.content);
await minioService.uploadStoryImageWithText(imageWithText, `story-${media.id}.png`);
photoBuffer = imageWithText;
originalFilename = `story-${media.id}.png`;
console.log('Story image with text generated locally');
} catch (error) {
console.error('Error generating story image with text, using original:', error);
}
}
const mimeType = originalFilename.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
const blob = new Blob([photoBuffer], { type: mimeType });
const formData = new FormData();
formData.append('access_token', page.accessToken!);
formData.append('published', 'false'); // Important: must be unpublished first
formData.append('source', blob, originalFilename);
const uploadUrl = `${this.baseUrl}/${page.pageId}/photos`;
const uploadResponse = await fetch(uploadUrl, {
method: 'POST',
body: formData,
});
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 en story Facebook en envoyant directement les données
* binaires (start → upload → finish sur /video_stories). Sert à doubler un
* Reel d'une story : la vidéo rendue est déjà en mémoire.
*/
async publishVideoStoryFromBuffer(page: SocialPage, videoBuffer: Buffer): Promise<string> {
if (!page.accessToken) throw new Error('No access token found for this page');
const accessToken = page.accessToken;
const fileSize = videoBuffer.length;
const startParams = new URLSearchParams({
access_token: accessToken,
upload_phase: 'start',
});
const startRes = await fetch(`${this.baseUrl}/${page.pageId}/video_stories?${startParams.toString()}`, {
method: 'POST',
});
if (!startRes.ok) {
const error = await startRes.json() as FacebookError;
throw new Error(`Facebook Story API error (START phase): ${error.error.message} (code: ${error.error.code})`);
}
const { video_id: videoId, upload_url: uploadUrl } = await startRes.json() as { video_id: string; upload_url: string };
const uploadRes = await fetch(uploadUrl, {
method: 'POST',
headers: {
'Authorization': `OAuth ${accessToken}`,
'Content-Type': 'application/octet-stream',
'offset': '0',
'file_size': fileSize.toString(),
},
body: new Blob([videoBuffer], { type: 'video/mp4' }),
});
if (!uploadRes.ok) {
throw new Error(`Facebook Story API error (UPLOAD phase): ${await uploadRes.text()}`);
}
const finishParams = new URLSearchParams({
access_token: accessToken,
upload_phase: 'finish',
video_id: videoId,
});
const finishRes = await fetch(`${this.baseUrl}/${page.pageId}/video_stories?${finishParams.toString()}`, {
method: 'POST',
});
if (!finishRes.ok) {
const error = await finishRes.json() as FacebookError;
throw new Error(`Facebook Story API error (FINISH phase): ${error.error.message} (code: ${error.error.code})`);
}
const finishData = await finishRes.json() as { success: boolean; post_id?: string };
console.log(`✅ Story vidéo publiée sur ${page.pageName}`);
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 comme Reel Facebook via l'API Reels (start → upload → finish).
* Utilise l'endpoint /video_reels pour que la vidéo soit reconnue comme un Reel.
*
* Spécifications vidéo requises pour les Reels :
* - Format : MP4, H.264
* - Ratio : 9:16 (vertical)
* - Durée : 3-90 secondes
* - Résolution recommandée : 1080x1920
*/
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 accessToken = page.accessToken;
console.log('🎬 Publishing video as Facebook Reel via /video_reels endpoint:', {
pageId: page.pageId,
pageName: page.pageName,
size: `${(fileSize / 1024 / 1024).toFixed(2)} MB`,
});
// ── Phase 1: START - Initialize the Reel upload ──────────────────────────
const startParams = new URLSearchParams({
access_token: accessToken,
upload_phase: 'start',
});
const startUrl = `${this.baseUrl}/${page.pageId}/video_reels?${startParams.toString()}`;
const startRes = await fetch(startUrl, { method: 'POST' });
const startRaw = await startRes.text();
console.log('📤 Facebook Reel START response:', startRaw);
const startData = JSON.parse(startRaw) as any;
if (!startRes.ok || startData.error) {
const subcode = startData.error?.error_subcode;
if (subcode === 1363042) {
throw new Error(
`Le token Facebook de la page "${page.pageName}" n'a pas la permission "publish_video". ` +
`Régénérez le token dans Graph API Explorer en ajoutant les permissions : ` +
`pages_manage_posts, pages_read_engagement, publish_video. ` +
`(code: ${startData.error?.code}, subcode: ${subcode})`
);
}
throw new Error(`Facebook Reel START error: ${startData.error?.message ?? startRaw} (code: ${startData.error?.code}, subcode: ${subcode})`);
}
const videoId = startData.video_id;
const uploadUrl = startData.upload_url;
console.log(`📤 Reel upload initialized: video_id=${videoId}, upload_url=${uploadUrl}`);
// ── Phase 2: UPLOAD - Upload binary data to the upload_url ───────────────
const videoBlob = new Blob([buf], { type: 'video/mp4' });
const uploadRes = await fetch(uploadUrl, {
method: 'POST',
headers: {
'Authorization': `OAuth ${accessToken}`,
'Content-Type': 'application/octet-stream',
'offset': '0',
'file_size': fileSize.toString(),
},
body: videoBlob,
});
if (!uploadRes.ok) {
const errorText = await uploadRes.text();
throw new Error(`Facebook Reel UPLOAD error: ${errorText}`);
}
console.log(`📤 Reel video data uploaded successfully (${fileSize} bytes)`);
// ── Phase 3: FINISH - Finalize and publish the Reel ──────────────────────
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 finishRes = await fetch(finishUrl, { method: 'POST' });
const finishRaw = await finishRes.text();
console.log('📤 Facebook Reel FINISH response:', finishRaw);
const finishData = JSON.parse(finishRaw) as any;
if (!finishRes.ok || finishData.error) {
throw new Error(`Facebook Reel FINISH error: ${finishData.error?.message ?? finishRaw} (code: ${finishData.error?.code})`);
}
const postId = finishData.post_id || videoId;
console.log('✅ Reel published successfully! 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();
-295
View File
@@ -1,295 +0,0 @@
/**
* Renouvellement des tokens Facebook.
*
* Le problème que ce service résout : un token de page saisi à la main finit
* toujours par mourir (60 jours pour un token issu du Graph Explorer, ou du
* jour au lendemain si le mot de passe change, si l'utilisateur perd son rôle
* d'admin, ou si Meta révoque l'autorisation) — et on ne l'apprend qu'au
* moment où une publication échoue.
*
* La parade est celle recommandée par Meta :
* 1. l'utilisateur autorise l'application (OAuth) ;
* 2. le code est échangé contre un token utilisateur court ;
* 3. ce token court est échangé contre un token utilisateur longue durée
* (~60 jours) — c'est lui que l'on conserve, chiffré ;
* 4. les tokens de page dérivés d'un token utilisateur longue durée
* n'expirent pas.
*
* Conserver le token utilisateur permet de régénérer un token de page sans
* intervention humaine, et de prolonger le token utilisateur lui-même avant
* son échéance : tant que l'utilisateur ne retire pas son autorisation, la
* chaîne se renouvelle toute seule.
*/
import type { Request } from 'express';
import { storage } from '../storage';
import { resolvePublicBaseUrl } from '../utils/public_url';
const GRAPH_VERSION = process.env.FACEBOOK_GRAPH_VERSION?.trim() || 'v19.0';
const GRAPH_URL = `https://graph.facebook.com/${GRAPH_VERSION}`;
/** Permissions nécessaires pour publier et lire les statistiques d'une page. */
const DEFAULT_SCOPES = [
'pages_show_list',
'pages_manage_posts',
'pages_read_engagement',
'pages_manage_engagement',
'read_insights',
'business_management',
'instagram_basic',
'instagram_content_publish',
].join(',');
export interface FacebookLongLivedToken {
accessToken: string;
/** Nul quand Facebook ne renvoie pas d'échéance (jeton sans expiration). */
expiresAt: Date | null;
}
export interface FacebookPageTarget {
id: string;
name: string;
accessToken: string;
followersCount: number;
/** Compte Instagram professionnel rattaché à la page, s'il existe. */
instagram: { id: string; name: string } | null;
}
/** Résultat de /debug_token : ce que Meta sait vraiment du jeton. */
export interface FacebookTokenInfo {
isValid: boolean;
/** Nul = n'expire pas (cas normal d'un token de page). */
expiresAt: Date | null;
scopes: string[];
error: string | null;
}
export class FacebookAuthError extends Error {
constructor(message: string) {
super(message);
this.name = 'FacebookAuthError';
}
}
export class FacebookOAuthService {
/**
* Identifiants de l'application Facebook (globaux à l'instance).
* La base prime sur les variables d'environnement.
*/
private async getCredentials(): Promise<{ appId: string; appSecret: string }> {
const config = await storage.getFacebookConfig();
const appId = config?.appId || process.env.FACEBOOK_APP_ID;
const appSecret = config?.appSecret || process.env.FACEBOOK_APP_SECRET;
if (!appId || !appSecret) {
throw new FacebookAuthError(
"Facebook n'est pas configuré. Renseignez l'App ID et l'App Secret de " +
'votre application Facebook dans les paramètres.'
);
}
return { appId, appSecret };
}
async isConfigured(): Promise<boolean> {
try {
await this.getCredentials();
return true;
} catch {
return false;
}
}
/**
* URI de redirection OAuth. `req` permet de la déduire du domaine réellement
* utilisé quand `APP_URL` n'est pas défini : sans lui on renverrait
* `http://localhost:5555`, que Facebook rejette.
*/
getRedirectUri(req?: Request): string {
if (process.env.FACEBOOK_REDIRECT_URI) {
return process.env.FACEBOOK_REDIRECT_URI;
}
return `${resolvePublicBaseUrl(req)}/api/facebook/callback`;
}
getScopes(): string {
return process.env.FACEBOOK_SCOPES || DEFAULT_SCOPES;
}
/** URL d'autorisation à ouvrir pour connecter les pages d'un compte Facebook. */
async buildAuthorizationUrl(state: string, req?: Request): Promise<string> {
const { appId } = await this.getCredentials();
const params = new URLSearchParams({
client_id: appId,
redirect_uri: this.getRedirectUri(req),
state,
scope: this.getScopes(),
response_type: 'code',
});
return `https://www.facebook.com/${GRAPH_VERSION}/dialog/oauth?${params.toString()}`;
}
/**
* Échange le code d'autorisation contre un token utilisateur **longue durée**.
* L'étape intermédiaire (token court) n'est jamais conservée : seul le token
* longue durée rend les tokens de page permanents.
*/
async exchangeCodeForUserToken(code: string, req?: Request): Promise<FacebookLongLivedToken> {
const { appId, appSecret } = await this.getCredentials();
const shortLived = await this.graphGet<{ access_token?: string }>('/oauth/access_token', {
client_id: appId,
client_secret: appSecret,
redirect_uri: this.getRedirectUri(req),
code,
}, "échange du code d'autorisation");
if (!shortLived.access_token) {
throw new FacebookAuthError('Facebook n\'a pas renvoyé de jeton pour ce code d\'autorisation.');
}
return this.extendUserToken(shortLived.access_token);
}
/**
* Prolonge un token utilisateur. Appelé au retour d'OAuth (token court →
* ~60 jours) mais aussi périodiquement : ré-échanger un token longue durée
* encore valide repart pour 60 jours, ce qui évite la reconnexion manuelle.
*/
async extendUserToken(userToken: string): Promise<FacebookLongLivedToken> {
const { appId, appSecret } = await this.getCredentials();
const longLived = await this.graphGet<{ access_token?: string; expires_in?: number }>(
'/oauth/access_token',
{
grant_type: 'fb_exchange_token',
client_id: appId,
client_secret: appSecret,
fb_exchange_token: userToken,
},
'prolongation du jeton utilisateur'
);
if (!longLived.access_token) {
throw new FacebookAuthError("Facebook n'a pas renvoyé de jeton longue durée.");
}
return {
accessToken: longLived.access_token,
// expires_in absent ou nul = jeton sans expiration connue.
expiresAt: longLived.expires_in
? new Date(Date.now() + longLived.expires_in * 1000)
: null,
};
}
/**
* Pages administrées par le compte, avec leur token de page.
* Ces tokens sont permanents lorsqu'ils dérivent d'un token utilisateur
* longue durée : c'est le cœur du renouvellement automatique.
*/
async listPages(userToken: string): Promise<FacebookPageTarget[]> {
const response = await this.graphGet<{
data?: {
id: string;
name: string;
access_token: string;
fan_count?: number;
followers_count?: number;
instagram_business_account?: { id: string; name?: string; username?: string };
}[];
}>(
'/me/accounts',
{
fields: 'id,name,access_token,fan_count,followers_count,instagram_business_account{id,name,username}',
limit: '200',
access_token: userToken,
},
'récupération des pages'
);
return (response.data ?? []).map((page) => ({
id: page.id,
name: page.name,
accessToken: page.access_token,
followersCount: page.followers_count ?? page.fan_count ?? 0,
instagram: page.instagram_business_account
? {
id: page.instagram_business_account.id,
name:
page.instagram_business_account.username ||
page.instagram_business_account.name ||
page.name,
}
: null,
}));
}
/**
* Interroge Meta sur l'état réel d'un jeton. Un simple appel /me ne dit pas
* quand le jeton expire : /debug_token le dit, ce qui permet de renouveler
* *avant* la panne plutôt qu'après.
*/
async inspectToken(token: string): Promise<FacebookTokenInfo> {
const { appId, appSecret } = await this.getCredentials();
const response = await this.graphGet<{
data?: {
is_valid?: boolean;
expires_at?: number;
scopes?: string[];
error?: { message?: string };
};
}>(
'/debug_token',
{
input_token: token,
// Jeton d'application : `app_id|app_secret`, accepté par /debug_token.
access_token: `${appId}|${appSecret}`,
},
'inspection du jeton'
);
const data = response.data ?? {};
return {
isValid: data.is_valid === true,
// `expires_at: 0` signifie « n'expire pas » chez Meta.
expiresAt: data.expires_at ? new Date(data.expires_at * 1000) : null,
scopes: data.scopes ?? [],
error: data.error?.message ?? null,
};
}
private async graphGet<T>(
endpoint: string,
params: Record<string, string>,
context: string
): Promise<T> {
const url = new URL(`${GRAPH_URL}${endpoint}`);
Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value));
let response: Response;
try {
response = await fetch(url.toString());
} catch (error) {
throw new FacebookAuthError(
`Facebook injoignable (${context}) : ${error instanceof Error ? error.message : 'erreur réseau'}`
);
}
const payload: any = await response.json().catch(() => ({}));
if (!response.ok || payload?.error) {
const message = payload?.error?.message || `HTTP ${response.status}`;
throw new FacebookAuthError(`Facebook (${context}) : ${message}`);
}
return payload as T;
}
}
export const facebookOAuthService = new FacebookOAuthService();
-356
View File
@@ -1,356 +0,0 @@
/**
* Client du service FFmpeg (conteneur Python `ffmpeg-service`).
*
* Le service rend le Reel puis expose le MP4 en téléchargement
* (GET /files/{job}/output.mp4) : la vidéo ne transite plus en base64 dans du
* JSON, qui gonflait sa taille d'un tiers et la gardait entière en mémoire.
*/
import fs from 'fs';
import { Readable } from 'stream';
import { pipeline } from 'stream/promises';
import type { ReadableStream as WebReadableStream } from 'stream/web';
import type { SrtCue } from '@shared/srt';
import type { TtsEngine, TtsStyle, VoiceOption } from '@shared/voices';
/** Un rendu long (stabilisation + encodage) peut dépasser plusieurs minutes. */
const PROCESS_TIMEOUT_MS = 20 * 60_000;
const DOWNLOAD_TIMEOUT_MS = 5 * 60_000;
// Qwen (local, CPU) met plusieurs fois la durée de la voix à la générer
const TTS_TIMEOUT_MS = 15 * 60_000;
const HEALTH_TIMEOUT_MS = 5_000;
export interface ReelRenderOptions {
text?: string;
/** Sous-titres SRT : remplacent `text`, la voix lit chacun à son instant. */
srtCues?: SrtCue[];
musicUrl?: string;
ttsEnabled?: boolean;
ttsVoice?: string;
ttsEngine?: TtsEngine;
ttsStyle?: TtsStyle;
geminiApiKey?: string;
qwen?: QwenTarget;
fontSize?: number;
musicVolume?: number;
drawText?: boolean;
stabilize?: boolean;
watermarkUrl?: string;
storeName?: string;
enableEndingEffect?: boolean;
/** Petit logo pendant la vidéo (le grand logo de fin dépend de enableEndingEffect). */
showLogo?: boolean;
}
export interface ReelRenderResult {
video: Buffer;
duration: number;
ttsEngine?: string | null;
ttsVoice?: string | null;
warnings: string[];
}
/** Rendu préparé pour Remotion : fichiers déjà écrits sur le disque. */
export interface PreparedReel {
videoPath: string;
audioPath: string | null;
totalDuration: number;
videoDuration: number;
logoStart: number | null;
/** Fondu final, calculé par le service pour ne jamais rogner la voix. */
fadeStart: number | null;
fadeDuration: number | null;
words: TimedWord[];
ttsEngine?: string | null;
ttsVoice?: string | null;
warnings: string[];
}
export interface TimedWord {
text: string;
start: number;
end: number;
}
/** Service Qwen TTS (local ou serveur GPU) réglé dans les paramètres. */
export interface QwenTarget {
url: string;
apiKey?: string;
}
export interface VoiceCatalog {
qwen: VoiceOption[];
qwen_available: boolean;
/** Modèle et carte graphique du service Qwen, null s'il ne répond pas. */
qwen_status: { model?: string; device?: string; busy?: boolean } | null;
gemini: VoiceOption[];
edge: VoiceOption[];
}
export interface VoicePreview {
audio: Buffer;
duration: number;
words: TimedWord[];
engine: string;
voice: string;
warnings: string[];
}
interface FFmpegConfig {
apiUrl: string;
apiKey: string;
}
/** Erreur renvoyée par le service, avec son message lisible. */
export class FFmpegServiceError extends Error {
constructor(message: string, readonly status?: number) {
super(message);
this.name = 'FFmpegServiceError';
}
}
export class FFmpegService {
private config: FFmpegConfig | null = null;
configure(apiUrl: string, apiKey: string): void {
this.config = { apiUrl: apiUrl.replace(/\/$/, ''), apiKey };
console.log('🎬 FFmpeg Service configured:', this.config.apiUrl);
}
private ensureConfigured(): FFmpegConfig {
if (!this.config) {
throw new FFmpegServiceError("Le service FFmpeg n'est pas configuré (FFMPEG_API_URL / FFMPEG_API_KEY).");
}
return this.config;
}
private async call(path: string, init: RequestInit & { timeoutMs: number }): Promise<Response> {
const config = this.ensureConfigured();
const { timeoutMs, headers, ...rest } = init;
const response = await fetch(`${config.apiUrl}${path}`, {
...rest,
headers: { 'X-API-Key': config.apiKey, ...headers },
signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok) {
const body = await response.text();
let detail = body;
try {
detail = JSON.parse(body).detail ?? body;
} catch { /* corps non JSON */ }
throw new FFmpegServiceError(String(detail).slice(0, 2000), response.status);
}
return response;
}
/**
* Rend un Reel à partir de l'URL d'une vidéo et renvoie le MP4 produit.
* Lève FFmpegServiceError en cas d'échec (voix comprise).
*/
async renderReel(videoUrl: string, options: ReelRenderOptions = {}): Promise<ReelRenderResult> {
const body = {
video_url: videoUrl,
text: options.text,
srt_cues: options.srtCues,
music_url: options.musicUrl,
tts_enabled: options.ttsEnabled ?? false,
tts_voice: options.ttsVoice,
tts_engine: options.ttsEngine,
tts_style: options.ttsStyle,
gemini_api_key: options.geminiApiKey,
qwen_tts_url: options.qwen?.url,
qwen_tts_api_key: options.qwen?.apiKey,
font_size: options.fontSize ?? 64,
music_volume: options.musicVolume ?? 0.25,
draw_text: options.drawText ?? true,
stabilize: options.stabilize ?? false,
watermark_url: options.watermarkUrl,
store_name: options.storeName,
enable_ending_effect: options.enableEndingEffect ?? true,
show_watermark: options.showLogo ?? true,
};
console.log('🎬 Rendu du Reel :', {
videoUrl,
textLength: options.text?.length ?? 0,
music: !!options.musicUrl,
tts: options.ttsEnabled ? `${options.ttsEngine}/${options.ttsVoice}/${options.ttsStyle ?? 'neutral'}` : false,
});
const response = await this.call('/process-reel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
timeoutMs: PROCESS_TIMEOUT_MS,
});
const data = await response.json() as {
job_id: string;
output_path: string;
duration: number;
tts_engine?: string | null;
tts_voice?: string | null;
warnings?: string[];
};
try {
const file = await this.call(data.output_path, { method: 'GET', timeoutMs: DOWNLOAD_TIMEOUT_MS });
const video = Buffer.from(await file.arrayBuffer());
for (const warning of data.warnings ?? []) console.warn(`⚠️ [FFmpeg] ${warning}`);
return {
video,
duration: data.duration,
ttsEngine: data.tts_engine,
ttsVoice: data.tts_voice,
warnings: data.warnings ?? [],
};
} finally {
// Le fichier n'est plus utile au service une fois récupéré
this.call(`/jobs/${data.job_id}`, { method: 'DELETE', timeoutMs: HEALTH_TIMEOUT_MS })
.catch(() => { /* purgé plus tard par le service */ });
}
}
/**
* Prépare un rendu Remotion : image recadrée à la durée finale et piste son
* finale, écrites dans `targetDir`, plus le minutage des mots.
*/
async prepareReel(
videoUrl: string,
options: ReelRenderOptions & { hasLogo: boolean },
targetDir: string,
): Promise<PreparedReel> {
const response = await this.call('/prepare-reel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
video_url: videoUrl,
text: options.text,
srt_cues: options.srtCues,
music_url: options.musicUrl,
tts_enabled: options.ttsEnabled ?? false,
tts_voice: options.ttsVoice,
tts_engine: options.ttsEngine,
tts_style: options.ttsStyle,
gemini_api_key: options.geminiApiKey,
qwen_tts_url: options.qwen?.url,
qwen_tts_api_key: options.qwen?.apiKey,
music_volume: options.musicVolume ?? 0.25,
draw_text: options.drawText ?? true,
stabilize: options.stabilize ?? false,
store_name: options.storeName,
enable_ending_effect: options.enableEndingEffect ?? true,
has_logo: options.hasLogo,
}),
timeoutMs: PROCESS_TIMEOUT_MS,
});
const data = await response.json() as {
job_id: string;
video_path: string;
audio_path: string | null;
total_duration: number;
video_duration: number;
logo_start: number | null;
fade_start?: number | null;
fade_duration?: number | null;
words: TimedWord[];
tts_engine?: string | null;
tts_voice?: string | null;
warnings?: string[];
};
try {
await fs.promises.mkdir(targetDir, { recursive: true });
const videoPath = `${targetDir}/video.mp4`;
await this.downloadTo(data.video_path, videoPath);
let audioPath: string | null = null;
if (data.audio_path) {
audioPath = `${targetDir}/audio.wav`;
await this.downloadTo(data.audio_path, audioPath);
}
for (const warning of data.warnings ?? []) console.warn(`⚠️ [FFmpeg] ${warning}`);
return {
videoPath,
audioPath,
totalDuration: data.total_duration,
videoDuration: data.video_duration,
logoStart: data.logo_start,
fadeStart: data.fade_start ?? null,
fadeDuration: data.fade_duration ?? null,
words: data.words ?? [],
ttsEngine: data.tts_engine,
ttsVoice: data.tts_voice,
warnings: data.warnings ?? [],
};
} finally {
this.call(`/jobs/${data.job_id}`, { method: 'DELETE', timeoutMs: HEALTH_TIMEOUT_MS })
.catch(() => { /* purgé plus tard par le service */ });
}
}
/** Télécharge un fichier du service en flux, sans le charger en mémoire. */
private async downloadTo(servicePath: string, target: string): Promise<void> {
const response = await this.call(servicePath, { method: 'GET', timeoutMs: DOWNLOAD_TIMEOUT_MS });
if (!response.body) throw new FFmpegServiceError(`Réponse vide pour ${servicePath}`);
await pipeline(
Readable.fromWeb(response.body as unknown as WebReadableStream<Uint8Array>),
fs.createWriteStream(target),
);
}
async healthCheck(): Promise<boolean> {
try {
await this.call('/health', { method: 'GET', timeoutMs: HEALTH_TIMEOUT_MS });
return true;
} catch {
return false;
}
}
/** Voix proposées par moteur ; `qwen_available` indique si le service local répond. */
async listVoices(qwen?: QwenTarget): Promise<VoiceCatalog> {
const headers: Record<string, string> = {};
if (qwen?.url) headers['X-Qwen-Url'] = qwen.url;
if (qwen?.apiKey) headers['X-Qwen-Key'] = qwen.apiKey;
const response = await this.call('/voices', { method: 'GET', headers, timeoutMs: 30_000 });
return await response.json() as VoiceCatalog;
}
/** Génère la voix seule (aperçu), avec le minutage de chaque mot. */
async previewVoice(
text: string,
options: { voice?: string; engine?: TtsEngine; style?: TtsStyle; geminiApiKey?: string; qwen?: QwenTarget } = {},
): Promise<VoicePreview> {
const response = await this.call('/preview-tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text,
tts_voice: options.voice,
tts_engine: options.engine,
tts_style: options.style,
gemini_api_key: options.geminiApiKey,
qwen_tts_url: options.qwen?.url,
qwen_tts_api_key: options.qwen?.apiKey,
}),
timeoutMs: TTS_TIMEOUT_MS,
});
const data = await response.json() as {
audio_base64: string;
duration: number;
words: TimedWord[];
engine: string;
voice: string;
warnings?: string[];
};
return {
audio: Buffer.from(data.audio_base64, 'base64'),
duration: data.duration,
words: data.words ?? [],
engine: data.engine,
voice: data.voice,
warnings: data.warnings ?? [],
};
}
}
export const ffmpegService = new FFmpegService();
-251
View File
@@ -1,251 +0,0 @@
/**
* FreeSound Music API Service
*
* Intégration avec l'API FreeSound pour rechercher et récupérer des sons
* libres de droits pour les Reels.
*
* Documentation: https://freesound.org/docs/api/
*/
interface FreeSoundResult {
id: number;
name: string;
duration: number;
username: string;
tags: string[];
license: string;
previews: {
'preview-hq-mp3': string;
'preview-lq-mp3': string;
'preview-hq-ogg': string;
'preview-lq-ogg': string;
};
images: {
waveform_m: string;
waveform_l: string;
spectral_m: string;
spectral_l: string;
};
download?: string;
}
interface FreeSoundSearchResponse {
count: number;
next: string | null;
previous: string | null;
results: FreeSoundResult[];
}
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; // Waveform image
license: string;
}
interface MusicSearchParams {
minDuration?: number; // Durée min en secondes
maxDuration?: number; // Durée max en secondes
genre?: string; // Genre musical / tag
limit?: number; // Nombre de résultats (default: 10)
offset?: number; // Offset pour pagination
search?: string; // Recherche texte libre
}
interface FreeSoundConfig {
clientId: string;
clientSecret: string;
}
const FREESOUND_API_BASE = 'https://freesound.org/apiv2';
export class FreeSoundService {
private config: FreeSoundConfig | null = null;
/**
* Configure le service avec les credentials FreeSound
*/
configure(clientId: string, clientSecret: string): void {
this.config = { clientId, clientSecret };
console.log('🎵 FreeSound Service configured');
}
/**
* Vérifie que le service est configuré
*/
private ensureConfigured(): FreeSoundConfig {
if (!this.config) {
throw new Error('FreeSound Service not configured. Call configure() first.');
}
return this.config;
}
/**
* Convertit un résultat FreeSound en format MusicTrack unifié
*/
private mapResult(result: FreeSoundResult): MusicTrack {
return {
id: result.id.toString(),
title: result.name,
artist: result.username,
albumName: 'FreeSound',
duration: Math.round(result.duration),
previewUrl: result.previews['preview-hq-mp3'] || result.previews['preview-lq-mp3'],
downloadUrl: result.previews['preview-hq-mp3'], // On utilise le preview HQ
imageUrl: result.images?.waveform_m || '',
license: result.license,
};
}
/**
* 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 du filtre de durée
const durationFilter = `duration:[${minDuration} TO ${maxDuration}]`;
// Recherche de musiques/sons musicaux
let query = search || 'music';
if (genre) {
query = `${query} ${genre}`;
}
const queryParams = new URLSearchParams({
token: config.clientSecret, // FreeSound utilise le token dans l'URL pour les requêtes simples
query: query,
filter: durationFilter,
page_size: limit.toString(),
page: Math.floor(offset / limit + 1).toString(),
fields: 'id,name,duration,username,tags,license,previews,images',
sort: 'rating_desc',
});
const url = `${FREESOUND_API_BASE}/search/text/?${queryParams.toString()}`;
console.log('🎵 Searching FreeSound:', {
query,
duration: `${minDuration}-${maxDuration}s`,
limit,
offset,
});
try {
const response = await fetch(url);
if (!response.ok) {
const errorText = await response.text();
console.error('FreeSound API error:', response.status, errorText);
throw new Error(`FreeSound API error: ${response.status}`);
}
const data = await response.json() as FreeSoundSearchResponse;
const tracks = data.results.map(result => this.mapResult(result));
console.log(`✅ Found ${tracks.length} tracks on FreeSound`);
return tracks;
} catch (error) {
console.error('❌ FreeSound search error:', error);
throw error;
}
}
/**
* Récupère les détails d'un son par son ID
*/
async getMusicDetails(trackId: string): Promise<MusicTrack | null> {
const config = this.ensureConfigured();
const url = `${FREESOUND_API_BASE}/sounds/${trackId}/?token=${config.clientSecret}&fields=id,name,duration,username,tags,license,previews,images`;
try {
const response = await fetch(url);
if (!response.ok) {
console.error('FreeSound API error:', response.status);
return null;
}
const result = await response.json() as FreeSoundResult;
return this.mapResult(result);
} catch (error) {
console.error('❌ FreeSound getMusicDetails error:', error);
return null;
}
}
/**
* Récupère des sons populaires (utilisé comme fallback ou découverte)
*/
async getPopularTracks(limit: number = 10): Promise<MusicTrack[]> {
const config = this.ensureConfigured();
const queryParams = new URLSearchParams({
token: config.clientSecret,
query: 'music loop beat',
filter: 'duration:[15 TO 90]',
page_size: limit.toString(),
fields: 'id,name,duration,username,tags,license,previews,images',
sort: 'downloads_desc',
});
const url = `${FREESOUND_API_BASE}/search/text/?${queryParams.toString()}`;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`FreeSound API error: ${response.status}`);
}
const data = await response.json() as FreeSoundSearchResponse;
return data.results.map(result => this.mapResult(result));
} catch (error) {
console.error('❌ FreeSound getPopularTracks error:', error);
return [];
}
}
/**
* Retourne l'URL de téléchargement/preview
*/
getDownloadUrl(track: MusicTrack): string {
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 freeSoundService = new FreeSoundService();
-301
View File
@@ -1,301 +0,0 @@
import sharp from "sharp";
import { randomUUID } from "crypto";
import path from "path";
import fs from "fs/promises";
import { createCanvas, loadImage, registerFont } from "canvas";
import { removeHashtags } from "@shared/emoji";
import { emojiRenderer } from "./emojiRenderer";
interface CropDimensions {
width: number;
height: number;
}
interface ProcessedImages {
facebookFeed: string;
instagramFeed: string;
instagramStory: string;
}
const CROP_FORMATS = {
facebookFeed: { width: 1200, height: 630 } as CropDimensions,
instagramFeed: { width: 1080, height: 1080 } as CropDimensions,
instagramStory: { width: 1080, height: 1920 } as CropDimensions,
};
// Register DejaVu Sans font for proper character rendering
try {
const fontPath = path.join(process.cwd(), "server", "fonts", "DejaVuSans.ttf");
registerFont(fontPath, { family: 'DejaVu Sans' });
console.log('✓ Font DejaVu Sans enregistrée avec succès');
} catch (error) {
console.error('Erreur lors de l\'enregistrement de la police:', error);
}
export class ImageProcessor {
private uploadsDir: string;
constructor() {
this.uploadsDir = path.join(process.cwd(), "uploads");
this.ensureUploadsDirExists();
}
private async ensureUploadsDirExists() {
try {
await fs.access(this.uploadsDir);
} catch {
await fs.mkdir(this.uploadsDir, { recursive: true });
}
}
async processImage(buffer: Buffer, originalFilename: string): Promise<ProcessedImages> {
const fileExt = path.extname(originalFilename);
const baseId = randomUUID();
const processedImages: ProcessedImages = {
facebookFeed: "",
instagramFeed: "",
instagramStory: "",
};
// Process Facebook Feed (1200x630)
const fbFeedFilename = `${baseId}-fb-feed${fileExt}`;
const fbFeedPath = path.join(this.uploadsDir, fbFeedFilename);
await sharp(buffer)
.rotate() // Auto-rotate based on EXIF orientation
.resize(CROP_FORMATS.facebookFeed.width, CROP_FORMATS.facebookFeed.height, {
fit: "cover",
position: "center",
})
.toFile(fbFeedPath);
processedImages.facebookFeed = `/uploads/${fbFeedFilename}`;
// Process Instagram Feed (1080x1080)
const igFeedFilename = `${baseId}-ig-feed${fileExt}`;
const igFeedPath = path.join(this.uploadsDir, igFeedFilename);
await sharp(buffer)
.rotate() // Auto-rotate based on EXIF orientation
.resize(CROP_FORMATS.instagramFeed.width, CROP_FORMATS.instagramFeed.height, {
fit: "cover",
position: "center",
})
.toFile(igFeedPath);
processedImages.instagramFeed = `/uploads/${igFeedFilename}`;
// Process Instagram Story (1080x1920)
const igStoryFilename = `${baseId}-ig-story${fileExt}`;
const igStoryPath = path.join(this.uploadsDir, igStoryFilename);
await sharp(buffer)
.rotate() // Auto-rotate based on EXIF orientation
.resize(CROP_FORMATS.instagramStory.width, CROP_FORMATS.instagramStory.height, {
fit: "cover",
position: "center",
})
.toFile(igStoryPath);
processedImages.instagramStory = `/uploads/${igStoryFilename}`;
return processedImages;
}
async saveOriginalFile(buffer: Buffer, filename: string): Promise<string> {
const uniqueFilename = `${randomUUID()}-${filename}`;
const filePath = path.join(this.uploadsDir, uniqueFilename);
await fs.writeFile(filePath, buffer);
return `/uploads/${uniqueFilename}`;
}
async deleteFile(filePath: string): Promise<void> {
try {
const fullPath = path.join(process.cwd(), filePath);
await fs.unlink(fullPath);
} catch (error) {
console.error("Error deleting file:", error);
}
}
async addTextToStoryImage(imageUrl: string, text: string): Promise<Buffer> {
const STORY_WIDTH = 1080;
const STORY_HEIGHT = 1920;
const TEXT_BOX_HEIGHT_RATIO = 0.25;
const MIN_FONT_SIZE = 12;
const MAX_FONT_SIZE = 72;
const PADDING = 40;
const LINE_HEIGHT_MULTIPLIER = 1.3;
// Remove hashtags from text while keeping emojis
const cleanText = removeHashtags(text);
// Download image from URL first (Sharp can't read URLs directly)
const imageResponse = await fetch(imageUrl);
if (!imageResponse.ok) {
throw new Error(`Failed to fetch image from URL: ${imageUrl}`);
}
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
// Pre-process image with sharp to apply rotation
const rotationBuffer = await sharp(imageBuffer)
.rotate() // Apply EXIF rotation
.toBuffer();
const canvas = createCanvas(STORY_WIDTH, STORY_HEIGHT);
const ctx = canvas.getContext('2d');
const image = await loadImage(rotationBuffer);
const imgWidth = image.width;
const imgHeight = image.height;
const targetRatio = STORY_WIDTH / STORY_HEIGHT;
const imgRatio = imgWidth / imgHeight;
let drawWidth, drawHeight, offsetX, offsetY;
if (imgRatio > targetRatio) {
drawHeight = STORY_HEIGHT;
drawWidth = imgWidth * (STORY_HEIGHT / imgHeight);
offsetX = (STORY_WIDTH - drawWidth) / 2;
offsetY = 0;
} else {
drawWidth = STORY_WIDTH;
drawHeight = imgHeight * (STORY_WIDTH / imgWidth);
offsetX = 0;
offsetY = (STORY_HEIGHT - drawHeight) / 2;
}
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, STORY_WIDTH, STORY_HEIGHT);
ctx.drawImage(image, offsetX, offsetY, drawWidth, drawHeight);
const textBoxHeight = STORY_HEIGHT * TEXT_BOX_HEIGHT_RATIO;
const textBoxY = STORY_HEIGHT - textBoxHeight;
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
ctx.fillRect(0, textBoxY, STORY_WIDTH, textBoxHeight);
const maxTextWidth = STORY_WIDTH - (PADDING * 2);
const maxTextHeight = textBoxHeight - (PADDING * 2);
let fontSize = MAX_FONT_SIZE;
let lines: string[] = [];
let totalTextHeight = 0;
while (fontSize >= MIN_FONT_SIZE) {
ctx.font = `bold ${fontSize}px "DejaVu Sans", Arial, sans-serif`;
lines = await emojiRenderer.wrapText(cleanText, ctx, maxTextWidth, fontSize);
const lineHeight = fontSize * LINE_HEIGHT_MULTIPLIER;
totalTextHeight = lines.length * lineHeight;
if (totalTextHeight <= maxTextHeight) {
break;
}
fontSize -= 2;
}
if (fontSize < MIN_FONT_SIZE) {
fontSize = MIN_FONT_SIZE;
ctx.font = `bold ${fontSize}px "DejaVu Sans", Arial, sans-serif`;
lines = await emojiRenderer.wrapText(cleanText, ctx, maxTextWidth, fontSize);
const lineHeight = fontSize * LINE_HEIGHT_MULTIPLIER;
totalTextHeight = lines.length * lineHeight;
const maxLines = Math.floor(maxTextHeight / lineHeight);
if (lines.length > maxLines) {
lines = lines.slice(0, maxLines);
if (lines[maxLines - 1]) {
lines[maxLines - 1] = lines[maxLines - 1].substring(0, lines[maxLines - 1].length - 3) + '...';
}
totalTextHeight = maxLines * lineHeight;
}
}
ctx.fillStyle = 'white';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
const lineHeight = fontSize * LINE_HEIGHT_MULTIPLIER;
const startY = textBoxY + (textBoxHeight - totalTextHeight) / 2 + lineHeight / 2;
for (let index = 0; index < lines.length; index++) {
const line = lines[index];
const y = startY + (index * lineHeight);
const lineWidth = await emojiRenderer.measureText(line, ctx, fontSize);
const x = (STORY_WIDTH - lineWidth) / 2; // Center the line
await emojiRenderer.drawText(line, ctx, x, y, fontSize);
}
return canvas.toBuffer('image/png');
}
private wrapText(ctx: any, text: string, maxWidth: number): string[] {
const words = text.split(' ');
const lines: string[] = [];
let currentLine = '';
for (const word of words) {
const testLine = currentLine ? `${currentLine} ${word}` : word;
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && currentLine) {
// Current line would exceed width, push it and start new line
lines.push(currentLine);
currentLine = word;
// Check if the word itself is too long for a single line
const wordMetrics = ctx.measureText(word);
if (wordMetrics.width > maxWidth) {
// Word is too long, need to break it into chunks
const chunks = this.breakLongWord(ctx, word, maxWidth);
for (let i = 0; i < chunks.length - 1; i++) {
lines.push(chunks[i]);
}
currentLine = chunks[chunks.length - 1];
}
} else if (metrics.width > maxWidth && !currentLine) {
// First word is too long, need to break it
const chunks = this.breakLongWord(ctx, word, maxWidth);
for (let i = 0; i < chunks.length - 1; i++) {
lines.push(chunks[i]);
}
currentLine = chunks[chunks.length - 1];
} else {
currentLine = testLine;
}
}
if (currentLine) {
lines.push(currentLine);
}
return lines;
}
/**
* Breaks a long word into chunks that fit within maxWidth
*/
private breakLongWord(ctx: any, word: string, maxWidth: number): string[] {
const chunks: string[] = [];
let currentChunk = '';
for (let i = 0; i < word.length; i++) {
const char = word[i];
const testChunk = currentChunk + char;
const metrics = ctx.measureText(testChunk);
if (metrics.width > maxWidth && currentChunk) {
chunks.push(currentChunk);
currentChunk = char;
} else {
currentChunk = testChunk;
}
}
if (currentChunk) {
chunks.push(currentChunk);
}
return chunks.length > 0 ? chunks : [word];
}
}
export const imageProcessor = new ImageProcessor();
-296
View File
@@ -1,296 +0,0 @@
/**
* 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();
-64
View File
@@ -1,64 +0,0 @@
import { db } from '../db';
import { media } from '@shared/schema';
import { eq, lt, and } from 'drizzle-orm';
import { minioService as storageService } from './minio';
/**
* Purge old local media to save disk space:
* - Images older than 30 days
* - Videos older than 7 days
*/
export async function purgeOldMedia() {
console.log('[Media Purge] Starting automatic purge of old media...');
try {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const oldImages = await db.query.media.findMany({
where: (m, { and, lt, eq }) => and(eq(m.type, 'image'), lt(m.createdAt, thirtyDaysAgo))
});
const oldVideos = await db.query.media.findMany({
where: (m, { and, lt, eq }) => and(eq(m.type, 'video'), lt(m.createdAt, sevenDaysAgo))
});
const toDelete = [...oldImages, ...oldVideos];
if (toDelete.length === 0) {
console.log('[Media Purge] No old media found to purge.');
return;
}
console.log(`[Media Purge] Found ${oldImages.length} old images (>30 days) and ${oldVideos.length} old videos (>7 days) to purge.`);
let successCount = 0;
let errorCount = 0;
for (const item of toDelete) {
try {
if (item.cloudinaryPublicId) {
await storageService.deleteMedia(item.cloudinaryPublicId, item.userId, item.type === 'video' ? 'video' : 'image');
}
// La vignette n'a plus de raison d'être une fois la ligne supprimée
if (item.thumbnailUrl?.startsWith('/uploads/')) {
await storageService
.deleteMedia(item.thumbnailUrl.replace('/uploads/', ''), item.userId, 'image')
.catch(err => console.warn(`[Media Purge] Vignette non supprimée pour ${item.id}:`, err));
}
await db.delete(media).where(eq(media.id, item.id));
successCount++;
} catch (err) {
console.error(`[Media Purge] Failed to delete media ${item.id}:`, err);
errorCount++;
}
}
console.log(`[Media Purge] Purge completed. Deleted: ${successCount}, Failed: ${errorCount}.`);
} catch (error) {
console.error('[Media Purge] Fatal error during media purge process:', error);
}
}
-143
View File
@@ -1,143 +0,0 @@
import fs from 'fs';
import path from 'path';
const UPLOADS_BASE = path.resolve(process.cwd(), 'uploads');
function ensureDir(dir: string): void {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
/**
* Builds the public URL for a locally stored file.
* Returns a relative path so it works on any domain/protocol.
* objectKey is the relative path from uploads/ (e.g. "media/123-file.jpg").
*/
export function buildMinioUrl(_bucketName: string, objectKey: string, _publicUrlOverride?: string | null): string {
return `/uploads/${objectKey}`;
}
/**
* Resolves a possibly-relative URL to an absolute URL for use by external
* services (Facebook API, Instagram API) that must download the file.
* Uses APP_URL env var (public-facing domain).
*/
export function resolvePublicUrl(url: string): string {
if (!url || !url.startsWith('/')) return url;
const base = (process.env.APP_URL || 'http://localhost:5555').replace(/\/$/, '');
return `${base}${url}`;
}
/**
* Resolves a possibly-relative URL to an absolute URL for use by internal
* Docker services (FFmpeg, etc.) that communicate via internal Docker network.
* Uses INTERNAL_APP_URL env var.
*/
export function resolveInternalUrl(url: string): string {
if (!url || !url.startsWith('/')) return url;
const base = (process.env.INTERNAL_APP_URL || process.env.APP_URL || 'http://localhost:5555').replace(/\/$/, '');
return `${base}${url}`;
}
/**
* Charge un média en mémoire, que son URL soit locale (/uploads/...) ou distante.
* Nécessaire pour les APIs qui veulent recevoir le fichier plutôt qu'une URL
* (upload TikTok, Reels Facebook).
*/
export async function readMediaBuffer(url: string): Promise<Buffer> {
if (url.startsWith('/')) {
const localPath = path.join(process.cwd(), url);
if (fs.existsSync(localPath)) {
return await fs.promises.readFile(localPath);
}
}
const absoluteUrl = resolvePublicUrl(url);
const response = await fetch(absoluteUrl);
if (!response.ok) {
throw new Error(`Impossible de récupérer le média depuis ${absoluteUrl} (${response.status})`);
}
return Buffer.from(await response.arrayBuffer());
}
class LocalStorageService {
async uploadMedia(
file: Buffer | string,
fileName: string,
_userId: string,
_mimeType: string
): Promise<{
publicId: string;
originalUrl: string;
facebookFeedUrl: string | null;
instagramFeedUrl: string | null;
instagramStoryUrl: string | null;
}> {
const dir = path.join(UPLOADS_BASE, 'media');
ensureDir(dir);
const safeName = path.basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '-');
const objectKey = `media/${Date.now()}-${safeName}`;
const filePath = path.join(UPLOADS_BASE, objectKey);
const body: Buffer = typeof file === 'string' ? fs.readFileSync(file) : file;
fs.writeFileSync(filePath, body);
const url = buildMinioUrl('', objectKey);
return { publicId: objectKey, originalUrl: url, facebookFeedUrl: url, instagramFeedUrl: url, instagramStoryUrl: url };
}
async deleteMedia(publicId: string, _userId: string, _mediaType: 'image' | 'video'): Promise<void> {
const filePath = path.join(UPLOADS_BASE, publicId);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
async uploadStoryImageWithText(buffer: Buffer, originalFileName: string): Promise<string> {
const dir = path.join(UPLOADS_BASE, 'stories');
ensureDir(dir);
const safeName = path.basename(originalFileName).replace(/[^a-zA-Z0-9._-]/g, '-');
const objectKey = `stories/story-${Date.now()}-${safeName}`;
fs.writeFileSync(path.join(UPLOADS_BASE, objectKey), buffer);
return buildMinioUrl('', objectKey);
}
/**
* Range une vignette vidéo. Elle survit volontairement à la suppression de la
* vidéo source, pour que l'historique reste illustré.
*/
async uploadThumbnail(buffer: Buffer, fileName: string): Promise<{ publicId: string; url: string }> {
const dir = path.join(UPLOADS_BASE, 'thumbnails');
ensureDir(dir);
const safeName = path.basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '-');
const objectKey = `thumbnails/${safeName}`;
fs.writeFileSync(path.join(UPLOADS_BASE, objectKey), buffer);
return { publicId: objectKey, url: buildMinioUrl('', objectKey) };
}
async uploadLogo(buffer: Buffer, fileName: string): Promise<{ publicId: string; url: string }> {
const dir = path.join(UPLOADS_BASE, 'logos');
ensureDir(dir);
const ext = path.extname(fileName) || '.png';
const objectKey = `logos/logo-${Date.now()}${ext}`;
fs.writeFileSync(path.join(UPLOADS_BASE, objectKey), buffer);
return { publicId: objectKey, url: buildMinioUrl('', objectKey) };
}
async deleteLogo(publicId: string): Promise<void> {
const filePath = path.join(UPLOADS_BASE, publicId);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
}
export const minioService = new LocalStorageService();
-289
View File
@@ -1,289 +0,0 @@
interface ProductInfo {
name: string;
description?: string;
price?: string;
features?: string[];
[key: string]: any;
}
interface GeneratedText {
variant: string;
text: string;
characterCount: number;
}
import crypto from 'crypto';
import { storage } from '../storage';
/**
* Extrait le message d'erreur d'une réponse OpenRouter, qu'elle soit au format
* JSON ({ error: { message } }) ou en texte brut. Tronqué pour éviter de
* déverser une page HTML entière dans l'interface.
*/
function extractUpstreamMessage(body: string): string {
const trimmed = (body || '').trim();
if (!trimmed) return 'aucun détail fourni';
try {
const parsed = JSON.parse(trimmed);
const message = parsed?.error?.message || parsed?.message || parsed?.error;
if (typeof message === 'string' && message.trim()) {
return message.trim().slice(0, 300);
}
} catch {
// Réponse non-JSON (page d'erreur HTML d'un proxy, par exemple)
}
return trimmed.replace(/\s+/g, ' ').slice(0, 300);
}
/**
* Traduit une erreur de génération en réponse HTTP exploitable côté interface.
* Sans cela, l'utilisateur ne voit qu'un 500 générique alors que la cause
* (configuration absente, clé invalide, crédits épuisés) est connue du serveur.
*/
export function describeGenerationError(error: unknown): { status: number; message: string } {
const message = error instanceof Error ? error.message : 'Erreur inconnue lors de la génération';
// Problème de configuration : l'administrateur peut le corriger lui-même
if (/Configuration OpenRouter non trouvée|Clé API OpenRouter manquante/.test(message)) {
return { status: 400, message };
}
// Refus du service tiers
if (/OpenRouter a refusé la requête|Réponse invalide de l'API OpenRouter/.test(message)) {
return { status: 502, message };
}
return { status: 500, message };
}
export class OpenRouterService {
private baseUrl = "https://openrouter.ai/api/v1/chat/completions";
async generatePostText(productInfo: ProductInfo, userId: string, modelOverride?: string): Promise<GeneratedText[]> {
// Prefer the requesting user's own configuration; fall back to the most
// recently saved shared configuration if this user hasn't set one up.
const config = (await storage.getOpenrouterConfig(userId)) || (await storage.getAnyOpenrouterConfig());
if (!config) {
throw new Error('Configuration OpenRouter non trouvée. Veuillez demander à un administrateur de configurer OpenRouter dans les Paramètres.');
}
if (!config.apiKey || !config.apiKey.trim()) {
throw new Error('Clé API OpenRouter manquante ou vide en base de données. Veuillez la ressaisir dans les Paramètres.');
}
const prompt = this.buildPrompt(productInfo, config.systemPrompt);
// Use provided model or fall back to config model
const modelToUse = modelOverride || config.model;
const keyFingerprint = crypto.createHash('sha256').update(config.apiKey).digest('hex').slice(0, 8);
console.log(`[OpenRouter] Generating with model="${modelToUse}" keyFingerprint=${keyFingerprint} keyLength=${config.apiKey.length} configUserId=${config.userId}`);
try {
const response = await fetch(this.baseUrl, {
method: "POST",
headers: {
"Authorization": `Bearer ${config.apiKey.trim()}`,
"Content-Type": "application/json",
"HTTP-Referer": process.env.APP_URL || "http://localhost:5555",
"X-Title": "Social Flow"
},
body: JSON.stringify({
model: modelToUse,
messages: [
{
role: "user",
content: prompt
}
],
temperature: 0.8,
max_tokens: 1000
})
});
if (!response.ok) {
const errorText = await response.text();
console.error('OpenRouter API Error Response:', errorText);
// Le motif renvoyé par OpenRouter (clé invalide, crédits épuisés, modèle
// inconnu…) est la seule information exploitable : on le fait remonter
// jusqu'à l'utilisateur, qui n'a pas accès aux logs du serveur.
throw new Error(
`OpenRouter a refusé la requête (${response.status} ${response.statusText}) ` +
`avec le modèle "${modelToUse}" : ${extractUpstreamMessage(errorText)}`
);
}
const data = await response.json();
if (!data.choices || !data.choices[0]) {
console.error('Invalid OpenRouter response - no choices:', { hasChoices: !!data.choices, model: modelToUse });
throw new Error('Réponse invalide de l\'API OpenRouter. Vérifiez votre clé API et votre modèle.');
}
const content = data.choices[0]?.message?.content || "";
return this.parseGeneratedText(content);
} catch (error) {
console.error("Error generating text with OpenRouter:", error);
throw error;
}
}
private buildPrompt(productInfo: ProductInfo, systemPrompt: string): string {
const features = productInfo.features?.join(", ") || "";
const productDetails = `Nom: ${productInfo.name}
${productInfo.description ? `Description: ${productInfo.description}` : ''}
${productInfo.price ? `Prix: ${productInfo.price}` : ''}
${features ? `Caractéristiques: ${features}` : ''}`;
return `${systemPrompt}
Informations produit:
${productDetails}
Format de réponse:
VERSION 1 - DYNAMIQUE:
[texte]
VERSION 2 - INFORMATIVE:
[texte]
VERSION 3 - ÉMOTIONNELLE:
[texte]`;
}
private parseGeneratedText(content: string): GeneratedText[] {
// Normaliser les retours à la ligne
const normalizedContent = content.replace(/\r\n/g, '\n').trim();
console.log('🔍 Parsing AI response (length:', normalizedContent.length, ')');
// Stratégie 1: Détection des headers VERSION avec parsing intelligent
const variants = this.tryMultipleParsingStrategies(normalizedContent);
if (variants.length > 0) {
console.log('✅ Parsed successfully:', variants.length, 'variants');
return variants;
}
// Fallback: retourner le texte complet
console.log('⚠️ Parsing failed, using fallback');
return [{
variant: "Généré par IA",
text: normalizedContent,
characterCount: normalizedContent.length
}];
}
private tryMultipleParsingStrategies(content: string): GeneratedText[] {
// Stratégie 1: Split par headers de VERSION (le plus flexible)
const strategy1 = this.parseByVersionHeaders(content);
if (strategy1.length >= 3) return strategy1;
// Stratégie 2: Regex avec markdown **VERSION N:**
const strategy2 = this.parseWithMarkdownHeaders(content);
if (strategy2.length >= 3) return strategy2;
// Stratégie 3: Regex simple sans markdown
const strategy3 = this.parseWithSimpleHeaders(content);
if (strategy3.length >= 3) return strategy3;
// Retourner le meilleur résultat obtenu
return strategy1.length > 0 ? strategy1 : (strategy2.length > 0 ? strategy2 : strategy3);
}
private parseByVersionHeaders(content: string): GeneratedText[] {
const variants: GeneratedText[] = [];
// Chercher tous les patterns VERSION N (très flexible)
const versionPattern = /(?:\*{2})?VERSION\s+(\d+)\s*[-–—]\s*([A-ZÀÂÄÉÈÊËÏÎÔÙÛÜ]+)(?:\*{2})?:?/gi;
const matches = Array.from(content.matchAll(versionPattern));
if (matches.length < 3) return variants;
// Extraire le texte entre chaque header
for (let i = 0; i < matches.length; i++) {
const match = matches[i];
const versionNum = match[1];
const style = match[2];
const startPos = match.index! + match[0].length;
const endPos = i < matches.length - 1 ? matches[i + 1].index! : content.length;
let text = content.substring(startPos, endPos).trim();
// Nettoyer le texte (enlever les séparateurs, notes entre parenthèses au début, etc.)
text = text.replace(/^[-*]{3,}\s*/gm, ''); // Enlever les séparateurs
text = text.replace(/^\([^)]+\)\s*/gm, ''); // Enlever les notes entre parenthèses
text = text.trim();
if (text.length > 0) {
variants.push({
variant: `Version ${versionNum} - ${this.capitalizeFirst(style.toLowerCase())}`,
text,
characterCount: text.length
});
}
}
return variants;
}
private parseWithMarkdownHeaders(content: string): GeneratedText[] {
const variants: GeneratedText[] = [];
const v1 = content.match(/\*{2}VERSION\s+1\s*[-–—]\s*DYNAMIQUE:?\*{2}\s*\n([\s\S]*?)(?=\*{2}VERSION\s+2|$)/i);
const v2 = content.match(/\*{2}VERSION\s+2\s*[-–—]\s*INFORMATIVE?:?\*{2}\s*\n([\s\S]*?)(?=\*{2}VERSION\s+3|$)/i);
const v3 = content.match(/\*{2}VERSION\s+3\s*[-–—]\s*[ÉE]MOTIONNELLE?:?\*{2}\s*\n([\s\S]*?)$/i);
if (v1) variants.push({ variant: "Version 1 - Dynamique", text: v1[1].trim(), characterCount: v1[1].trim().length });
if (v2) variants.push({ variant: "Version 2 - Informative", text: v2[1].trim(), characterCount: v2[1].trim().length });
if (v3) variants.push({ variant: "Version 3 - Émotionnelle", text: v3[1].trim(), characterCount: v3[1].trim().length });
return variants;
}
private parseWithSimpleHeaders(content: string): GeneratedText[] {
const variants: GeneratedText[] = [];
const v1 = content.match(/VERSION\s+1\s*[-–—]\s*DYNAMIQUE:?\s*\n([\s\S]*?)(?=VERSION\s+2|$)/i);
const v2 = content.match(/VERSION\s+2\s*[-–—]\s*INFORMATIVE?:?\s*\n([\s\S]*?)(?=VERSION\s+3|$)/i);
const v3 = content.match(/VERSION\s+3\s*[-–—]\s*[ÉE]MOTIONNELLE?:?\s*\n([\s\S]*?)$/i);
if (v1) variants.push({ variant: "Version 1 - Dynamique", text: v1[1].trim(), characterCount: v1[1].trim().length });
if (v2) variants.push({ variant: "Version 2 - Informative", text: v2[1].trim(), characterCount: v2[1].trim().length });
if (v3) variants.push({ variant: "Version 3 - Émotionnelle", text: v3[1].trim(), characterCount: v3[1].trim().length });
return variants;
}
private capitalizeFirst(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase();
}
async getAvailableModels(): Promise<any[]> {
try {
const response = await fetch("https://openrouter.ai/api/v1/models", {
method: "GET",
headers: {
"Content-Type": "application/json",
}
});
if (!response.ok) {
throw new Error(`Failed to fetch models: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return data.data || [];
} catch (error) {
console.error("Error fetching OpenRouter models:", error);
throw error;
}
}
}
export const openRouterService = new OpenRouterService();
-94
View File
@@ -1,94 +0,0 @@
/**
* Résolution des ressources d'un Reel (musique, logo, clé Gemini, nom du
* magasin), jusqu'ici recopiée dans chaque route.
*/
import type { QwenTarget } from "../ffmpeg";
import { storage } from "../../storage";
import { buildMinioUrl, resolveInternalUrl } from "../minio";
/**
* URL de la musique téléchargeable par le service FFmpeg (réseau Docker
* interne : l'URL publique HTTPS n'y est pas joignable).
*/
export async function resolveMusicUrl(
musicTrackId?: string,
musicUrl?: string,
): Promise<string | undefined> {
if (musicUrl) return resolveInternalUrl(musicUrl);
if (!musicTrackId?.startsWith("internal_")) return undefined;
try {
const track = await storage.getAudioTrack(musicTrackId.slice("internal_".length));
return track ? resolveInternalUrl(track.url) : undefined;
} catch (error) {
console.error("⚠️ [Reels] Piste audio introuvable :", error);
return undefined;
}
}
/** Chemin relatif (/uploads/...) du logo configuré, s'il existe. */
export async function resolveLogoPath(): Promise<string | undefined> {
try {
const config = await storage.getCloudinaryConfig();
if (config?.logoPublicId) {
return buildMinioUrl(config.cloudName, config.logoPublicId, config.publicUrl);
}
} catch (error) {
console.error("⚠️ [Reels] Configuration du logo illisible :", error);
}
return undefined;
}
/**
* Clé Gemini : configuration de l'application, sinon variable d'environnement.
* Aussi transmise avec Qwen, qui se replie sur Gemini si le service local échoue.
*/
export async function resolveGeminiApiKey(ttsEngine?: string): Promise<string | undefined> {
if (ttsEngine !== "gemini" && ttsEngine !== "qwen") return undefined;
const appConfig = await storage.getAppConfig();
return appConfig?.geminiApiKey ?? process.env.GEMINI_API_KEY ?? undefined;
}
/** Service Qwen TTS réglé dans les paramètres (sinon ffmpeg-api prend QWEN_TTS_URL). */
export async function resolveQwenTarget(ttsEngine?: string): Promise<QwenTarget | undefined> {
if (ttsEngine !== "qwen") return undefined;
return qwenTargetFromConfig(await storage.getAppConfig());
}
/** Adresse http(s) du service Qwen saisie dans les paramètres, sans « / » final ; null si invalide. */
export function normalizeQwenUrl(value: unknown): string | null {
if (typeof value !== "string" || !value.trim()) return null;
try {
const url = new URL(value.trim());
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
return url.toString().replace(/\/+$/, "");
} catch {
return null;
}
}
export function qwenTargetFromConfig(
appConfig: { qwenTtsUrl?: string | null; qwenTtsApiKey?: string | null } | undefined,
): QwenTarget | undefined {
if (!appConfig?.qwenTtsUrl) return undefined;
return { url: appConfig.qwenTtsUrl, apiKey: appConfig.qwenTtsApiKey ?? undefined };
}
/** Nom affiché dans l'outro : celui de la page choisie, sinon de la première page de l'utilisateur. */
export async function resolveStoreName(
userId: string,
preferredPageId?: string,
): Promise<string | undefined> {
try {
if (preferredPageId) {
const page = await storage.getSocialPage(preferredPageId);
if (page) return page.pageName;
}
const pages = await storage.getSocialPages(userId);
return pages[0]?.pageName;
} catch (error) {
console.error("⚠️ [Reels] Nom du magasin introuvable :", error);
return undefined;
}
}
-12
View File
@@ -1,12 +0,0 @@
/**
* Point d'entrée des Reels : enregistre les traitements de la file et démarre
* le worker.
*/
import { registerReelJobHandler, startReelWorker } from "./queue";
import { runVideoReelJob } from "./videoPipeline";
export function startReelProcessing(): Promise<void> {
registerReelJobHandler("video", runVideoReelJob);
return startReelWorker();
}
-189
View File
@@ -1,189 +0,0 @@
/**
* Enregistrement et publication d'un Reel rendu, commun aux Reels vidéo et
* images.
*
* La vidéo est toujours envoyée en binaire : les fichiers vivent sous
* /uploads, une URL relative que Facebook ne peut pas télécharger (c'était le
* cas de l'ancien `publishReel(originalUrl)`).
*/
import type { Media, SocialPage } from "@shared/schema";
import { storage } from "../../storage";
import { minioService } from "../minio";
import { createVideoThumbnail } from "../thumbnail";
import { facebookService } from "../facebook";
import { tiktokService } from "../tiktok";
export interface PagePublishResult {
pageId: string;
success: boolean;
reelId?: string;
error?: string;
/** Échec de la story associée : n'empêche pas le Reel d'être publié. */
storyError?: string;
}
/** Range la vidéo rendue dans la médiathèque, avec sa vignette. */
export async function storeRenderedVideo(
userId: string,
videoBuffer: Buffer,
fileName: string,
): Promise<Media> {
const uploaded = await minioService.uploadMedia(videoBuffer, fileName, userId, "video/mp4");
// Vignette extraite maintenant : la vidéo sera supprimée du disque après
// publication, mais l'historique doit rester illustré.
const thumbnailUrl = await createVideoThumbnail(videoBuffer);
return storage.createMedia({
userId,
type: "video",
cloudinaryPublicId: uploaded.publicId,
originalUrl: uploaded.originalUrl,
facebookFeedUrl: uploaded.facebookFeedUrl,
instagramFeedUrl: uploaded.instagramFeedUrl,
instagramStoryUrl: uploaded.instagramStoryUrl,
thumbnailUrl,
fileName,
fileSize: videoBuffer.length,
});
}
/**
* Publie (ou planifie) la vidéo sur chaque page, crée les entrées
* `scheduled_posts` et met à jour le statut du post.
*
* Avec `alsoStory`, chaque page Facebook reçoit aussi la vidéo en story (TikTok
* n'a pas de stories).
*/
export async function publishReelToPages(options: {
postId: string;
pageIds: string[];
videoBuffer: Buffer;
description: string;
scheduledFor?: string;
alsoStory?: boolean;
}): Promise<PagePublishResult[]> {
const { postId, pageIds, videoBuffer, description, scheduledFor, alsoStory } = options;
const results: PagePublishResult[] = [];
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" && page.platform !== "tiktok") {
results.push({
pageId,
success: false,
error: `Plateforme non supportée pour les reels : ${page.platform}`,
});
continue;
}
const scheduledPost = await storage.createScheduledPost({
postId,
pageId: page.id,
postType: "reel",
scheduledAt: scheduledFor ? new Date(scheduledFor) : new Date(),
});
const withStory = alsoStory && page.platform === "facebook";
if (scheduledFor) {
// Le planificateur publiera à l'heure dite
if (withStory) {
await storage.createScheduledPost({
postId,
pageId: page.id,
postType: "story",
scheduledAt: new Date(scheduledFor),
});
}
results.push({ pageId, success: true, reelId: "scheduled" });
continue;
}
console.log(`🚀 [Reels] Publication sur ${page.platform} ${page.pageName}...`);
if (page.platform === "facebook") {
const reelId = await facebookService.publishVideoFromBuffer(page, videoBuffer, description);
await storage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(),
externalPostId: reelId,
});
const storyError = withStory ? await publishStory(postId, page, videoBuffer) : undefined;
results.push({ pageId, success: true, reelId, storyError });
} else {
// TikTok finalise la publication de son côté : l'envoi est marqué comme
// effectué pour ne pas republier, et le poller de statut renseignera
// l'identifiant définitif du post.
const publishId = await tiktokService.publishVideoFromBuffer(page, videoBuffer, description);
await storage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(),
publishId,
publishStatus: "PROCESSING_UPLOAD",
});
results.push({ pageId, success: true, reelId: publishId });
}
} catch (error) {
console.error(`❌ [Reels] Publication sur la page ${pageId} impossible :`, error);
results.push({
pageId,
success: false,
error: error instanceof Error ? error.message : "Erreur inconnue",
});
}
}
const succeeded = results.filter((r) => r.success).length;
const status = scheduledFor && succeeded > 0 ? "scheduled" : succeeded > 0 ? "published" : "failed";
await storage.updatePost(postId, { status });
return results;
}
/**
* Publie la story d'une page juste après son Reel : un échec est consigné sur
* son entrée planifiée (visible dans l'historique) et renvoyé, sans faire
* échouer le Reel.
*/
async function publishStory(
postId: string,
page: SocialPage,
videoBuffer: Buffer,
): Promise<string | undefined> {
// Créée seulement maintenant : en attente pendant l'envoi du Reel, le
// planificateur aurait pu la publier une seconde fois.
let scheduledStoryId: string | undefined;
try {
({ id: scheduledStoryId } = await storage.createScheduledPost({
postId,
pageId: page.id,
postType: "story",
scheduledAt: new Date(),
}));
const storyId = await facebookService.publishVideoStoryFromBuffer(page, videoBuffer);
await storage.updateScheduledPost(scheduledStoryId, {
publishedAt: new Date(),
externalPostId: storyId,
});
return undefined;
} catch (error) {
const message = error instanceof Error ? error.message : "Erreur inconnue";
console.error(`❌ [Reels] Story sur ${page.pageName} impossible :`, error);
if (scheduledStoryId) {
// Marquée comme traitée pour que le planificateur ne la republie pas en boucle
await storage.updateScheduledPost(scheduledStoryId, {
publishedAt: new Date(),
error: message,
}).catch((e) => console.error(`⚠️ [Reels] Échec de la story non enregistré :`, e));
}
return message;
}
}
/** Message d'erreur lisible lorsque toutes les publications ont échoué. */
export function describePublishFailure(results: PagePublishResult[]): string {
const details = results.map((r) => r.error).filter(Boolean).join(" ; ");
return `Publication impossible sur toutes les pages${details ? ` : ${details}` : ""}`;
}
-278
View File
@@ -1,278 +0,0 @@
/**
* File d'attente persistante des rendus de Reels.
*
* L'ancienne file comptait les posts « processing » pour décider de lancer un
* rendu : un redémarrage pendant un job laissait ce compteur à 1 pour toujours
* et bloquait tous les Reels suivants. Ici, chaque job est une ligne de
* `reel_jobs` :
* - le worker la réserve atomiquement (`FOR UPDATE SKIP LOCKED`), ce qui exclut
* tout double traitement, même avec plusieurs instances ;
* - un battement de cœur est écrit pendant le rendu ; au démarrage, les jobs
* dont le cœur ne bat plus sont remis en file (ou échouent après
* MAX_ATTEMPTS tentatives).
*/
import { and, eq, sql } from "drizzle-orm";
import { db } from "../../db";
import { posts, reelJobs, type ReelJob } from "@shared/schema";
import type { ReelJobKind, ReelJobStatus } from "@shared/reel";
const HEARTBEAT_INTERVAL_MS = 15_000;
/** Au-delà, un job « processing » est considéré comme orphelin. */
const STALE_AFTER_MS = 2 * 60_000;
const POLL_INTERVAL_MS = 10_000;
const MAX_ATTEMPTS = 2;
/** Outils fournis à un handler pour rendre compte de son avancement. */
export interface JobContext {
job: ReelJob;
progress(progress: number, step?: string): Promise<void>;
}
export type JobHandler = (ctx: JobContext) => Promise<unknown>;
const handlers = new Map<ReelJobKind, JobHandler>();
let running = false;
let started = false;
let pollTimer: NodeJS.Timeout | null = null;
export function registerReelJobHandler(kind: ReelJobKind, handler: JobHandler): void {
handlers.set(kind, handler);
}
/** Ajoute un job à la file et réveille le worker. */
export async function enqueueReelJob(input: {
kind: ReelJobKind;
userId: string;
postId?: string;
params: unknown;
}): Promise<ReelJob> {
const [job] = await db
.insert(reelJobs)
.values({
kind: input.kind,
userId: input.userId,
postId: input.postId,
params: input.params,
})
.returning();
if (job.postId) {
await syncPost(job.postId, "pending", 0);
}
kick();
return job;
}
export async function getReelJob(id: string): Promise<ReelJob | undefined> {
const [job] = await db.select().from(reelJobs).where(eq(reelJobs.id, id));
return job;
}
/** Nombre de jobs qui passeront avant un nouveau job (utile pour informer l'utilisateur). */
export async function countActiveReelJobs(): Promise<number> {
const [row] = await db
.select({ count: sql<number>`count(*)::int` })
.from(reelJobs)
.where(sql`${reelJobs.status} IN ('pending', 'processing')`);
return row?.count ?? 0;
}
/**
* Démarre le worker : récupère les jobs orphelins puis traite la file.
* Idempotent.
*/
export async function startReelWorker(): Promise<void> {
if (started) return;
started = true;
try {
await recoverStaleJobs();
await failLegacyStuckPosts();
} catch (error) {
console.error("❌ [ReelQueue] Récupération des jobs orphelins impossible :", error);
}
console.log(`🎬 [ReelQueue] Worker démarré (traitements : ${Array.from(handlers.keys()).join(", ")})`);
pollTimer = setInterval(kick, POLL_INTERVAL_MS);
pollTimer.unref();
kick();
}
/** Relance la boucle si elle est à l'arrêt. */
function kick(): void {
if (!started || running) return;
running = true;
drain()
.catch((error) => console.error("❌ [ReelQueue] Erreur de la boucle :", error))
.finally(() => {
running = false;
});
}
async function drain(): Promise<void> {
for (;;) {
const job = await claimNextJob();
if (!job) return;
await runJob(job);
}
}
async function claimNextJob(): Promise<ReelJob | undefined> {
const result = await db.execute<{ id: string }>(sql`
UPDATE reel_jobs
SET status = 'processing',
attempts = attempts + 1,
heartbeat_at = now(),
updated_at = now(),
error = NULL
WHERE id = (
SELECT id FROM reel_jobs
WHERE status = 'pending'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING id
`);
const id = result.rows[0]?.id;
return id ? getReelJob(id) : undefined;
}
async function runJob(job: ReelJob): Promise<void> {
const handler = handlers.get(job.kind as ReelJobKind);
console.log(`🎬 [ReelQueue] Job ${job.id} (${job.kind}), tentative ${job.attempts}`);
const heartbeat = setInterval(() => {
db.update(reelJobs)
.set({ heartbeatAt: new Date() })
.where(eq(reelJobs.id, job.id))
.catch((error) => console.warn(`⚠️ [ReelQueue] Heartbeat ${job.id} :`, error));
}, HEARTBEAT_INTERVAL_MS);
const ctx: JobContext = {
job,
async progress(progress, step) {
await updateJob(job, { progress, step });
},
};
try {
if (!handler) {
throw new Error(`Aucun traitement enregistré pour les jobs « ${job.kind} »`);
}
if (job.postId) {
await syncPost(job.postId, "processing", 0);
}
const result = await handler(ctx);
await updateJob(job, { status: "completed", progress: 100, step: "done", result: result ?? null });
console.log(`✅ [ReelQueue] Job ${job.id} terminé`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`❌ [ReelQueue] Job ${job.id} en échec :`, error);
await updateJob(job, { status: "failed", error: message }).catch((e) =>
console.error(`⚠️ [ReelQueue] Statut d'échec non enregistré pour ${job.id} :`, e),
);
} finally {
clearInterval(heartbeat);
}
}
async function updateJob(
job: ReelJob,
patch: {
status?: ReelJobStatus;
progress?: number;
step?: string;
result?: unknown;
error?: string;
},
): Promise<void> {
await db
.update(reelJobs)
.set({ ...patch, heartbeatAt: new Date(), updatedAt: new Date() })
.where(eq(reelJobs.id, job.id));
if (job.postId) {
const status = patch.status ?? "processing";
const progress = patch.status === "failed" ? 0 : patch.progress;
await syncPost(job.postId, status, progress, patch.error);
}
}
/**
* Reflète l'état du job sur le post : l'interface (`/reels/ongoing`) lit les
* colonnes `generation_*` du post.
*/
async function syncPost(
postId: string,
status: ReelJobStatus,
progress?: number,
error?: string,
): Promise<void> {
const patch: Partial<typeof posts.$inferInsert> = {
generationStatus: status,
updatedAt: new Date(),
};
if (progress !== undefined) patch.generationProgress = progress;
if (error !== undefined) patch.generationError = error;
if (status === "failed") patch.status = "failed";
await db.update(posts).set(patch).where(eq(posts.id, postId));
}
/**
* Remet en file les jobs interrompus (serveur arrêté pendant un rendu), ou les
* fait échouer s'ils ont déjà épuisé leurs tentatives : un job qui fait tomber
* le serveur ne doit pas le faire tomber en boucle.
*/
async function recoverStaleJobs(): Promise<void> {
const staleBefore = new Date(Date.now() - STALE_AFTER_MS);
const stale = await db
.select()
.from(reelJobs)
.where(
and(
eq(reelJobs.status, "processing"),
sql`(${reelJobs.heartbeatAt} IS NULL OR ${reelJobs.heartbeatAt} < ${staleBefore})`,
),
);
for (const job of stale) {
if (job.attempts >= MAX_ATTEMPTS) {
console.warn(`⚠️ [ReelQueue] Job ${job.id} abandonné après ${job.attempts} tentatives`);
await updateJob(job, {
status: "failed",
error: "Le rendu a été interrompu à plusieurs reprises (redémarrage du serveur).",
});
} else {
console.warn(`♻️ [ReelQueue] Job ${job.id} interrompu, remis en file`);
await db
.update(reelJobs)
.set({ status: "pending", step: null, progress: 0, updatedAt: new Date() })
.where(eq(reelJobs.id, job.id));
if (job.postId) await syncPost(job.postId, "pending", 0);
}
}
}
/**
* Posts laissés « processing »/« pending » par l'ancienne file (avant
* reel_jobs) : ils n'ont aucun job pour les reprendre et bloquaient l'affichage
* des Reels en cours. On les marque en échec pour que l'utilisateur relance.
*/
async function failLegacyStuckPosts(): Promise<void> {
const result = await db.execute(sql`
UPDATE posts
SET generation_status = 'failed',
generation_progress = 0,
generation_error = 'Génération interrompue, veuillez relancer le Reel.',
status = 'failed',
updated_at = now()
WHERE generation_status IN ('processing', 'pending')
AND NOT EXISTS (SELECT 1 FROM reel_jobs j WHERE j.post_id = posts.id)
`);
if (result.rowCount) {
console.warn(`⚠️ [ReelQueue] ${result.rowCount} Reel(s) de l'ancienne file marqués en échec`);
}
}
@@ -1,12 +0,0 @@
import { describe, expect, it } from "vitest";
import { toDataUrl } from "./remotionAssets";
describe("toDataUrl", () => {
it("refuse un chemin qui sort du dossier uploads", async () => {
await expect(toDataUrl("/uploads/../../etc/passwd")).rejects.toThrow(/refusé/);
});
it("laisse les URL externes telles quelles", async () => {
await expect(toDataUrl("https://exemple.fr/logo.png")).resolves.toBe("https://exemple.fr/logo.png");
});
});
-58
View File
@@ -1,58 +0,0 @@
/**
* Ressources fournies aux compositions Remotion pendant un rendu serveur.
*/
import fs from "fs";
import path from "path";
/** Dossier temporaire servi sous /uploads/temp (voir server/index.ts). */
export const REMOTION_TEMP_DIR =
process.env.TEMP_UPLOAD_DIR || path.join(process.cwd(), "uploads", "temp");
/**
* URL HTTP locale d'un fichier servi par l'application. Vidéo et audio sont
* lus côté Node par le moteur de rendu : l'adresse locale suffit.
*/
export function localHttpUrl(url: string): string {
if (!url.startsWith("/")) return url;
const port = process.env.PORT || "5555";
return `http://localhost:${port}${url}`;
}
/** URL servie d'un fichier du dossier temporaire. */
export function tempFileUrl(absolutePath: string): string {
const relative = path.relative(REMOTION_TEMP_DIR, absolutePath).split(path.sep).join("/");
return localHttpUrl(`/uploads/temp/${relative}`);
}
const MIME_BY_EXT: Record<string, string> = {
png: "image/png",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
};
/**
* Convertit un chemin local /uploads/... en data URL : les images sont
* affichées par Chromium, qui ne lit pas http://localhost de façon fiable
* dans Docker. Refuse tout chemin sortant du dossier uploads.
*/
export async function toDataUrl(relativeUrl: string): Promise<string> {
if (!relativeUrl.startsWith("/uploads/")) return relativeUrl;
const uploadsRoot = path.join(process.cwd(), "uploads") + path.sep;
const tempRoot = path.resolve(REMOTION_TEMP_DIR) + path.sep;
const filePath = relativeUrl.startsWith("/uploads/temp/")
? path.resolve(REMOTION_TEMP_DIR, "." + relativeUrl.slice("/uploads/temp".length))
: path.resolve(process.cwd(), "." + relativeUrl);
if (!filePath.startsWith(uploadsRoot) && !filePath.startsWith(tempRoot)) {
throw new Error(`Chemin d'image refusé : ${relativeUrl}`);
}
try {
const buffer = await fs.promises.readFile(filePath);
const ext = path.extname(filePath).slice(1).toLowerCase();
return `data:${MIME_BY_EXT[ext] ?? "image/jpeg"};base64,${buffer.toString("base64")}`;
} catch {
console.warn(`⚠️ [Remotion] Image introuvable : ${filePath}`);
return relativeUrl;
}
}
-85
View File
@@ -1,85 +0,0 @@
/**
* Rendu Remotion côté serveur, commun aux Reels vidéo et images : bundle mis
* en cache, réglages d'encodage des réseaux sociaux, progression.
*/
import os from "os";
import path from "path";
import { bundle } from "@remotion/bundler";
import { renderMedia, selectComposition } from "@remotion/renderer";
let bundleCache: Promise<string> | null = null;
/** Bundle des compositions, construit une fois par processus. */
export function getRemotionBundle(): Promise<string> {
if (!bundleCache) {
// process.cwd() = /app dans Docker, racine du projet en dev
const entryPoint = path.resolve(process.cwd(), "client/src/remotion/index.ts");
console.log("📦 [Remotion] Bundle de", entryPoint);
bundleCache = bundle({
entryPoint,
// Mêmes alias que Vite : les compositions importent @shared/*
webpackOverride: (config) => ({
...config,
resolve: {
...config.resolve,
alias: {
...(config.resolve?.alias ?? {}),
"@shared": path.resolve(process.cwd(), "shared"),
},
},
}),
}).catch((error) => {
bundleCache = null; // réessayer au prochain rendu
throw error;
});
}
return bundleCache;
}
/** Rendus en parallèle dans Chromium : par défaut la moitié des cœurs, 1 au minimum. */
function renderConcurrency(): number {
const configured = Number(process.env.RENDER_CONCURRENCY);
if (Number.isFinite(configured) && configured > 0) return Math.floor(configured);
return Math.max(1, Math.floor(os.cpus().length / 2));
}
export async function renderReelComposition(options: {
compositionId: "ReelVideo";
inputProps: Record<string, unknown>;
outputLocation: string;
onProgress?: (ratio: number) => void;
}): Promise<void> {
const serveUrl = await getRemotionBundle();
const composition = await selectComposition({
serveUrl,
id: options.compositionId,
inputProps: options.inputProps,
});
const startedAt = Date.now();
await renderMedia({
composition,
serveUrl,
inputProps: options.inputProps,
outputLocation: options.outputLocation,
codec: "h264",
// Qualité visuelle élevée, conforme aux recommandations Instagram/TikTok
crf: 18,
x264Preset: "medium",
pixelFormat: "yuv420p",
colorSpace: "bt709",
audioCodec: "aac",
audioBitrate: "192k",
concurrency: renderConcurrency(),
onProgress: ({ progress }) => options.onProgress?.(progress),
// Une image lente (décodage vidéo sur une petite machine) ne doit pas faire échouer le rendu
timeoutInMilliseconds: 60_000,
onBrowserLog: (log) => {
if (log.type === "error") console.warn("⚠️ [Remotion] Navigateur :", log.text.slice(0, 300));
},
});
console.log(
`🎞️ [Remotion] ${options.compositionId} rendu en ${((Date.now() - startedAt) / 1000).toFixed(1)} s`,
);
}
-160
View File
@@ -1,160 +0,0 @@
/**
* Rendu d'un Reel à partir d'une vidéo, puis médiathèque et publication.
* Exécuté par la file `reel_jobs` (voir queue.ts).
*
* Deux moteurs :
* - « remotion » (défaut) : le service Python prépare l'image (recadrage, HDR,
* stabilisation) et la piste son finale, Remotion compose les sous-titres
* animés, le logo et l'effet de fin — le même rendu que l'aperçu ;
* - « ffmpeg » : tout est fait par le service Python (sous-titres ASS),
* plus rapide, conservé en secours (REEL_RENDERER=ffmpeg).
*/
import fs from "fs";
import path from "path";
import { videoReelParamsSchema, type VideoReelParams } from "@shared/reel";
import { storage } from "../../storage";
import { ffmpegService, type ReelRenderOptions } from "../ffmpeg";
import { resolveInternalUrl } from "../minio";
import type { JobContext } from "./queue";
import { resolveGeminiApiKey, resolveLogoPath, resolveMusicUrl, resolveQwenTarget } from "./assets";
import { describePublishFailure, publishReelToPages, storeRenderedVideo } from "./publish";
import { REMOTION_TEMP_DIR, tempFileUrl, toDataUrl } from "./remotionAssets";
import { renderReelComposition } from "./remotionRenderer";
export type ReelRenderer = "remotion" | "ffmpeg";
export function configuredRenderer(): ReelRenderer {
return process.env.REEL_RENDERER === "ffmpeg" ? "ffmpeg" : "remotion";
}
export async function runVideoReelJob({ job, progress }: JobContext) {
if (!job.postId) throw new Error("Job vidéo sans post associé");
const postId = job.postId;
const params = videoReelParamsSchema.parse(job.params);
await progress(5, "prepare");
const media = await storage.getMediaById(params.videoMediaId);
if (!media || media.type !== "video") {
throw new Error("Vidéo source introuvable ou invalide");
}
const [musicUrl, logoPath, geminiApiKey, qwen] = await Promise.all([
resolveMusicUrl(params.musicTrackId, params.musicUrl),
resolveLogoPath(),
resolveGeminiApiKey(params.ttsEngine),
resolveQwenTarget(params.ttsEngine),
]);
const options: ReelRenderOptions = {
text: params.srtCues ? undefined : params.overlayText,
srtCues: params.srtCues,
musicUrl,
ttsEnabled: params.ttsEnabled,
ttsVoice: params.ttsVoice,
ttsEngine: params.ttsEngine,
ttsStyle: params.ttsStyle,
geminiApiKey,
qwen,
fontSize: params.fontSize,
musicVolume: params.musicVolume,
drawText: params.drawText,
stabilize: params.stabilize,
storeName: params.storeName,
enableEndingEffect: params.enableEndingEffect,
showLogo: params.showLogo,
};
const renderer = configuredRenderer();
const startedAt = Date.now();
const videoBuffer =
renderer === "remotion"
? await renderWithRemotion(job.id, resolveInternalUrl(media.originalUrl), options, params, logoPath, progress)
: await renderWithFfmpeg(resolveInternalUrl(media.originalUrl), options, logoPath, progress);
console.log(`⏱️ [Reels] Rendu ${renderer} : ${((Date.now() - startedAt) / 1000).toFixed(1)} s`);
await progress(88, "store");
const processedMedia = await storeRenderedVideo(job.userId, videoBuffer, `reel-${Date.now()}.mp4`);
await storage.updatePostMedia(postId, [processedMedia.id]);
await progress(93, "publish");
const results = await publishReelToPages({
postId,
pageIds: params.pageIds,
videoBuffer,
description: params.description || params.overlayText || "",
scheduledFor: params.scheduledFor,
alsoStory: params.alsoStory,
});
if (!results.some((r) => r.success)) {
throw new Error(describePublishFailure(results));
}
return { mediaId: processedMedia.id, renderer, results };
}
async function renderWithFfmpeg(
videoUrl: string,
options: ReelRenderOptions,
logoPath: string | undefined,
progress: JobContext["progress"],
): Promise<Buffer> {
await progress(15, "render");
// Le logo sert au coin de la vidéo et/ou à l'effet de fin : inutile de l'envoyer si aucun des deux
const logoUsed = Boolean(logoPath) && (options.showLogo !== false || options.enableEndingEffect !== false);
const rendered = await ffmpegService.renderReel(videoUrl, {
...options,
watermarkUrl: logoUsed && logoPath ? resolveInternalUrl(logoPath) : undefined,
});
return rendered.video;
}
async function renderWithRemotion(
jobId: string,
videoUrl: string,
options: ReelRenderOptions,
params: VideoReelParams,
logoPath: string | undefined,
progress: JobContext["progress"],
): Promise<Buffer> {
const workdir = path.join(REMOTION_TEMP_DIR, `reel-${jobId}`);
try {
// 1. Image recadrée à la durée finale, piste son finale, mots minutés
await progress(10, "voice");
const prepared = await ffmpegService.prepareReel(videoUrl, { ...options, hasLogo: Boolean(logoPath) }, workdir);
// 2. Composition : sous-titres animés, logo, effet de fin
await progress(40, "render");
const outputLocation = path.join(workdir, "reel.mp4");
let lastReported = 40;
await renderReelComposition({
compositionId: "ReelVideo",
outputLocation,
inputProps: {
videoUrl: tempFileUrl(prepared.videoPath),
videoDuration: prepared.totalDuration,
totalDuration: prepared.totalDuration,
words: prepared.words,
captionStyle: params.captionStyle,
logoUrl: logoPath ? await toDataUrl(logoPath) : undefined,
showWatermark: params.showLogo,
storeName: params.enableEndingEffect ? params.storeName : undefined,
logoStart: prepared.logoStart,
fadeStart: prepared.fadeStart,
fadeDuration: prepared.fadeDuration,
mixedAudioUrl: prepared.audioPath ? tempFileUrl(prepared.audioPath) : undefined,
},
onProgress: (ratio) => {
const pct = 40 + Math.floor(ratio * 45);
if (pct >= lastReported + 5) {
lastReported = pct;
progress(pct, "render").catch(() => { /* non bloquant */ });
}
},
});
return await fs.promises.readFile(outputLocation);
} finally {
await fs.promises.rm(workdir, { recursive: true, force: true }).catch(() => { /* déjà absent */ });
}
}
-182
View File
@@ -1,182 +0,0 @@
import cron, { ScheduledTask } from "node-cron";
import { storage } from "../storage";
import { facebookService } from "./facebook";
import { tiktokService } from "./tiktok";
import { minioService as storageService, readMediaBuffer } from "./minio";
export class SchedulerService {
private task: ScheduledTask | null = null;
start() {
// Run every minute to check for pending posts
this.task = cron.schedule("* * * * *", async () => {
await this.processPendingPosts();
});
console.log("Scheduler service started");
}
stop() {
if (this.task) {
this.task.stop();
console.log("Scheduler service stopped");
}
}
private async processPendingPosts() {
try {
const pendingPosts = await storage.getPendingScheduledPosts();
for (const scheduledPost of pendingPosts) {
try {
// Reject legacy "both" posts - they should have been split at creation
// New posts are automatically split in /api/posts
if (scheduledPost.postType === 'both') {
const errorMsg = 'Legacy postType "both" is no longer supported. Deleting this scheduled entry. Please recreate this post - it will automatically be split into separate feed and story posts.';
console.error(`Rejecting and deleting legacy "both" post ${scheduledPost.id}: ${errorMsg}`);
// Delete the legacy post entirely (don't mark as published - that's misleading)
await storage.deleteScheduledPost(scheduledPost.id);
// Update parent post status to draft so user can reschedule
await storage.updatePost(scheduledPost.postId, {
status: "draft",
});
continue;
}
await this.publishPost(scheduledPost);
} catch (error) {
console.error(`Error publishing post ${scheduledPost.id}:`, error);
await storage.updateScheduledPost(scheduledPost.id, {
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
} catch (error) {
console.error("Error processing pending posts:", error);
}
}
private async publishPost(scheduledPost: any) {
const post = await storage.getPost(scheduledPost.postId);
const page = await storage.getSocialPage(scheduledPost.pageId);
if (!post || !page) {
throw new Error("Post or page not found");
}
console.log(`Publishing post ${post.id} to ${page.platform} page ${page.pageName}`);
// Get all media for the post (for multi-photo carousels)
const postMediaList = await storage.getPostMedia(post.id);
const mediaList: any[] = [];
for (const pm of postMediaList) {
const media = await storage.getMediaById(pm.mediaId);
if (media) {
mediaList.push(media);
}
}
// Validate that story posts have media
// Note: "both" should never reach here since they're split at creation
if (scheduledPost.postType === 'story' && mediaList.length === 0) {
const errorMsg = 'Les stories nécessitent un média. Ce post ne peut pas être publié.';
console.error(`Deleting invalid story post ${scheduledPost.id} without media: ${errorMsg}`);
// Delete the invalid scheduled post (don't mark as published - that's misleading)
await storage.deleteScheduledPost(scheduledPost.id);
// Set post to draft so user can fix and reschedule
await storage.updatePost(post.id, {
status: "draft",
});
return;
}
// Publish to Facebook/Instagram/TikTok
if (page.platform === 'facebook') {
const externalPostId = await facebookService.publishPost(post, page, scheduledPost.postType, mediaList);
// Update the scheduled post as published (clear any previous error)
await storage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(),
externalPostId,
error: null,
});
} else if (page.platform === 'tiktok') {
const publishId = await this.publishToTiktok(post, page, mediaList);
// TikTok publie de façon asynchrone : on marque l'envoi comme effectué pour
// ne pas republier à la minute suivante, et le poller de statut renseignera
// l'identifiant définitif du post.
await storage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(),
publishId,
publishStatus: 'PROCESSING_UPLOAD',
error: null,
});
} else if (page.platform === 'instagram') {
// Instagram publishing not yet implemented
console.warn(`Instagram publishing not yet implemented for post ${post.id}`);
await storage.updateScheduledPost(scheduledPost.id, {
error: 'Instagram publishing not yet implemented',
});
await storage.updatePost(post.id, {
status: "failed",
});
return;
} else {
throw new Error(`Unsupported platform: ${page.platform}`);
}
// Update post status
await storage.updatePost(post.id, {
status: "published",
});
// Delete local video files only when no other unpublished scheduled posts reference them
for (const mediaItem of mediaList) {
if (mediaItem.type === 'video' && mediaItem.cloudinaryPublicId) {
try {
const remainingScheduled = await storage.getScheduledPostsByPost(post.id);
const hasPending = remainingScheduled.some(sp => !sp.publishedAt);
if (!hasPending) {
await storageService.deleteMedia(mediaItem.cloudinaryPublicId, mediaItem.userId, 'video');
console.log(`[Scheduler] Deleted local video file for media ${mediaItem.id}`);
} else {
console.log(`[Scheduler] Kept local video file for media ${mediaItem.id} (pending scheduled posts still exist)`);
}
} catch (err) {
console.warn(`[Scheduler] Failed to delete local video file for media ${mediaItem.id}:`, err);
}
}
}
console.log(`Successfully published post ${post.id} to ${page.platform} page ${page.pageName}`);
}
/**
* Envoie la vidéo d'un post planifié sur un compte TikTok.
* TikTok ne sait pas programmer une publication : c'est notre planificateur qui
* déclenche l'envoi à l'heure voulue.
*
* @returns le publish_id à suivre pour connaître le résultat final
*/
private async publishToTiktok(post: any, page: any, mediaList: any[]): Promise<string> {
const videoMedia = mediaList.find(m => m.type === 'video');
if (!videoMedia) {
throw new Error(
`Le compte TikTok "${page.pageName}" ne peut recevoir que des vidéos : ce post n'en contient pas.`
);
}
const videoBuffer = await readMediaBuffer(videoMedia.originalUrl);
return await tiktokService.publishVideoFromBuffer(page, videoBuffer, post.content);
}
}
export const schedulerService = new SchedulerService();
-101
View File
@@ -1,101 +0,0 @@
/**
* Génération de vignettes vidéo.
*
* Les vidéos sources sont supprimées du disque une fois publiées (le
* planificateur libère la place, la purge quotidienne fait le reste), alors que
* la ligne `media` survit pour l'historique. Sans vignette persistée, l'interface
* tente alors de lire un fichier disparu : d'où les « Video thumbnail generation
* failed » et les tuiles vides.
*
* On extrait donc une image au moment de la création du média, et elle reste
* disponible après la disparition de la vidéo.
*/
import fs from 'fs';
import path from 'path';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { minioService } from './minio';
const execFileAsync = promisify(execFile);
const TEMP_DIR = path.join(process.cwd(), 'uploads', 'temp');
/**
* Extrait une image d'une vidéo avec FFmpeg.
*
* @param videoPath chemin du fichier vidéo
* @param outputPath chemin de l'image à écrire
* @param seekTime instant de la capture, en secondes
* @returns true si l'image a bien été écrite
*/
export async function generateVideoThumbnail(
videoPath: string,
outputPath: string,
seekTime: number = 1
): Promise<boolean> {
try {
// Arguments séparés : aucun passage par le shell, les chemins ne sont jamais interprétés
await execFileAsync('ffmpeg', [
'-y', '-ss', String(seekTime), '-i', videoPath, '-vframes', '1', '-q:v', '2', outputPath,
]);
return fs.existsSync(outputPath);
} catch (error) {
console.warn('⚠️ Failed to generate video thumbnail:', error);
return false;
}
}
/**
* Produit la vignette d'une vidéo et la range dans le stockage public.
*
* Tolérante à l'échec : une vignette manquante dégrade l'affichage mais ne doit
* jamais faire échouer une publication.
*
* @param source chemin du fichier vidéo, ou son contenu en mémoire
* @returns l'URL publique de la vignette, ou null si l'extraction a échoué
*/
export async function createVideoThumbnail(source: string | Buffer): Promise<string | null> {
const stamp = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
const outputPath = path.join(TEMP_DIR, `thumb-${stamp}.jpg`);
// Une vidéo reçue en mémoire doit d'abord être posée sur le disque pour FFmpeg
let videoPath: string;
let tempVideoPath: string | null = null;
try {
if (!fs.existsSync(TEMP_DIR)) {
fs.mkdirSync(TEMP_DIR, { recursive: true });
}
if (Buffer.isBuffer(source)) {
tempVideoPath = path.join(TEMP_DIR, `thumb-src-${stamp}.mp4`);
await fs.promises.writeFile(tempVideoPath, source);
videoPath = tempVideoPath;
} else {
videoPath = source;
if (!fs.existsSync(videoPath)) {
console.warn(`⚠️ [Thumbnail] Vidéo introuvable pour la vignette : ${videoPath}`);
return null;
}
}
const ok = await generateVideoThumbnail(videoPath, outputPath);
if (!ok) return null;
const buffer = await fs.promises.readFile(outputPath);
const { url } = await minioService.uploadThumbnail(buffer, `thumb-${stamp}.jpg`);
console.log(`🖼️ [Thumbnail] Vignette générée : ${url}`);
return url;
} catch (error) {
console.warn('⚠️ [Thumbnail] Génération de la vignette impossible :', error);
return null;
} finally {
for (const file of [outputPath, tempVideoPath]) {
if (file) {
await fs.promises.unlink(file).catch(() => { /* déjà absent */ });
}
}
}
}
-574
View File
@@ -1,574 +0,0 @@
/**
* Service de publication TikTok (Content Posting API v2).
*
* Différences structurantes avec Facebook, qui expliquent la forme de ce service :
* - Pas de token collable à la main : chaque compte magasin passe par OAuth.
* L'access token ne vit que 24h et doit être renouvelé avec le refresh token
* (valable 1 an) avant chaque publication.
* - La publication est asynchrone : l'upload rend un `publish_id`, et l'identifiant
* définitif du post n'est connu qu'en interrogeant /status/fetch/ ensuite.
* - `creator_info/query` doit être appelé avant chaque publication pour connaître
* les niveaux de confidentialité réellement autorisés sur le compte.
*
* Note : tant que l'application n'a pas passé l'audit TikTok, le seul niveau de
* confidentialité disponible est SELF_ONLY (vidéo visible du seul propriétaire du
* compte). Le code s'adapte automatiquement à ce que le compte autorise.
*/
import type { Request } from 'express';
import { storage } from '../storage';
import { resolvePublicBaseUrl } from '../utils/public_url';
import type { SocialPage } from '@shared/schema';
const AUTH_BASE_URL = 'https://www.tiktok.com/v2/auth/authorize/';
const API_BASE_URL = 'https://open.tiktokapis.com/v2';
/** Scopes demandés à l'autorisation. `video.publish` nécessite l'audit TikTok. */
const DEFAULT_SCOPES = 'user.info.basic,video.publish';
/** TikTok impose des chunks entre 5 Mo et 64 Mo. */
const MAX_SINGLE_CHUNK_SIZE = 64 * 1024 * 1024;
const CHUNK_SIZE = 32 * 1024 * 1024;
/** Longueur maximale de la légende d'une vidéo TikTok. */
const MAX_TITLE_LENGTH = 2200;
/** Renouvelle le token si sa fin de validité est à moins de 5 minutes. */
const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000;
export interface TiktokCreatorInfo {
creatorAvatarUrl?: string;
creatorUsername?: string;
creatorNickname?: string;
privacyLevelOptions: string[];
commentDisabled: boolean;
duetDisabled: boolean;
stitchDisabled: boolean;
maxVideoPostDurationSec?: number;
}
export interface TiktokPublishOptions {
privacyLevel?: string;
disableComment?: boolean;
disableDuet?: boolean;
disableStitch?: boolean;
}
export interface TiktokPublishStatus {
status: string;
failReason?: string;
postId?: string;
}
interface TiktokTokenResponse {
access_token: string;
expires_in: number;
open_id: string;
refresh_token: string;
refresh_expires_in: number;
scope: string;
token_type: string;
}
export class TiktokService {
/**
* Identifiants de l'application développeur TikTok (globaux à l'instance).
* La base prime sur les variables d'environnement.
*/
private async getCredentials(): Promise<{ clientKey: string; clientSecret: string }> {
const config = await storage.getTiktokConfig();
const clientKey = config?.clientKey || process.env.TIKTOK_CLIENT_KEY;
const clientSecret = config?.clientSecret || process.env.TIKTOK_CLIENT_SECRET;
if (!clientKey || !clientSecret) {
throw new Error(
"TikTok n'est pas configuré. Renseignez le client key et le client secret " +
"de votre application TikTok dans les paramètres."
);
}
return { clientKey, clientSecret };
}
async isConfigured(): Promise<boolean> {
try {
await this.getCredentials();
return true;
} catch {
return false;
}
}
/**
* URI de redirection OAuth. `req` sert de repli quand `APP_URL` n'est pas
* défini : le domaine public est alors lu sur la requête plutôt que remplacé
* par `http://localhost:5555`.
*/
getRedirectUri(req?: Request): string {
if (process.env.TIKTOK_REDIRECT_URI) {
return process.env.TIKTOK_REDIRECT_URI;
}
return `${resolvePublicBaseUrl(req)}/api/tiktok/callback`;
}
/**
* URL d'autorisation à ouvrir pour connecter le compte TikTok d'un magasin.
* Chaque magasin répète ce flux : un compte = une ligne dans social_pages.
*/
async buildAuthorizationUrl(state: string, req?: Request): Promise<string> {
const { clientKey } = await this.getCredentials();
const params = new URLSearchParams({
client_key: clientKey,
scope: process.env.TIKTOK_SCOPES || DEFAULT_SCOPES,
response_type: 'code',
redirect_uri: this.getRedirectUri(req),
state,
});
return `${AUTH_BASE_URL}?${params.toString()}`;
}
/** Échange le code d'autorisation contre un couple access/refresh token. */
async exchangeCodeForToken(code: string, req?: Request): Promise<TiktokTokenResponse> {
const { clientKey, clientSecret } = await this.getCredentials();
return await this.requestToken({
client_key: clientKey,
client_secret: clientSecret,
// TikTok renvoie le code URL-encodé dans la query string
code: decodeURIComponent(code),
grant_type: 'authorization_code',
redirect_uri: this.getRedirectUri(req),
});
}
/** Renouvelle un access token expiré. TikTok rend aussi un nouveau refresh token. */
async refreshAccessToken(refreshToken: string): Promise<TiktokTokenResponse> {
const { clientKey, clientSecret } = await this.getCredentials();
return await this.requestToken({
client_key: clientKey,
client_secret: clientSecret,
grant_type: 'refresh_token',
refresh_token: refreshToken,
});
}
private async requestToken(body: Record<string, string>): Promise<TiktokTokenResponse> {
const response = await fetch(`${API_BASE_URL}/oauth/token/`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Cache-Control': 'no-cache',
},
body: new URLSearchParams(body).toString(),
});
const raw = await response.text();
let data: any;
try {
data = JSON.parse(raw);
} catch {
throw new Error(`Réponse TikTok illisible (${response.status}): ${raw.slice(0, 200)}`);
}
// L'endpoint OAuth signale les erreurs avec `error` + `error_description`
if (!response.ok || data.error) {
const description = data.error_description || data.error || raw.slice(0, 200);
throw new Error(`Erreur OAuth TikTok: ${description}`);
}
if (!data.access_token) {
throw new Error("Réponse TikTok inattendue : aucun access token reçu");
}
return data as TiktokTokenResponse;
}
/**
* Récupère un access token valide pour une page, en le renouvelant si besoin.
* Le nouveau couple de tokens est persisté immédiatement : le refresh token
* TikTok est à usage unique, le perdre déconnecterait le compte.
*/
async getValidAccessToken(page: SocialPage): Promise<string> {
if (page.platform !== 'tiktok') {
throw new Error('Ce service ne gère que les comptes TikTok');
}
const expiresAt = page.tokenExpiresAt ? new Date(page.tokenExpiresAt).getTime() : 0;
const stillValid = expiresAt - TOKEN_REFRESH_MARGIN_MS > Date.now();
if (stillValid && page.accessToken) {
return page.accessToken;
}
if (!page.refreshToken) {
throw new Error(
`Le compte TikTok "${page.pageName}" doit être reconnecté (aucun refresh token enregistré).`
);
}
console.log(`🔄 [TikTok] Renouvellement du token pour ${page.pageName}`);
let tokens: TiktokTokenResponse;
try {
tokens = await this.refreshAccessToken(page.refreshToken);
} catch (error) {
await storage.updateSocialPage(page.id, {
tokenStatus: 'expired',
lastTokenCheck: new Date(),
});
throw new Error(
`Impossible de renouveler le token TikTok de "${page.pageName}". ` +
`Le compte doit être reconnecté. (${error instanceof Error ? error.message : 'erreur inconnue'})`
);
}
await this.persistTokens(page.id, tokens);
return tokens.access_token;
}
/** Enregistre un couple de tokens fraîchement obtenu sur une page. */
async persistTokens(pageId: string, tokens: TiktokTokenResponse): Promise<void> {
await storage.updateSocialPage(pageId, {
accessToken: tokens.access_token,
tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000),
refreshToken: tokens.refresh_token,
refreshTokenExpiresAt: new Date(Date.now() + tokens.refresh_expires_in * 1000),
scopes: tokens.scope,
tokenStatus: 'valid',
lastTokenCheck: new Date(),
});
}
/** Profil public du compte, utilisé pour nommer la page à la connexion. */
async getUserInfo(accessToken: string): Promise<{
openId: string;
displayName: string;
avatarUrl?: string;
}> {
const params = new URLSearchParams({
fields: 'open_id,display_name,avatar_url',
});
const response = await fetch(`${API_BASE_URL}/user/info/?${params.toString()}`, {
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
});
const data = await this.parseApiResponse(response, 'récupération du profil');
const user = data.user || {};
return {
openId: user.open_id,
displayName: user.display_name || 'Compte TikTok',
avatarUrl: user.avatar_url,
};
}
/**
* Options réellement autorisées sur le compte (confidentialité, commentaires,
* duo, stitch, durée max). TikTok impose cet appel avant chaque publication.
*/
async getCreatorInfo(page: SocialPage): Promise<TiktokCreatorInfo> {
return await this.queryCreatorInfo(await this.getValidAccessToken(page));
}
/**
* Variante prenant un token déjà résolu : le refresh token TikTok tourne à chaque
* renouvellement, donc une même opération ne doit déclencher qu'un seul refresh.
*/
private async queryCreatorInfo(accessToken: string): Promise<TiktokCreatorInfo> {
const response = await fetch(`${API_BASE_URL}/post/publish/creator_info/query/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json; charset=UTF-8',
},
});
const data = await this.parseApiResponse(response, 'récupération des options du compte');
return {
creatorAvatarUrl: data.creator_avatar_url,
creatorUsername: data.creator_username,
creatorNickname: data.creator_nickname,
privacyLevelOptions: data.privacy_level_options || [],
commentDisabled: !!data.comment_disabled,
duetDisabled: !!data.duet_disabled,
stitchDisabled: !!data.stitch_disabled,
maxVideoPostDurationSec: data.max_video_post_duration_sec,
};
}
/**
* Publie une vidéo sur un compte TikTok en trois temps :
* 1. init — déclare la taille et le découpage, récupère publish_id + upload_url
* 2. upload — envoie les données binaires par chunks
* 3. (plus tard) status/fetch — le poller récupère l'identifiant du post
*
* On envoie le fichier (FILE_UPLOAD) plutôt qu'une URL (PULL_FROM_URL) : cette
* seconde option exigerait de faire vérifier le domaine de l'instance chez TikTok
* et que les médias soient publiquement accessibles.
*
* @returns le publish_id à suivre pour connaître le résultat final
*/
async publishVideoFromBuffer(
page: SocialPage,
videoBuffer: Buffer,
caption?: string,
options: TiktokPublishOptions = {}
): Promise<string> {
if (page.platform !== 'tiktok') {
throw new Error('Ce service ne gère que les comptes TikTok');
}
// Un seul renouvellement de token pour toute la publication
const accessToken = await this.getValidAccessToken(page);
const creatorInfo = await this.queryCreatorInfo(accessToken);
const privacyLevel = this.resolvePrivacyLevel(creatorInfo, options.privacyLevel, page.pageName);
const videoSize = videoBuffer.length;
const { chunkSize, totalChunkCount } = this.computeChunking(videoSize);
console.log('🎬 [TikTok] Publication en cours:', {
compte: page.pageName,
taille: `${(videoSize / 1024 / 1024).toFixed(2)} Mo`,
chunks: totalChunkCount,
confidentialite: privacyLevel,
});
// ── Étape 1 : init ────────────────────────────────────────────────────────
const initBody = {
post_info: {
title: (caption || '').slice(0, MAX_TITLE_LENGTH),
privacy_level: privacyLevel,
// Une option désactivée côté compte doit obligatoirement être déclarée désactivée
disable_comment: creatorInfo.commentDisabled || !!options.disableComment,
disable_duet: creatorInfo.duetDisabled || !!options.disableDuet,
disable_stitch: creatorInfo.stitchDisabled || !!options.disableStitch,
},
source_info: {
source: 'FILE_UPLOAD',
video_size: videoSize,
chunk_size: chunkSize,
total_chunk_count: totalChunkCount,
},
};
const initResponse = await fetch(`${API_BASE_URL}/post/publish/video/init/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json; charset=UTF-8',
},
body: JSON.stringify(initBody),
});
const initData = await this.parseApiResponse(initResponse, 'initialisation de la publication');
const publishId: string = initData.publish_id;
const uploadUrl: string = initData.upload_url;
if (!publishId || !uploadUrl) {
throw new Error("TikTok n'a pas renvoyé d'identifiant de publication");
}
console.log(`📤 [TikTok] Upload initialisé: publish_id=${publishId}`);
// ── Étape 2 : upload des chunks ───────────────────────────────────────────
for (let index = 0; index < totalChunkCount; index++) {
const start = index * chunkSize;
// Le dernier chunk absorbe le reliquat
const end = index === totalChunkCount - 1 ? videoSize - 1 : start + chunkSize - 1;
const chunk = videoBuffer.subarray(start, end + 1);
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
headers: {
'Content-Type': 'video/mp4',
'Content-Length': chunk.length.toString(),
'Content-Range': `bytes ${start}-${end}/${videoSize}`,
},
body: new Blob([chunk], { type: 'video/mp4' }),
});
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text();
throw new Error(
`Échec de l'envoi du chunk ${index + 1}/${totalChunkCount} vers TikTok ` +
`(${uploadResponse.status}): ${errorText.slice(0, 200)}`
);
}
console.log(`📤 [TikTok] Chunk ${index + 1}/${totalChunkCount} envoyé`);
}
console.log(`✅ [TikTok] Vidéo transmise pour ${page.pageName}, traitement en cours côté TikTok`);
return publishId;
}
/** Interroge TikTok sur l'avancement d'une publication. */
async getPublishStatus(page: SocialPage, publishId: string): Promise<TiktokPublishStatus> {
const accessToken = await this.getValidAccessToken(page);
const response = await fetch(`${API_BASE_URL}/post/publish/status/fetch/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json; charset=UTF-8',
},
body: JSON.stringify({ publish_id: publishId }),
});
const data = await this.parseApiResponse(response, 'suivi de la publication');
// Le nom du champ comporte bien une faute de frappe côté API TikTok
const postIds: string[] = data.publicaly_available_post_id || data.publicly_available_post_id || [];
return {
status: data.status,
failReason: data.fail_reason,
postId: postIds.length > 0 ? postIds[0] : undefined,
};
}
/**
* Met à jour les publications TikTok encore en cours de traitement.
* Appelé périodiquement : c'est ce qui renseigne l'identifiant définitif du post
* (ou l'erreur) une fois que TikTok a fini d'encoder la vidéo.
*/
async syncPendingPublications(): Promise<void> {
const pending = await storage.getScheduledPostsAwaitingPublishStatus();
if (pending.length === 0) {
return;
}
console.log(`🔎 [TikTok] Suivi de ${pending.length} publication(s) en cours...`);
for (const scheduledPost of pending) {
try {
const page = await storage.getSocialPage(scheduledPost.pageId);
if (!page || page.platform !== 'tiktok' || !scheduledPost.publishId) {
continue;
}
const status = await this.getPublishStatus(page, scheduledPost.publishId);
if (status.status === 'PUBLISH_COMPLETE') {
await storage.updateScheduledPost(scheduledPost.id, {
publishStatus: status.status,
// À défaut d'ID public (compte privé), on garde le publish_id comme référence
externalPostId: status.postId || scheduledPost.publishId,
error: null,
});
console.log(`✅ [TikTok] Publication terminée sur ${page.pageName}`);
continue;
}
if (status.status === 'FAILED') {
await storage.updateScheduledPost(scheduledPost.id, {
publishStatus: 'FAILED',
error: `Publication TikTok échouée : ${status.failReason || 'raison inconnue'}`,
});
console.error(`❌ [TikTok] Publication échouée sur ${page.pageName}: ${status.failReason}`);
continue;
}
// Filet de sécurité : au-delà de 24h, on cesse d'interroger l'API
const sentAt = scheduledPost.publishedAt ? new Date(scheduledPost.publishedAt).getTime() : 0;
if (sentAt && Date.now() - sentAt > 24 * 60 * 60 * 1000) {
await storage.updateScheduledPost(scheduledPost.id, {
publishStatus: 'FAILED',
error: `Statut TikTok toujours "${status.status}" après 24h, suivi abandonné.`,
});
console.error(`❌ [TikTok] Suivi abandonné pour ${page.pageName} (statut ${status.status})`);
continue;
}
await storage.updateScheduledPost(scheduledPost.id, { publishStatus: status.status });
} catch (error) {
console.error(
`⚠️ [TikTok] Impossible de récupérer le statut de la publication ${scheduledPost.id}:`,
error instanceof Error ? error.message : error
);
}
}
}
/**
* Choisit un niveau de confidentialité réellement autorisé sur le compte.
* Sans audit TikTok validé, seul SELF_ONLY est proposé : on s'y rabat plutôt
* que d'échouer, en le signalant dans les logs.
*/
private resolvePrivacyLevel(
creatorInfo: TiktokCreatorInfo,
requested: string | undefined,
pageName: string
): string {
const options = creatorInfo.privacyLevelOptions;
if (options.length === 0) {
throw new Error(
`Le compte TikTok "${pageName}" ne propose aucun niveau de confidentialité. ` +
`Vérifiez que le compte est bien autorisé et que l'application dispose du scope video.publish.`
);
}
const wanted = requested || 'PUBLIC_TO_EVERYONE';
if (options.includes(wanted)) {
return wanted;
}
const fallback = options.includes('SELF_ONLY') ? 'SELF_ONLY' : options[0];
console.warn(
`⚠️ [TikTok] "${wanted}" indisponible sur ${pageName} (options: ${options.join(', ')}). ` +
`Publication en ${fallback}. Tant que l'application TikTok n'est pas auditée, ` +
`les vidéos restent privées.`
);
return fallback;
}
/**
* Découpage du fichier : un seul chunk jusqu'à 64 Mo, sinon des chunks de 32 Mo,
* le dernier absorbant le reliquat (règle imposée par TikTok).
*/
private computeChunking(videoSize: number): { chunkSize: number; totalChunkCount: number } {
if (videoSize <= MAX_SINGLE_CHUNK_SIZE) {
return { chunkSize: videoSize, totalChunkCount: 1 };
}
return {
chunkSize: CHUNK_SIZE,
totalChunkCount: Math.floor(videoSize / CHUNK_SIZE),
};
}
/**
* Les endpoints v2 répondent HTTP 200 avec `error.code === 'ok'` en cas de succès :
* il ne suffit pas de regarder le statut HTTP.
*/
private async parseApiResponse(response: Response, context: string): Promise<any> {
const raw = await response.text();
let payload: any;
try {
payload = JSON.parse(raw);
} catch {
throw new Error(`Réponse TikTok illisible lors de la ${context} (${response.status}): ${raw.slice(0, 200)}`);
}
const errorCode = payload?.error?.code;
if (!response.ok || (errorCode && errorCode !== 'ok')) {
const message = payload?.error?.message || raw.slice(0, 200);
throw new Error(`Erreur TikTok lors de la ${context}: ${message} (code: ${errorCode || response.status})`);
}
return payload.data || {};
}
}
export const tiktokService = new TiktokService();
-358
View File
@@ -1,358 +0,0 @@
// crypto removed, using shared encryption utility
import { db } from '../db';
import { socialPages } from '../../shared/schema';
import { eq } from 'drizzle-orm';
import { GraphAPIClient } from '../utils/graph_client';
import type { SocialPage } from '../../shared/schema';
import { encrypt, decrypt } from '../utils/encryption';
import { facebookOAuthService, FacebookAuthError } from './facebook_oauth';
/**
* Un jeton dont l'échéance est plus proche que cette fenêtre est renouvelé
* sans attendre : il reste largement le temps de réessayer les jours suivants
* si Facebook est indisponible au moment du contrôle.
*/
const RENEWAL_WINDOW_MS = 15 * 24 * 60 * 60 * 1000;
/** En deçà de cette marge, un jeton non renouvelable est signalé « expiring ». */
const WARNING_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
export interface TokenCheckResult {
pageId: string;
pageName: string;
status: 'valid' | 'expiring' | 'expired' | 'error';
/** Vrai quand ce passage a effectivement réécrit un nouveau jeton. */
renewed: boolean;
error?: string;
}
/**
* Service responsible for managing Facebook Page Token lifecycle
* - Encryption at rest
* - Automated refreshing of expiring tokens
*/
export class TokenManager {
/**
* Encrypts a text using shared encryption utility
*/
static encrypt(text: string): string {
return encrypt(text);
}
/**
* Decrypts a text using shared encryption utility
*/
static decrypt(text: string): string {
return decrypt(text);
}
/**
* Checks all active pages for expiring tokens and refreshes them if needed.
*/
static async checkAndRefreshTokens(): Promise<TokenCheckResult[]> {
console.log('[TokenManager] Starting daily token check...');
const results: TokenCheckResult[] = [];
try {
// Fetch active pages
const pages = await db.query.socialPages.findMany({
where: eq(socialPages.isActive, "true")
});
for (const page of pages) {
try {
results.push(await this.processPageToken(page));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[TokenManager] Error processing page ${page.pageName} (${page.id}):`, error);
await this.persistStatus(page.id, 'error', message);
results.push({
pageId: page.id,
pageName: page.pageName,
status: 'error',
renewed: false,
error: message,
});
}
}
console.log('[TokenManager] Finished token check.');
} catch (e) {
console.error('[TokenManager] Critical error in checkAndRefreshTokens:', e);
}
return results;
}
/** Contrôle (et renouvellement) d'une seule page, déclenché depuis l'UI. */
static async checkPage(pageId: string): Promise<TokenCheckResult> {
const [page] = await db.select().from(socialPages).where(eq(socialPages.id, pageId));
if (!page) {
throw new Error('Page introuvable');
}
return this.processPageToken(page);
}
private static async processPageToken(page: SocialPage): Promise<TokenCheckResult> {
// Les comptes TikTok ont leur propre cycle de vie : l'access token ne dure
// que 24h et se renouvelle avec le refresh token, pas via la Graph API.
if (page.platform === 'tiktok') {
return await this.processTiktokToken(page);
}
console.log(`[TokenManager] Checking page: ${page.pageName}`);
const accessToken = this.readToken(page.accessToken);
// Sans application Facebook configurée, on ne peut ni interroger
// /debug_token ni régénérer quoi que ce soit : on se rabat sur le
// contrôle historique (le jeton répond-il encore ?).
if (!(await facebookOAuthService.isConfigured())) {
return this.legacyCheck(page, accessToken);
}
let info;
try {
info = await facebookOAuthService.inspectToken(accessToken);
} catch (error) {
// Facebook injoignable : on ne dégrade pas le statut sur un incident
// réseau, sinon toute l'UI passerait au rouge le temps d'une panne.
const message = error instanceof Error ? error.message : String(error);
console.warn(`[TokenManager] Inspection impossible pour ${page.pageName}: ${message}`);
return this.legacyCheck(page, accessToken);
}
const expiresAt = info.expiresAt;
const expiringSoon =
expiresAt !== null && expiresAt.getTime() - Date.now() < RENEWAL_WINDOW_MS;
if (info.isValid && !expiringSoon) {
// Le jeton de page tient : on en profite pour prolonger le jeton
// utilisateur s'il approche de son échéance, car c'est lui qui
// permettra le prochain renouvellement.
await this.extendUserTokenIfNeeded(page);
await this.persistStatus(page.id, 'valid', null, { tokenExpiresAt: expiresAt });
return { pageId: page.id, pageName: page.pageName, status: 'valid', renewed: false };
}
const reason = info.isValid
? `Le jeton expire le ${expiresAt?.toLocaleDateString('fr-FR')}`
: info.error || 'Le jeton a été invalidé par Facebook';
console.warn(`[TokenManager] ${page.pageName}: ${reason} — tentative de renouvellement`);
// Renouvellement automatique : c'est tout l'intérêt d'avoir conservé le
// jeton utilisateur longue durée au moment de la connexion OAuth.
try {
const renewed = await this.renewFromUserToken(page);
if (renewed) {
console.log(`[TokenManager] Jeton renouvelé automatiquement pour ${page.pageName}`);
return { pageId: page.id, pageName: page.pageName, status: 'valid', renewed: true };
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[TokenManager] Renouvellement impossible pour ${page.pageName}: ${message}`);
await this.persistStatus(page.id, info.isValid ? 'expiring' : 'expired', message, {
tokenExpiresAt: expiresAt,
});
return {
pageId: page.id,
pageName: page.pageName,
status: info.isValid ? 'expiring' : 'expired',
renewed: false,
error: message,
};
}
// Pas de jeton utilisateur en base : la page a été ajoutée à la main,
// seule une reconnexion peut la remettre d'aplomb.
const manual =
`${reason}. Reconnectez la page via « Connecter des pages Facebook » ` +
'pour activer le renouvellement automatique.';
const status = info.isValid ? 'expiring' : 'expired';
await this.persistStatus(page.id, status, manual, { tokenExpiresAt: expiresAt });
return { pageId: page.id, pageName: page.pageName, status, renewed: false, error: manual };
}
/**
* Régénère le jeton de page à partir du jeton utilisateur longue durée.
* Renvoie false quand la page n'a pas de jeton utilisateur enregistré.
*/
private static async renewFromUserToken(page: SocialPage): Promise<boolean> {
if (!page.userAccessToken) return false;
const storedUserToken = this.readToken(page.userAccessToken);
// On prolonge d'abord le jeton utilisateur : le ré-échanger repart pour
// ~60 jours, ce qui repousse d'autant la prochaine reconnexion manuelle.
// Un échec ici n'est pas bloquant tant que le jeton actuel fonctionne.
let userToken = storedUserToken;
let userTokenExpiresAt = page.userTokenExpiresAt ?? null;
try {
const extended = await facebookOAuthService.extendUserToken(storedUserToken);
userToken = extended.accessToken;
userTokenExpiresAt = extended.expiresAt;
} catch (error) {
console.warn(
`[TokenManager] Prolongation du jeton utilisateur impossible pour ${page.pageName}:`,
error instanceof Error ? error.message : error
);
}
const targets = await facebookOAuthService.listPages(userToken);
// Une page Instagram est publiée avec le jeton de la page Facebook à
// laquelle son compte professionnel est rattaché.
const match = targets.find(
(target) => target.id === page.pageId || target.instagram?.id === page.pageId
);
if (!match) {
throw new FacebookAuthError(
`La page « ${page.pageName} » n'est plus administrée par le compte Facebook connecté. ` +
'Reconnectez-la avec un compte administrateur de cette page.'
);
}
// Un jeton de page dérivé d'un jeton utilisateur longue durée n'expire
// pas : `expiresAt` reste donc généralement nul.
let pageTokenExpiresAt: Date | null = null;
try {
pageTokenExpiresAt = (await facebookOAuthService.inspectToken(match.accessToken)).expiresAt;
} catch {
// L'inspection est un confort : le jeton fraîchement émis est bon.
}
await db.update(socialPages)
.set({
accessToken: match.accessToken,
tokenExpiresAt: pageTokenExpiresAt,
userAccessToken: encrypt(userToken),
userTokenExpiresAt,
followersCount: match.followersCount || page.followersCount,
tokenStatus: 'valid',
tokenError: null,
lastTokenCheck: new Date(),
})
.where(eq(socialPages.id, page.id));
return true;
}
/**
* Prolonge le jeton utilisateur quand son échéance approche, même si le
* jeton de page se porte bien : c'est ce qui évite de perdre la capacité de
* renouveler le jour où le jeton de page est révoqué.
*/
private static async extendUserTokenIfNeeded(page: SocialPage): Promise<void> {
if (!page.userAccessToken) return;
const expiresAt = page.userTokenExpiresAt ? new Date(page.userTokenExpiresAt).getTime() : null;
if (expiresAt !== null && expiresAt - Date.now() > RENEWAL_WINDOW_MS) return;
try {
const extended = await facebookOAuthService.extendUserToken(this.readToken(page.userAccessToken));
await db.update(socialPages)
.set({
userAccessToken: encrypt(extended.accessToken),
userTokenExpiresAt: extended.expiresAt,
})
.where(eq(socialPages.id, page.id));
console.log(`[TokenManager] Jeton utilisateur prolongé pour ${page.pageName}`);
} catch (error) {
console.warn(
`[TokenManager] Impossible de prolonger le jeton utilisateur de ${page.pageName}:`,
error instanceof Error ? error.message : error
);
}
}
/**
* Contrôle historique, utilisé quand l'application Facebook n'est pas
* configurée : on sait seulement si le jeton répond encore.
*/
private static async legacyCheck(page: SocialPage, accessToken: string): Promise<TokenCheckResult> {
const isValid = await this.verifyToken(accessToken);
if (!isValid) {
const error =
"Le jeton n'est plus accepté par Facebook. Configurez l'application Facebook " +
'dans les paramètres pour activer le renouvellement automatique, ou saisissez un nouveau jeton.';
await this.persistStatus(page.id, 'expired', error);
return { pageId: page.id, pageName: page.pageName, status: 'expired', renewed: false, error };
}
// Le jeton répond : reste l'échéance déclarée à l'ajout de la page.
const declared = page.tokenExpiresAt ? new Date(page.tokenExpiresAt).getTime() : null;
const status = declared !== null && declared - Date.now() < WARNING_WINDOW_MS ? 'expiring' : 'valid';
await this.persistStatus(page.id, status, null);
return { pageId: page.id, pageName: page.pageName, status, renewed: false };
}
/**
* Vérifie qu'un compte TikTok est toujours utilisable.
* On ne teste pas l'access token (il est renouvelé à la demande avant chaque
* publication) mais le refresh token, dont l'expiration déconnecterait le compte.
*/
private static async processTiktokToken(page: SocialPage): Promise<TokenCheckResult> {
console.log(`[TokenManager] Checking TikTok account: ${page.pageName}`);
const refreshExpiresAt = page.refreshTokenExpiresAt
? new Date(page.refreshTokenExpiresAt).getTime()
: 0;
let status: "valid" | "expiring" | "expired" = "valid";
let error: string | null = null;
if (!page.refreshToken || (refreshExpiresAt && refreshExpiresAt <= Date.now())) {
status = "expired";
error = "L'autorisation TikTok a expiré : reconnectez le compte.";
console.warn(`[TokenManager] TikTok account ${page.pageName} must be reconnected`);
} else if (refreshExpiresAt && refreshExpiresAt - Date.now() < 30 * 24 * 60 * 60 * 1000) {
// Moins de 30 jours de validité restante
status = "expiring";
}
await this.persistStatus(page.id, status, error);
return { pageId: page.id, pageName: page.pageName, status, renewed: false, error: error ?? undefined };
}
private static async persistStatus(
pageId: string,
status: 'valid' | 'expiring' | 'expired' | 'error',
error: string | null,
extra: { tokenExpiresAt?: Date | null } = {}
): Promise<void> {
await db.update(socialPages)
.set({
tokenStatus: status,
tokenError: error,
lastTokenCheck: new Date(),
...(extra.tokenExpiresAt !== undefined ? { tokenExpiresAt: extra.tokenExpiresAt } : {}),
})
.where(eq(socialPages.id, pageId));
}
/**
* Les jetons ont pu être enregistrés en clair (saisie manuelle) ou chiffrés
* (flux OAuth) : `decrypt` renvoie le texte tel quel s'il n'est pas chiffré.
*/
private static readToken(token: string): string {
try {
return this.decrypt(token);
} catch {
return token;
}
}
private static async verifyToken(token: string): Promise<boolean> {
try {
// Simple validation check: Get 'me' (page profile)
await GraphAPIClient.get('me', { accessToken: token });
return true;
} catch (error) {
console.error('[TokenManager] Token verification failed:', error);
return false;
}
}
}
-14
View File
@@ -1,14 +0,0 @@
import { describe, expect, it } from "vitest";
import { estimateVoiceTiming } from "./ttsSync";
describe("estimateVoiceTiming", () => {
it("ignore hashtags et liens dans le décompte", () => {
expect(estimateVoiceTiming("Venez vite #promo https://x.fr").wordCount).toBe(2);
});
it("prévient quand la voix sera trop longue", () => {
const timing = estimateVoiceTiming("mot ".repeat(200));
expect(timing.isHealthy).toBe(false);
expect(timing.warnings[0]).toMatch(/Texte long/);
});
});
-49
View File
@@ -1,49 +0,0 @@
/**
* Estimation de la durée de lecture d'un texte, affichée pendant la saisie.
*
* Auparavant, chaque pause de frappe générait une voix complète pour la
* mesurer (un appel Gemini facturé à chaque fois). Le minutage réel des mots
* vient désormais de la voix au moment du rendu ; ici, une estimation suffit.
*/
/** Débit moyen d'une voix de synthèse française, en mots par seconde. */
const WORDS_PER_SECOND = 2.6;
/** Au-delà, la voix dépasse la durée confortable d'un Reel. */
const MAX_COMFORTABLE_SECONDS = 45;
export interface SyncTiming {
wordDuration: number;
audioDuration: number;
wordCount: number;
punctuationPause: number;
isHealthy: boolean;
warnings: string[];
}
export function estimateVoiceTiming(text: string): SyncTiming {
const words = text
.replace(/#[\wÀ-ÿ]+/g, '')
.replace(/https?:\/\/\S+/g, '')
.split(/\s+/)
.filter((w) => /[A-Za-z0-9À-ÿ]/.test(w));
const pauses = (text.match(/[.!?;:]/g) ?? []).length;
const punctuationPause = 0.35;
const audioDuration = words.length / WORDS_PER_SECOND + pauses * punctuationPause;
const warnings: string[] = [];
if (audioDuration > MAX_COMFORTABLE_SECONDS) {
warnings.push(`Texte long : environ ${Math.round(audioDuration)} s de voix. Visez moins de ${MAX_COMFORTABLE_SECONDS} s.`);
}
if (words.length > 0 && words.length < 4) {
warnings.push('Texte très court : la voix ne durera que quelques secondes.');
}
return {
wordDuration: words.length ? audioDuration / words.length : 0,
audioDuration,
wordCount: words.length,
punctuationPause,
isHealthy: warnings.length === 0,
warnings,
};
}
-11
View File
@@ -1,11 +0,0 @@
declare module 'twemoji-parser' {
export interface EmojiEntity {
text: string;
url: string;
indices: [number, number];
type: string;
}
export function parse(text: string, options?: { buildUrl?: (codePoint: string, assetType: string) => string }): EmojiEntity[];
export function toCodePoints(unicodeSurrogates: string): string[];
}