mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-23 06:58:12 +02:00
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:
54 files changed
+2
-13174
No files matched your search
@@ -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.`);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}` : ""}`;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
@@ -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 */ });
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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 */ });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Vendored
-11
@@ -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[];
|
||||
}
|
||||
Reference in new issue
Block a user