This commit is contained in:
Michael committed 2026-03-04 14:25:06 +01:00
commit f6376cb470
917 files changed
+28420 -93723

No files matched your search

+8 -4
View File
@@ -39,19 +39,19 @@ app.use(helmet({
crossOriginEmbedderPolicy: false,
}));
// Rate limiting global - 100 requêtes par 15 minutes par IP
// Rate limiting global - 1000 requêtes par 15 minutes par IP
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
max: 1000,
message: { error: 'Trop de requêtes, réessayez plus tard' },
standardHeaders: true,
legacyHeaders: false,
});
// Rate limiting strict pour l'authentification - 5 tentatives par 15 minutes
// Rate limiting pour l'authentification - 30 tentatives par 15 minutes
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
max: 30,
message: { error: 'Trop de tentatives de connexion, réessayez dans 15 minutes' },
standardHeaders: true,
legacyHeaders: false,
@@ -60,6 +60,10 @@ const authLimiter = rateLimit({
app.use('/api/', globalLimiter);
app.use('/api/auth/login', authLimiter);
// Serve locally stored audio files
const AUDIO_UPLOAD_DIR = process.env.AUDIO_UPLOAD_DIR || '/app/uploads/audio';
app.use('/uploads/audio', express.static(AUDIO_UPLOAD_DIR));
declare module 'http' {
interface IncomingMessage {
rawBody: unknown
+13
View File
@@ -117,6 +117,19 @@ export async function migrate() {
ADD COLUMN IF NOT EXISTS "page_engagement" integer DEFAULT 0;
`);
// audio_tracks (bibliothèque audio interne)
await client.query(`
CREATE TABLE IF NOT EXISTS "audio_tracks" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" varchar REFERENCES "users"("id") ON DELETE cascade,
"title" text NOT NULL,
"file_name" text NOT NULL,
"url" text NOT NULL,
"duration" integer DEFAULT 0,
"created_at" timestamp DEFAULT now()
);
`);
console.log("[Migration] Safe migration completed.");
} catch (error) {
console.error("[Migration] Error during migration:", error);
+172 -3
View File
@@ -3,7 +3,7 @@ import { createServer, type Server } from "http";
import fs from "fs";
import os from "os";
import { storage } from "./storage";
import { db } from "./db";
import { db, pool } from "./db";
import multer from "multer";
import bcrypt from "bcrypt";
import passport from "./auth";
@@ -15,6 +15,7 @@ import type { User, InsertUser, ScheduledPost, FreesoundConfig } from "@shared/s
import { freeSoundService } from "./services/freesound";
import { analyticsRouter } from "./routes/analytics";
import { reelsRouter } from "./routes/reels";
import { insertAudioTrackSchema } from "@shared/schema";
// Types MIME autorisés pour les uploads
const ALLOWED_MIME_TYPES = [
@@ -22,6 +23,11 @@ const ALLOWED_MIME_TYPES = [
'video/mp4', 'video/quicktime', 'video/webm'
];
// Types MIME autorisés pour l'audio
const ALLOWED_AUDIO_MIME_TYPES = [
'audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg'
];
// Configuration multer avec validation de taille et type
// Configuration multer avec validation de taille (4GB) et stockage disque temporaire
const upload = multer({
@@ -49,6 +55,36 @@ const upload = multer({
}
});
// Setup audio upload with different filters
// Files saved directly to the persistent local folder
const AUDIO_UPLOAD_DIR = process.env.AUDIO_UPLOAD_DIR || '/app/uploads/audio';
// Ensure the directory exists at startup
import { mkdirSync } from 'fs';
try { mkdirSync(AUDIO_UPLOAD_DIR, { recursive: true }); } catch { /* already exists */ }
const audioUpload = multer({
storage: multer.diskStorage({
destination: AUDIO_UPLOAD_DIR,
filename: (req, file, cb) => {
const safeName = file.originalname.replace(/[^a-zA-Z0-9.]/g, '_');
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, uniqueSuffix + '-' + safeName);
}
}),
limits: {
fileSize: 50 * 1024 * 1024, // 50MB max for audio
files: 20
},
fileFilter: (req, file, cb) => {
if (ALLOWED_AUDIO_MIME_TYPES.includes(file.mimetype) || file.originalname.match(/\.(mp3|wav|ogg)$/i)) {
cb(null, true);
} else {
cb(new Error(`Type de fichier audio non autorisé: ${file.mimetype}`));
}
}
});
// Middleware pour vérifier l'authentification
function requireAuth(req: Request, res: Response, next: NextFunction) {
if (!req.isAuthenticated()) {
@@ -948,6 +984,138 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// Audio Tracks Management (Admin Only) - Multi-file upload (stored locally)
app.post("/api/audio-tracks", requireAdmin, audioUpload.array("files", 20), async (req, res) => {
try {
const files = req.files as Express.Multer.File[];
if (!files || files.length === 0) {
return res.status(400).json({ error: "No audio file uploaded" });
}
const user = req.user as User;
const results = [];
for (const file of files) {
try {
console.log(`🎵 Audio saved locally: ${file.originalname} (${(file.size / 1024 / 1024).toFixed(2)} MB)`);
// Multer/busboy reçoit le nom en latin1, mais le navigateur l'envoie en UTF-8 → on reconvertit
const decodedName = Buffer.from(file.originalname, 'latin1').toString('utf8');
const publicUrl = `/uploads/audio/${file.filename}`;
const track = await storage.createAudioTrack({
userId: user.id,
title: decodedName.replace(/\.[^/.]+$/, ""),
fileName: decodedName,
url: publicUrl,
duration: 0,
});
results.push({ success: true, track });
} catch (fileError) {
// Remove the file if DB insert fails
await fs.promises.unlink(file.path).catch(() => { });
results.push({ success: false, fileName: file.originalname, error: fileError instanceof Error ? fileError.message : "Save failed" });
}
}
res.json({ results });
} catch (error) {
console.error("Error uploading audio tracks:", error);
if (req.files) {
for (const file of req.files as Express.Multer.File[]) {
await fs.promises.unlink(file.path).catch(() => { });
}
}
const errorMessage = error instanceof Error ? error.message : "Failed to upload audio tracks";
res.status(500).json({ error: errorMessage });
}
});
// Route admin : correction des titres corrompus (latin1 lu comme UTF-8)
// Se déclenche aussi automatiquement au démarrage
const fixAudioTrackEncoding = async () => {
try {
const tracks = await storage.getAudioTracks();
let fixed = 0;
for (const track of tracks) {
// Détecte les séquences typiques latin1/utf8 mal interprétées (ex: é, à , è)
if (/[\xC0-\xC3][\x80-\xBF]/.test(track.title)) {
const corrected = Buffer.from(track.title, 'latin1').toString('utf8');
const correctedFileName = Buffer.from(track.fileName, 'latin1').toString('utf8');
await pool.query(
`UPDATE audio_tracks SET title = $1, file_name = $2 WHERE id = $3`,
[corrected, correctedFileName, track.id]
);
fixed++;
}
}
if (fixed > 0) console.log(`🎵 Fixed encoding for ${fixed} audio track(s).`);
} catch (e) {
console.error('Error fixing audio track encoding:', e);
}
};
// Lancer la correction au démarrage
fixAudioTrackEncoding().catch(() => { });
app.post("/api/audio-tracks/fix-encoding", requireAdmin, async (req, res) => {
try {
await fixAudioTrackEncoding();
const tracks = await storage.getAudioTracks();
res.json({ success: true, tracks });
} catch (error) {
res.status(500).json({ error: "Failed to fix encoding" });
}
});
app.get("/api/audio-tracks", requireAuth, async (req, res) => {
try {
// Both admin and regular users can list audio tracks (to pick them for reels)
const tracks = await storage.getAudioTracks();
res.json(tracks);
} catch (error) {
console.error("Error fetching audio tracks:", error);
res.status(500).json({ error: "Failed to fetch audio tracks" });
}
});
app.delete("/api/audio-tracks/:id", requireAdmin, async (req, res) => {
try {
const { id } = req.params;
const track = await storage.getAudioTrack(id);
if (!track) {
return res.status(404).json({ error: "Audio track not found" });
}
// Try to extract publicId from cloudinary url to delete it from there too
// e.g. https://res.cloudinary.com/demo/video/upload/v123456789/user_id/audio_name.mp3
const urlParts = track.url.split('/upload/');
if (urlParts.length > 1) {
const afterUpload = urlParts[1];
// Remove version number (v12345...)
let pathWithoutVersion = afterUpload;
if (afterUpload.match(/^v\d+\//)) {
pathWithoutVersion = afterUpload.replace(/^v\d+\//, '');
}
// Remove extension
const publicId = pathWithoutVersion.replace(/\.[^/.]+$/, "");
try {
await cloudinaryService.deleteMedia(publicId, track.userId || '', 'video');
} catch (cloudinaryError) {
console.warn(`Failed to delete audio from cloudinary: ${publicId}`, cloudinaryError);
}
}
await storage.deleteAudioTrack(id);
res.json({ success: true });
} catch (error) {
console.error("Error deleting audio track:", error);
res.status(500).json({ error: "Failed to delete audio track" });
}
});
// Posts
app.get("/api/posts", requireAuth, async (req, res) => {
try {
@@ -1391,11 +1559,12 @@ export async function registerRoutes(app: Express): Promise<Server> {
return res.status(404).json({ error: "Page non trouvée" });
}
let pageData = insertSocialPageSchema.partial().parse(req.body);
let parsedData = insertSocialPageSchema.partial().parse(req.body);
let pageData: Partial<SocialPage> = { ...parsedData };
// If accessToken is being updated, recalculate expiration date (60 days from now)
// AND reset the status to valid so the UI updates immediately
if (pageData.accessToken) {
if (parsedData.accessToken) {
const tokenExpiresAt = new Date();
tokenExpiresAt.setDate(tokenExpiresAt.getDate() + 60);
pageData = {
+185 -20
View File
@@ -10,6 +10,9 @@ import { ffmpegService } from '../services/ffmpeg';
import { facebookService } from '../services/facebook';
import { cloudinaryService } from '../services/cloudinary';
import { openRouterService } from '../services/openrouter';
import { db } from '../db';
import { cloudinaryConfig } from '@shared/schema';
import { eq } from 'drizzle-orm';
export const reelsRouter = Router();
@@ -314,9 +317,33 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => {
// Récupérer l'URL de la musique si trackId fourni
let finalMusicUrl = musicUrl;
if (musicTrackId && !musicUrl) {
const track = await freeSoundService.getMusicDetails(musicTrackId);
if (track) {
finalMusicUrl = track.downloadUrl;
if (musicTrackId.startsWith('internal_')) {
const internalId = musicTrackId.replace('internal_', '');
const track = await storage.getAudioTrack(internalId);
if (track) {
// Utiliser INTERNAL_APP_URL pour que le container ffmpeg (réseau Docker interne)
// puisse télécharger le fichier audio. APP_URL (HTTPS public) n'est pas accessible
// depuis le réseau internal Docker.
const internalBaseUrl = process.env.INTERNAL_APP_URL || process.env.APP_URL || 'http://localhost:5555';
finalMusicUrl = track.url.startsWith('http')
? track.url
: `${internalBaseUrl}${track.url}`;
console.log(`🎵 Audio URL for ffmpeg: ${finalMusicUrl}`);
}
} else {
const track = await freeSoundService.getMusicDetails(musicTrackId);
if (track) {
finalMusicUrl = track.downloadUrl;
}
}
}
let watermarkUrl: string | undefined = undefined;
if (req.user) {
const user = req.user as User;
const [userCloudinary] = await db.select().from(cloudinaryConfig).where(eq(cloudinaryConfig.userId, user.id));
if (userCloudinary && userCloudinary.logoPublicId) {
watermarkUrl = `https://res.cloudinary.com/${userCloudinary.cloudName}/image/upload/${userCloudinary.logoPublicId}`;
}
}
@@ -331,6 +358,7 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => {
musicVolume,
drawText,
stabilize,
watermarkUrl,
});
if (!result.success) {
@@ -374,6 +402,52 @@ reelsRouter.post('/reels/tts-preview', async (req: Request, res: Response) => {
}
});
/**
* Traitement d'arrière-plan pour les Reels
* Gère le pipeline FFmpeg -> Cloudinary -> Facebook de manière asynchrone
*/
/**
* Vérifie la file d'attente et lance le prochain job si disponible
*/
async function checkQueueAndProcessNext() {
try {
const nextPost = await storage.getNextPendingReel();
if (nextPost) {
console.log(`📥 [Queue] Found pending job: ${nextPost.id}. Starting processing...`);
// Marquer comme processing immédiatement
await storage.updatePostGenerationStatus(nextPost.id, 'processing', 0);
// Reconstruire le payload depuis les données stockées (si stockées) ou les defaults
// Note: En mode "pending", on a perdu le body de la requête initiale car on ne stocke pas tout dans Post.
// Pour une solution robuste, il faudrait stocker les paramètres de génération dans une table 'reel_jobs'.
// ICI: HACK PROVISOIRE -> On suppose que les données sont stockées dans 'content' ou 'productInfo' mais ce n'est pas le cas.
// SOLUTION: On ne peut pas relancer processReelBackground sans les arguments (ttsVoice, music, etc).
//
// FIX: Pour le MVP, comme on n'a pas de table 'jobs', on va devoir stocker les paramètres requis dans 'productInfo' (jsonb) du Post
// lors de la création en mode 'pending'.
// Récupérer les paramètres stockés
const jobData = nextPost.productInfo as any;
if (!jobData || !jobData.videoMediaId) {
console.error(`❌ [Queue] Job ${nextPost.id} has no stored job data in productInfo.`);
await storage.updatePostGenerationStatus(nextPost.id, 'failed', 0, "Données de job manquantes");
return;
}
// Lancer le traitement
processReelBackground(nextPost.userId, nextPost.id, jobData)
.catch(err => console.error('🔥 [Queue] Unhandled error starting queued job:', err));
} else {
console.log('🏁 [Queue] No more pending jobs.');
}
} catch (error) {
console.error('❌ [Queue] Error checking queue:', error);
}
}
/**
* Traitement d'arrière-plan pour les Reels
* Gère le pipeline FFmpeg -> Cloudinary -> Facebook de manière asynchrone
@@ -396,7 +470,8 @@ async function processReelBackground(
musicVolume?: number;
drawText?: boolean;
stabilize?: boolean;
}
},
storeName?: string
) {
const {
videoMediaId,
@@ -437,9 +512,27 @@ async function processReelBackground(
// 2. Récupérer l'URL de la musique
let finalMusicUrl = musicUrl;
if (musicTrackId && !musicUrl) {
const track = await freeSoundService.getMusicDetails(musicTrackId);
if (track) {
finalMusicUrl = track.downloadUrl;
if (musicTrackId.startsWith('internal_')) {
try {
const internalId = musicTrackId.replace('internal_', '');
const track = await storage.getAudioTrack(internalId);
if (track) {
const internalBaseUrl = process.env.INTERNAL_APP_URL || process.env.APP_URL || 'http://localhost:5555';
finalMusicUrl = track.url.startsWith('http')
? track.url
: `${internalBaseUrl}${track.url}`;
console.log(`🎵 [Background] Audio URL for ffmpeg: ${finalMusicUrl}`);
}
} catch (e) { console.error('Error fetching internal track', e); }
} else {
try {
const track = await freeSoundService.getMusicDetails(musicTrackId);
if (track) {
finalMusicUrl = track.downloadUrl;
}
} catch (e) {
console.error(`⚠️ [Background] Failed to fetch music details for ${musicTrackId}`, e);
}
}
}
@@ -453,6 +546,17 @@ async function processReelBackground(
// 3. Traiter la vidéo via FFmpeg
await updateProgress(15);
const startTime = Date.now();
let watermarkUrl: string | undefined = undefined;
try {
const [userCloudinary] = await db.select().from(cloudinaryConfig).where(eq(cloudinaryConfig.userId, media.userId));
if (userCloudinary && userCloudinary.logoPublicId) {
watermarkUrl = `https://res.cloudinary.com/${userCloudinary.cloudName}/image/upload/${userCloudinary.logoPublicId}`;
}
} catch (e) {
console.error('Error fetching watermark configuration', e);
}
const ffmpegResult = await ffmpegService.processReelFromUrl(media.originalUrl, {
text: overlayText,
musicUrl: finalMusicUrl,
@@ -463,6 +567,8 @@ async function processReelBackground(
musicVolume,
drawText,
stabilize,
watermarkUrl,
storeName,
});
console.log(`⏱️ [Background] FFmpeg took ${(Date.now() - startTime) / 1000}s`);
@@ -564,7 +670,7 @@ async function processReelBackground(
// Si au moins une réussite, on considère "published" (ou partial), sinon "failed"
// Si planifié, reste "scheduled".
let finalStatus = 'failed';
let finalStatus: 'failed' | 'scheduled' | 'published' = 'failed';
if (scheduledFor) {
finalStatus = 'scheduled';
} else if (allSuccess) {
@@ -593,11 +699,14 @@ async function processReelBackground(
} catch (e) {
console.error(`⚠️ [Background] Failed to update error status for ${postId}:`, e);
}
} finally {
// IMPORTANT: Toujours vérifier la file d'attente à la fin (succès ou échec)
await checkQueueAndProcessNext();
}
}
/**
* Créer et publier un Reel (Asynchrone)
* Créer et publier un Reel (Asynchrone avec File d'Attente)
* POST /api/reels
*/
reelsRouter.post('/reels', async (req: Request, res: Response) => {
@@ -629,32 +738,58 @@ reelsRouter.post('/reels', async (req: Request, res: Response) => {
return res.status(400).json({ error: 'Au moins une page requise' });
}
// Créer immédiatement le Post en base (Statut Draft/Scheduled)
// Cela permet de retourner un ID tout de suite et d'éviter le timeout
// Vérifier le nombre de jobs en cours
const processingCount = await storage.countProcessingReels();
// MAX_CONCURRENT = 1
const isQueueBusy = processingCount >= 1;
const initialStatus = isQueueBusy ? 'pending' : 'processing';
const initialMessage = isQueueBusy
? "File d'attente pleine. Votre vidéo sera traitée dès que possible."
: "Traitement démarré en arrière-plan.";
// Récupérer le nom de la première page pour l'utiliser comme storeName
let storeName: string | undefined = undefined;
try {
const page = await storage.getSocialPage(pageIds[0]);
if (page) {
storeName = page.pageName;
}
} catch (e) {
console.error('Erreur récupération nom de page', e);
}
// Créer immédiatement le Post en base
// ON STOCKE LES PARAMS DU JOB DANS productInfo POUR POUVOIR LE REPRENDRE PLUS TARD
// C'est un hack car on n'a pas de table params_job, mais ça marche car productInfo est jsonb
const post = await storage.createPost({
userId: user.id,
content: description || overlayText || '',
aiGenerated: 'false',
status: scheduledFor ? 'scheduled' : 'draft',
scheduledFor: scheduledFor ? new Date(scheduledFor) : undefined,
generationStatus: 'processing',
generationStatus: initialStatus,
generationProgress: 0,
productInfo: req.body, // Stockage complet des paramètres
});
console.log(`✨ Reel Request accepted. Post ID: ${post.id}. Starting background processing.`);
console.log(`✨ Reel Request accepted. Post ID: ${post.id}. Status: ${initialStatus}`);
// Démarrer le traitement en arrière-plan (Fire & Forget)
// On ne met pas 'await' ici pour ne pas bloquer la réponse HTTP
processReelBackground(user.id, post.id, req.body).catch(err => {
console.error('🔥 Unhandled background error:', err);
});
if (!isQueueBusy) {
// Démarrer le traitement en arrière-plan (Fire & Forget)
processReelBackground(user.id, post.id, req.body, storeName).catch(err => {
console.error('🔥 Unhandled background error:', err);
});
} else {
console.log(`⏳ [Queue] Worker busy (count=${processingCount}). Job ${post.id} is queued.`);
}
// Réponse immédiate au client
res.json({
success: true,
postId: post.id,
message: "Traitement démarré en arrière-plan. La publication apparaîtra bientôt.",
// On retourne des valeurs placeholder pour la compatibilité frontend
message: initialMessage,
queued: isQueueBusy,
results: [],
videoUrl: ""
});
@@ -704,3 +839,33 @@ reelsRouter.get('/reels/ongoing', async (req: Request, res: Response) => {
res.status(500).json({ error: 'Erreur lors de la récupération des reels en cours' });
}
});
/**
* Supprimer un Reel
* DELETE /api/reels/:id
*/
reelsRouter.delete('/reels/:id', async (req: Request, res: Response) => {
try {
const user = req.user as User;
const { id } = req.params;
// Vérifier si le post existe
const post = await storage.getPost(id);
if (!post) {
return res.status(404).json({ error: 'Reel non trouvé' });
}
// Vérifier les permissions (admin ou propriétaire)
if (user.role !== 'admin' && post.userId !== user.id) {
return res.status(403).json({ error: 'Non autorisé à supprimer ce Reel' });
}
// Supprimer le post (cascade supprimera scheduled_posts et liens media)
await storage.deletePost(id);
res.json({ success: true, message: 'Reel supprimé avec succès' });
} catch (error) {
console.error('❌ Error deleting Reel:', error);
res.status(500).json({ error: 'Erreur lors de la suppression du Reel' });
}
});
+10
View File
@@ -18,6 +18,8 @@ interface FFmpegReelRequest {
music_volume?: number; // Volume musique (default: 0.25)
draw_text?: boolean; // Dessiner le texte sur la vidéo (default: true)
stabilize?: boolean; // Stabilisation vidéo via vidstab (default: false)
watermark_url?: string; // URL du logo
store_name?: string; // Nom du magasin pour l'outro
}
interface FFmpegReelResponse {
@@ -73,6 +75,8 @@ export class FFmpegService {
musicVolume?: number;
drawText?: boolean;
stabilize?: boolean;
watermarkUrl?: string;
storeName?: string;
} = {}
): Promise<{ success: boolean; videoBase64?: string; duration?: number; error?: string }> {
const config = this.ensureConfigured();
@@ -89,6 +93,8 @@ export class FFmpegService {
music_volume: options.musicVolume ?? 0.25,
draw_text: options.drawText ?? true,
stabilize: options.stabilize ?? false,
watermark_url: options.watermarkUrl,
store_name: options.storeName,
};
// Remove undefined values
@@ -168,6 +174,8 @@ export class FFmpegService {
musicVolume?: number;
drawText?: boolean;
stabilize?: boolean;
watermarkUrl?: string;
storeName?: string;
} = {}
): Promise<{ success: boolean; videoBase64?: string; duration?: number; error?: string }> {
const config = this.ensureConfigured();
@@ -184,6 +192,8 @@ export class FFmpegService {
music_volume: options.musicVolume ?? 0.25,
draw_text: options.drawText ?? true,
stabilize: options.stabilize ?? false,
watermark_url: options.watermarkUrl,
store_name: options.storeName,
};
// Remove undefined values
+50 -2
View File
@@ -34,6 +34,9 @@ import {
freesoundConfig,
type FreesoundConfig,
type InsertFreesoundConfig,
audioTracks,
type AudioTrack,
type InsertAudioTrack,
} from "@shared/schema";
import { db } from "./db";
import { eq, and, gte, lte, desc, asc, isNull, inArray } from "drizzle-orm";
@@ -52,7 +55,7 @@ export interface IStorage {
getSocialPages(userId: string): Promise<SocialPage[]>;
getSocialPage(id: string): Promise<SocialPage | undefined>;
createSocialPage(page: InsertSocialPage): Promise<SocialPage>;
updateSocialPage(id: string, page: Partial<InsertSocialPage>): Promise<SocialPage>;
updateSocialPage(id: string, page: Partial<SocialPage>): Promise<SocialPage>;
deleteSocialPage(id: string): Promise<void>;
// Media
@@ -71,6 +74,8 @@ export interface IStorage {
updatePostGenerationStatus(id: string, status: string, progress: number, error?: string): Promise<Post>;
deletePost(id: string): Promise<void>;
updatePostMedia(postId: string, mediaIds: string[]): Promise<void>;
countProcessingReels(): Promise<number>;
getNextPendingReel(): Promise<Post | undefined>;
// Scheduled Posts
getScheduledPosts(userId: string, startDate?: Date, endDate?: Date): Promise<ScheduledPost[]>;
@@ -111,6 +116,12 @@ export interface IStorage {
removeMusicFavorite(userId: string, trackId: string): Promise<void>;
isMusicFavorite(userId: string, trackId: string): Promise<boolean>;
// Audio Tracks
getAudioTracks(): Promise<AudioTrack[]>;
getAudioTrack(id: string): Promise<AudioTrack | undefined>;
createAudioTrack(track: InsertAudioTrack): Promise<AudioTrack>;
deleteAudioTrack(id: string): Promise<void>;
// Freesound Config
getFreesoundConfig(userId: string): Promise<FreesoundConfig | undefined>;
createFreesoundConfig(config: InsertFreesoundConfig): Promise<FreesoundConfig>;
@@ -178,7 +189,7 @@ export class DatabaseStorage implements IStorage {
return newPage;
}
async updateSocialPage(id: string, page: Partial<InsertSocialPage>): Promise<SocialPage> {
async updateSocialPage(id: string, page: Partial<SocialPage>): Promise<SocialPage> {
// Chiffrer le token si présent dans la mise à jour
const updateData = page.accessToken
? { ...page, accessToken: encrypt(page.accessToken) }
@@ -292,6 +303,24 @@ export class DatabaseStorage implements IStorage {
}
}
async countProcessingReels(): Promise<number> {
const processing = await db
.select()
.from(posts)
.where(eq(posts.generationStatus, "processing"));
return processing.length;
}
async getNextPendingReel(): Promise<Post | undefined> {
const [post] = await db
.select()
.from(posts)
.where(eq(posts.generationStatus, "pending"))
.orderBy(asc(posts.createdAt))
.limit(1);
return post || undefined;
}
// Scheduled Posts
async getScheduledPosts(userId: string, startDate?: Date, endDate?: Date): Promise<any[]> {
let query = db
@@ -518,6 +547,25 @@ export class DatabaseStorage implements IStorage {
return !!fav;
}
// Audio Tracks
async getAudioTracks(): Promise<AudioTrack[]> {
return db.select().from(audioTracks).orderBy(desc(audioTracks.createdAt));
}
async getAudioTrack(id: string): Promise<AudioTrack | undefined> {
const [track] = await db.select().from(audioTracks).where(eq(audioTracks.id, id));
return track || undefined;
}
async createAudioTrack(track: InsertAudioTrack): Promise<AudioTrack> {
const [newTrack] = await db.insert(audioTracks).values(track).returning();
return newTrack;
}
async deleteAudioTrack(id: string): Promise<void> {
await db.delete(audioTracks).where(eq(audioTracks.id, id));
}
// Freesound Config
async getFreesoundConfig(userId: string): Promise<FreesoundConfig | undefined> {
const [config] = await db.select().from(freesoundConfig).where(eq(freesoundConfig.userId, userId));