diff --git a/README.md b/README.md new file mode 100644 index 0000000..e796e14 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# Timelapse Backend + +Ce projet est une API de backend pour gérer une solution de timelapse avec capture, stockage et transformation d'images en vidéos. + +## Architecture + +L'application a été refactorisée pour suivre un modèle MVC (Modèle-Vue-Contrôleur) et respecter le principe de séparation des préoccupations. Voici la structure du projet : + +``` +/src + /config - Configuration centralisée de l'application + /controllers - Logique de traitement des requêtes HTTP + /database - Connexion à la base de données + /data - Compatibilité avec l'ancienne version (à terme, à supprimer) + /middlewares - Middlewares Express + /models - Modèles de données et logique d'accès à la BDD + /routes - Définition des routes de l'API + /services - Services métier et logique complexe + /utils - Utilitaires partagés + /video - Compatibilité avec l'ancienne version (à terme, à supprimer) +``` + +### Points d'entrée + +- `server.js` - Point d'entrée principal du serveur Express +- `api.js` - Routes principales de l'API + +## Composants principaux + +### Modèles + +Les modèles encapsulent la logique d'accès aux données et les règles métier : + +- `Project.js` - Gestion des projets de timelapse +- `Measurement.js` - Gestion des mesures et images +- `Video.js` - Gestion des vidéos générées +- `Camera.js` - Gestion de la caméra et des paramètres de capture + +### Contrôleurs + +Les contrôleurs gèrent le traitement des requêtes HTTP et interagissent avec les modèles et services : + +- `projectController.js` - Gestion des projets +- `measurementController.js` - Gestion des mesures +- `videoController.js` - Gestion des vidéos +- `imageController.js` - Gestion des images et téléchargements +- `cameraController.js` - Gestion des paramètres de la caméra + +### Services + +Les services implémentent la logique métier complexe : + +- `storageService.js` - Gestion du stockage des fichiers +- `videoService.js` - Service de création de vidéos à partir d'images + +### Routes + +Les routes définissent les points d'accès HTTP de l'API : + +- `projectRoutes.js` - Routes pour les projets +- `measurementRoutes.js` - Routes pour les mesures +- `videoRoutes.js` - Routes pour les vidéos +- `imageRoutes.js` - Routes pour les images +- `cameraRoutes.js` - Routes pour la caméra + +## Déploiement + +Le déploiement est géré via Docker Compose, avec une configuration dans `docker-compose.yml`. Le script `deploy.sh` gère le déploiement automatisé. + +## Phase de Transition + +L'application est actuellement en phase de transition de l'ancienne architecture vers la nouvelle. Les fichiers suivants sont des ponts de compatibilité qui seront progressivement supprimés : + +- `src/database/database_manager.js` - Redirige vers les nouveaux modèles +- `src/data/storage_manager.js` - Redirige vers le nouveau service de stockage +- `src/video/videoManager.js` - Redirige vers le nouveau service vidéo + +Pour supprimer les fichiers obsolètes une fois toutes les références mises à jour, utilisez : + +```bash +node cleanup.js delete +``` + +## Documentation API + +La documentation de l'API est disponible via Swagger à l'adresse `/api-docs`. \ No newline at end of file diff --git a/api.js b/api.js index 618e18b..bd578ea 100644 --- a/api.js +++ b/api.js @@ -1,27 +1,10 @@ const express = require('express'); const router = express.Router(); -const cors = require('cors'); -const projectRoutes = require('./routes/projectRoutes'); -const measurementRoutes = require('./routes/measurementRoutes'); -const videoRoutes = require('./routes/videoRoutes'); -const imageRoutes = require('./routes/imageRoutes'); -const uploadRoutes = require('./routes/uploadRoutes'); -const FileWatcher = require('./src/data/filewatcher'); -const database_manager = require('./src/database/database_manager'); -const capture_system = require('./routes/capture_system'); -router.use(cors({ - origin: ['http://127.0.0.1:5500', 'http://localhost:5500', 'http://localhost:3000'], - methods: ['GET', 'POST', 'PUT', 'DELETE'], - allowedHeaders: ['Content-Type'], - credentials: true, -})); +// Importe toutes les routes depuis notre nouvelle structure +const apiRoutes = require('./src/routes'); -router.use('/', projectRoutes); -router.use('/', measurementRoutes); -router.use('/', videoRoutes); -router.use('/', imageRoutes); -router.use('/', uploadRoutes); -router.use('/', capture_system); +// Utilise directement toutes les routes définies dans src/routes/index.js +router.use('/', apiRoutes); module.exports = router; diff --git a/backend.config.js b/backend.config.js index c856c16..14703ec 100644 --- a/backend.config.js +++ b/backend.config.js @@ -1,10 +1,9 @@ -module.exports = { - apps: [{ - name: "backend", - script: "server.js", - out_file: "/dev/stdout", - error_file: "/dev/stderr", - log_date_format: "YYYY-MM-DD HH:mm:ss", - combine_logs: true, // Combine les logs stdout et stderr - }] -}; +/** + * Ce fichier de configuration est maintenu pour compatibilité + * mais redirige vers notre nouvelle architecture centralisée. + * À terme, toutes les références à ce fichier devraient être remplacées + * par des importations directes de src/config/index.js + */ + +const config = require('./src/config'); +module.exports = config; diff --git a/cleanup.js b/cleanup.js new file mode 100644 index 0000000..2208236 --- /dev/null +++ b/cleanup.js @@ -0,0 +1,77 @@ +/** + * Script de nettoyage pour supprimer les fichiers obsolètes après refactoring + * + * Usage: + * - Pour lister les fichiers obsolètes sans les supprimer : node cleanup.js list + * - Pour supprimer les fichiers obsolètes : node cleanup.js delete + */ + +const fs = require('fs'); +const path = require('path'); + +// Liste des fichiers à considérer comme obsolètes +const deprecatedFiles = [ + // Anciens fichiers de routes qui ont été remplacés par src/routes/* + 'routes/uploadRoutes.js', + 'routes/projectRoutes.js', + 'routes/measurementRoutes.js', + 'routes/videoRoutes.js', + 'routes/capture_system.js', + 'routes/imageRoutes.js', + + // Utilitaires remplacés + 'utils/serverError.js', + + // Fichiers de backend qui ont été refactorisés + 'ffmpeg.js', +]; + +// Fonction pour lister les fichiers obsolètes +function listDeprecatedFiles() { + console.log('====== Fichiers obsolètes ======'); + deprecatedFiles.forEach(file => { + const filePath = path.join(__dirname, file); + if (fs.existsSync(filePath)) { + console.log(`✓ ${file} (existe)`); + } else { + console.log(`✗ ${file} (déjà supprimé)`); + } + }); + console.log('=============================='); +} + +// Fonction pour supprimer les fichiers obsolètes +function deleteDeprecatedFiles() { + console.log('====== Suppression des fichiers obsolètes ======'); + deprecatedFiles.forEach(file => { + const filePath = path.join(__dirname, file); + if (fs.existsSync(filePath)) { + try { + fs.unlinkSync(filePath); + console.log(`✅ ${file} supprimé avec succès`); + } catch (error) { + console.error(`❌ Erreur lors de la suppression de ${file}:`, error.message); + } + } else { + console.log(`⚠️ ${file} n'existe pas ou a déjà été supprimé`); + } + }); + console.log('=============================================='); +} + +// Traitement des arguments +const action = process.argv[2]; + +if (action === 'list') { + listDeprecatedFiles(); +} else if (action === 'delete') { + listDeprecatedFiles(); + console.log('\nConfirmation de suppression...'); + deleteDeprecatedFiles(); +} else { + console.log(` +Usage: + - Pour lister les fichiers obsolètes : node cleanup.js list + - Pour supprimer les fichiers obsolètes : node cleanup.js delete + `); +} \ No newline at end of file diff --git a/db.js b/db.js index a0992f5..663b124 100644 --- a/db.js +++ b/db.js @@ -1,35 +1,9 @@ -const { Client } = require('pg'); +/** + * Ce fichier est maintenu pour des raisons de compatibilité + * mais redirige vers la nouvelle structure de connexion à la base de données. + * À terme, toutes les références devraient utiliser src/database/connection.js + */ -const client = new Client({ - host: 'timelapse-db', - port: 5432, - user: 'postgres', - password: 'postgres', - database: 'timelapse' -}); +const db = require('./src/database/connection'); -let isConnecting = false; - -function init_database() { - if (isConnecting) { - console.log('[DB] Connection attempt already in progress, skipping...'); - return; - } - - console.log('[DB] Initialisation de la base de données PostgreSQL...'); - isConnecting = true; - - client.connect(err => { - isConnecting = false; - - if (err) { - console.error('[DB] Erreur de connexion à la base de données:', err); - setTimeout(init_database, 3000); - } else { - console.log('[DB] Connecté à la base de données PostgreSQL.'); - } - }); -} - -init_database(); -module.exports = client; \ No newline at end of file +module.exports = db; \ No newline at end of file diff --git a/server.js b/server.js index 5987951..d7aa940 100644 --- a/server.js +++ b/server.js @@ -1,47 +1,31 @@ // server.js const express = require('express'); -const cors = require('cors'); +const cors = require('cors'); const app = express(); -const port = 3000; +const swaggerUi = require('swagger-ui-express'); +const swaggerJsdoc = require('swagger-jsdoc'); +const config = require('./src/config'); +const DatabaseManager = require('./src/models/database'); // Middleware pour gérer les requêtes JSON app.use(express.json()); -// Cors accès à tout -app.use(cors({ - origin: ['http://127.0.0.1:5500', 'http://localhost:5500', 'http://localhost:3000'], - methods: ['GET', 'POST', 'PUT', 'DELETE'], - allowedHeaders: ['Content-Type'], - credentials: true, -})); +// Configuration CORS +app.use(cors(config.server.cors)); -// Importer les routes -const apiRoutes = require('./api'); +// Initialisation de la base de données +DatabaseManager.initialize() + .then(() => console.log('[SERVER] Base de données initialisée avec succès')) + .catch(err => console.error('[SERVER] Erreur d\'initialisation de la base de données:', err)); + +// Importer les routes API +const apiRoutes = require('./src/routes'); app.use('/api', apiRoutes); -// Swagger dependencies -const swaggerUi = require('swagger-ui-express'); -const swaggerJsdoc = require('swagger-jsdoc'); - // Configuration de Swagger const swaggerOptions = { - definition: { - openapi: '3.0.0', - info: { - title: 'API Documentation', - version: '1.0.0', - description: 'Documentation de l\'API avec Swagger', - }, - servers: [ - { - url: 'https://timelapse.kerboul.me/api', - }, - { - url: 'http://localhost:3000/api', - } - ], - }, - apis: ['./routes/*.js'], // Prend en compte tous les fichiers de routes pour générer la documentation + definition: config.swagger.definition, + apis: config.swagger.apis }; // Initialisation de swagger-jsdoc @@ -52,14 +36,36 @@ app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocs)); // Route de base pour tester le serveur app.get('/', (req, res) => { - res.setHeader('Access-Control-Allow-Origin', 'http://localhost:5500'); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); - res.send('Bienvenue sur mon API Node.js!'); + res.send('Bienvenue sur l\'API Timelapse!'); +}); + +// Gestion des erreurs 404 +app.use((req, res) => { + res.status(404).json({ + error: { + message: 'Route non trouvée', + path: req.path, + method: req.method, + statusCode: 404 + } + }); +}); + +// Gestion des erreurs globales +app.use((err, req, res, next) => { + console.error('[SERVER] Erreur non gérée:', err); + res.status(500).json({ + error: { + message: 'Erreur serveur interne', + statusCode: 500, + details: process.env.NODE_ENV === 'production' ? undefined : err.message + } + }); }); // Démarrer le serveur +const port = config.server.port; app.listen(port, () => { console.log(`[SERVER] Serveur démarré sur http://localhost:${port}`); - console.log(`[SERVER] Swagger documentation disponible sur http://localhost:${port}/api-docs`); + console.log(`[SERVER] Documentation Swagger disponible sur http://localhost:${port}/api-docs`); }); diff --git a/src/config/index.js b/src/config/index.js new file mode 100644 index 0000000..e848be1 --- /dev/null +++ b/src/config/index.js @@ -0,0 +1,70 @@ +// src/config/index.js +const path = require('path'); + +// Configuration centralisée de l'application +module.exports = { + // Configuration du serveur + server: { + port: process.env.PORT || 3000, + cors: { + origins: ['http://127.0.0.1:5500', 'http://localhost:5500', 'http://localhost:3000'], + methods: ['GET', 'POST', 'PUT', 'DELETE'], + allowedHeaders: ['Content-Type'], + credentials: true + } + }, + + // Configuration de la base de données + database: { + host: process.env.DB_HOST || 'timelapse-db', + port: process.env.DB_PORT || 5432, + user: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || 'postgres', + database: process.env.DB_NAME || 'timelapse', + reconnectInterval: 3000 + }, + + // Configuration de Swagger + swagger: { + definition: { + openapi: '3.0.0', + info: { + title: 'API Documentation', + version: '1.0.0', + description: 'Documentation de l\'API avec Swagger' + }, + servers: [ + { url: 'https://timelapse.kerboul.me/api' }, + { url: 'http://localhost:3000/api' } + ] + }, + apis: ['./src/routes/*.js', './src/controllers/*.js'] // Chemins pour la documentation + }, + + // Chemins des répertoires + paths: { + storage: path.join(process.cwd(), 'storage'), + uploads: path.join(process.cwd(), 'uploads'), + samples: path.join(process.cwd(), 'sample') + }, + + // Statuts pour les projets et vidéos + status: { + waiting: 0, + completed: 1, + failed: 2, + inProgress: 3 + }, + + // Paramètres par défaut pour la caméra + camera: { + defaultSettings: { + id: 1, + interval: null, + nbImages: null, + maintenance: false, + stopFlag: false, + idle: true + } + } +}; \ No newline at end of file diff --git a/src/controllers/cameraController.js b/src/controllers/cameraController.js new file mode 100644 index 0000000..5af030c --- /dev/null +++ b/src/controllers/cameraController.js @@ -0,0 +1,152 @@ +// src/controllers/cameraController.js +const Camera = require('../models/Camera'); +const Project = require('../models/Project'); +const { sendError, asyncHandler } = require('../utils/errorHandler'); +const config = require('../config'); + +/** + * Contrôleur pour les opérations liées à la caméra + */ +class CameraController { + /** + * Récupère le statut actuel de la caméra + */ + static getCameraStatus = asyncHandler(async (req, res) => { + try { + const settings = await Camera.getCamera(); + + if (!settings) { + // Initialise la caméra si elle n'existe pas + const cameraSettings = await Camera.initializeCamera(); + return res.json(cameraSettings); + } + + res.json(settings); + } catch (error) { + sendError('Erreur lors de la récupération du statut de la caméra', res, error, 500); + } + }); + + /** + * Démarre une procédure de capture + */ + static startProcedure = asyncHandler(async (req, res) => { + const { project_id, interval, nb_images } = req.body; + + if (!interval || !nb_images) { + return sendError('L\'intervalle et le nombre d\'images sont requis', res, null, 400); + } + + try { + // Vérifie qu'aucune procédure n'est déjà en cours + const existingProject = await Project.findCurrentRenderingProject(); + + if (existingProject) { + return sendError('Un projet est déjà en cours de capture. Veuillez l\'arrêter avant d\'en démarrer un nouveau.', res, null, 400); + } + + // Met à jour les paramètres de la caméra + const newSettings = { + interval: interval, + nb_images: nb_images, + stop_flag: false, + active: 1 // active = 1 (idle = 0) + }; + + await Camera.updateCamera(1, newSettings); + + // Met à jour le statut du projet + await Project.updateProject(project_id, { status: config.status.inProgress }); + + console.log(`[CAMERA] Procédure démarrée pour le projet : ${project_id}`); + + res.json({ + message: 'Procédure de capture démarrée avec succès', + settings: { interval, nb_images } + }); + } catch (error) { + sendError('Erreur lors du démarrage de la procédure de capture', res, error, 500); + } + }); + + /** + * Initie l'arrêt de la procédure de capture + */ + static stopProcedure = asyncHandler(async (req, res) => { + try { + // Marque le drapeau d'arrêt + await Camera.updateCamera(1, { stop_flag: true }); + + console.log('[CAMERA] Arrêt de la caméra demandé, en attente de confirmation...'); + + res.json({ message: 'Procédure d\'arrêt de la caméra initiée avec succès' }); + } catch (error) { + sendError('Erreur lors de l\'arrêt de la procédure de capture', res, error, 500); + } + }); + + /** + * Confirme l'arrêt de la caméra (appelé par la caméra) + */ + static confirmStopProcedure = asyncHandler(async (req, res) => { + try { + // Réinitialise les paramètres de la caméra + const newSettings = { + interval: null, + nb_images: null, + stop_flag: false, + active: 0 // idle = true + }; + + await Camera.updateCamera(1, newSettings); + + // Réinitialise le statut du projet en cours + const currentProject = await Project.findCurrentRenderingProject(); + + if (currentProject) { + await Project.updateProject(currentProject.id, { status: config.status.waiting }); + console.log(`[CAMERA] Projet : ${currentProject.id} arrêté.`); + } else { + console.log('[CAMERA] Aucun projet à arrêter.'); + } + + console.log('[CAMERA] Caméra arrêtée.'); + + res.json({ message: 'Caméra arrêtée avec succès' }); + } catch (error) { + sendError('Erreur lors de la confirmation de l\'arrêt de la caméra', res, error, 500); + } + }); + + /** + * Active le mode maintenance + */ + static activateMaintenance = asyncHandler(async (req, res) => { + try { + await Camera.updateCamera(1, { maintenance: 1 }); + + console.log('[CAMERA] Mode maintenance activé.'); + + res.json({ message: 'Caméra en mode maintenance' }); + } catch (error) { + sendError('Erreur lors de l\'activation du mode maintenance', res, error, 500); + } + }); + + /** + * Désactive le mode maintenance + */ + static deactivateMaintenance = asyncHandler(async (req, res) => { + try { + await Camera.updateCamera(1, { maintenance: 0 }); + + console.log('[CAMERA] Mode maintenance désactivé.'); + + res.json({ message: 'Caméra sortie du mode maintenance' }); + } catch (error) { + sendError('Erreur lors de la désactivation du mode maintenance', res, error, 500); + } + }); +} + +module.exports = CameraController; \ No newline at end of file diff --git a/src/controllers/imageController.js b/src/controllers/imageController.js new file mode 100644 index 0000000..a211a90 --- /dev/null +++ b/src/controllers/imageController.js @@ -0,0 +1,175 @@ +// src/controllers/imageController.js +const fs = require('fs'); +const sharp = require('sharp'); +const Measurement = require('../models/Measurement'); +const StorageService = require('../services/storageService'); +const { sendError, asyncHandler } = require('../utils/errorHandler'); +const path = require('path'); +const config = require('../config'); + +/** + * Contrôleur pour les opérations liées aux images + */ +class ImageController { + /** + * Récupère une image par projet et ordre ID + */ + static getImageByProjectAndOrderId = asyncHandler(async (req, res) => { + const { projectId, orderId } = req.params; + + if (!projectId || !orderId || isNaN(projectId) || isNaN(orderId)) { + return sendError('IDs de projet ou d\'ordre invalides', res, null, 400); + } + + try { + const measurement = await Measurement.getMeasurementByProjectAndOrderId(projectId, orderId); + + if (!measurement) { + return sendError('Image non trouvée', res, null, 404); + } + + const imagePath = measurement.path; + + // Vérifie si l'image existe + await fs.promises.access(imagePath, fs.constants.F_OK); + + res.download(imagePath); + } catch (error) { + if (error.code === 'ENOENT') { + sendError('Image non trouvée sur le disque', res, error, 404); + } else { + sendError('Erreur lors de la récupération de l\'image', res, error, 500); + } + } + }); + + /** + * Récupère une image par son ID de mesure + */ + static getImageByMeasurementId = asyncHandler(async (req, res) => { + const measurementId = req.params.measurementId; + + if (!measurementId || isNaN(measurementId)) { + return sendError('ID de mesure invalide', res, null, 400); + } + + try { + const measurement = await Measurement.getMeasurementById(measurementId); + + if (!measurement) { + return sendError('Image non trouvée', res, null, 404); + } + + const imagePath = measurement.path; + + // Vérifie si l'image existe + await fs.promises.access(imagePath, fs.constants.F_OK); + + res.download(imagePath); + } catch (error) { + if (error.code === 'ENOENT') { + sendError('Image non trouvée sur le disque', res, error, 404); + } else { + sendError('Erreur lors de la récupération de l\'image', res, error, 500); + } + } + }); + + /** + * Récupère un aperçu redimensionné d'une image + */ + static getImagePreview = asyncHandler(async (req, res) => { + const { projectId, orderId } = req.params; + + if (!projectId || !orderId || isNaN(projectId) || isNaN(orderId)) { + return sendError('IDs de projet ou d\'ordre invalides', res, null, 400); + } + + try { + const measurement = await Measurement.getMeasurementByProjectAndOrderId(projectId, orderId); + + if (!measurement) { + return sendError('Image non trouvée', res, null, 404); + } + + const imagePath = measurement.path; + + // Vérifie si l'image existe + await fs.promises.access(imagePath, fs.constants.F_OK); + + // Redimensionne l'image + const metadata = await sharp(imagePath).metadata(); + const width = Math.floor(metadata.width / 7); + const height = Math.floor(metadata.height / 7); + + const resizedImage = await sharp(imagePath) + .resize(width, height) + .jpeg({ quality: 65 }) + .toBuffer(); + + res.set('Content-Type', 'image/jpeg'); + res.send(resizedImage); + } catch (error) { + if (error.code === 'ENOENT') { + sendError('Image non trouvée sur le disque', res, error, 404); + } else { + sendError('Erreur lors de la récupération de l\'aperçu de l\'image', res, error, 500); + } + } + }); + + /** + * Télécharge une nouvelle image avec des données de mesure + */ + static uploadImage = asyncHandler(async (req, res) => { + const { projectId, timestamp, temperature, humidity } = req.body; + const image = req.file; + + if (!image || !projectId || !timestamp || !temperature || !humidity) { + return sendError('Tous les champs sont requis', res, null, 400); + } + + try { + // Obtention du prochain ordre ID + const nextOrderId = await Measurement.getNextOrderId(projectId); + + if (nextOrderId === null) { + return sendError('Projet non trouvé', res, null, 404); + } + + // Enregistrement de l'image + const imagePath = await StorageService.measurement.uploadMeasurementImage( + image, projectId, nextOrderId + ); + + // Création de l'entrée dans la base de données + const measurement = await Measurement.createMeasurement( + projectId, timestamp, imagePath, temperature, humidity, nextOrderId + ); + + res.json({ + message: 'Mesure téléchargée avec succès', + path: imagePath, + id: measurement.id + }); + } catch (error) { + sendError('Erreur lors du téléchargement de la mesure', res, error, 500); + } + }); + + /** + * Récupère une image d'exemple (pour les tests) + */ + static getSmileImage = asyncHandler(async (req, res) => { + const imagePath = path.join(config.paths.samples, 'smile.png'); + + try { + await fs.promises.access(imagePath, fs.constants.F_OK); + res.sendFile(imagePath); + } catch (error) { + sendError('Image d\'exemple non trouvée', res, error, 404); + } + }); +} + +module.exports = ImageController; \ No newline at end of file diff --git a/src/controllers/measurementController.js b/src/controllers/measurementController.js new file mode 100644 index 0000000..49d05b7 --- /dev/null +++ b/src/controllers/measurementController.js @@ -0,0 +1,135 @@ +// src/controllers/measurementController.js +const Measurement = require('../models/Measurement'); +const { sendError, asyncHandler } = require('../utils/errorHandler'); +const StorageService = require('../services/storageService'); +const fs = require('fs').promises; + +/** + * Contrôleur pour les opérations liées aux mesures + */ +class MeasurementController { + /** + * Récupère toutes les mesures + */ + static getAllMeasurements = asyncHandler(async (req, res) => { + const measurements = await Measurement.getAllMeasurements(); + + if (!measurements || measurements.length === 0) { + return sendError('Aucune mesure trouvée', res, null, 404); + } + + res.json(measurements); + }); + + /** + * Récupère une mesure par son ID + */ + static getMeasurementById = asyncHandler(async (req, res) => { + const measurementId = req.params.id; + + if (!measurementId || isNaN(measurementId)) { + return sendError('ID de mesure invalide', res, null, 400); + } + + const measurement = await Measurement.getMeasurementById(measurementId); + + if (!measurement) { + return sendError('Mesure non trouvée', res, null, 404); + } + + res.json(measurement); + }); + + /** + * Récupère une mesure par son projet ID et son ordre ID + */ + static getMeasurementByProjectAndOrderId = asyncHandler(async (req, res) => { + const { projectId, orderId } = req.params; + + if (!projectId || !orderId || isNaN(projectId) || isNaN(orderId)) { + return sendError('IDs de projet ou d\'ordre invalides', res, null, 400); + } + + const measurement = await Measurement.getMeasurementByProjectAndOrderId(projectId, orderId); + + if (!measurement) { + return sendError('Mesure non trouvée', res, null, 404); + } + + res.json(measurement); + }); + + /** + * Supprime une mesure par son ID + */ + static deleteMeasurement = asyncHandler(async (req, res) => { + const measurementId = req.params.id; + + if (!measurementId || isNaN(measurementId)) { + return sendError('ID de mesure invalide', res, null, 400); + } + + // Récupère les informations de la mesure avant suppression + const measurement = await Measurement.getMeasurementById(measurementId); + + if (!measurement) { + return sendError('Mesure non trouvée', res, null, 404); + } + + // Supprime le fichier image associé si existant + if (measurement.path) { + try { + await fs.access(measurement.path); + await fs.unlink(measurement.path); + } catch (error) { + console.error(`[MEASUREMENT] Erreur lors de la suppression du fichier : ${measurement.path}`, error); + } + } + + // Supprime l'entrée de la base de données + await Measurement.deleteMeasurement(measurementId); + + res.json({ + message: 'Mesure supprimée avec succès', + id: measurementId + }); + }); + + /** + * Supprime une mesure par projet ID et ordre ID + */ + static deleteMeasurementByProjectAndOrderId = asyncHandler(async (req, res) => { + const { projectId, orderId } = req.params; + + if (!projectId || !orderId || isNaN(projectId) || isNaN(orderId)) { + return sendError('IDs de projet ou d\'ordre invalides', res, null, 400); + } + + // Récupère les informations de la mesure avant suppression + const measurement = await Measurement.getMeasurementByProjectAndOrderId(projectId, orderId); + + if (!measurement) { + return sendError('Mesure non trouvée', res, null, 404); + } + + // Supprime le fichier image associé si existant + if (measurement.path) { + try { + await fs.access(measurement.path); + await fs.unlink(measurement.path); + } catch (error) { + console.error(`[MEASUREMENT] Erreur lors de la suppression du fichier : ${measurement.path}`, error); + } + } + + // Supprime l'entrée de la base de données + await Measurement.deleteMeasurement(measurement.id); + + res.json({ + message: 'Mesure supprimée avec succès', + id: measurement.id + }); + }); +} + +module.exports = MeasurementController; \ No newline at end of file diff --git a/src/controllers/projectController.js b/src/controllers/projectController.js new file mode 100644 index 0000000..fd2f74c --- /dev/null +++ b/src/controllers/projectController.js @@ -0,0 +1,123 @@ +// src/controllers/projectController.js +const Project = require('../models/Project'); +const Video = require('../models/Video'); +const Measurement = require('../models/Measurement'); +const StorageService = require('../services/storageService'); +const { sendError, asyncHandler } = require('../utils/errorHandler'); +const config = require('../config'); + +/** + * Contrôleur pour les opérations liées aux projets + */ +class ProjectController { + /** + * Récupère tous les projets + */ + static getAllProjects = asyncHandler(async (req, res) => { + const projects = await Project.getAllProjects(); + res.json(projects); + }); + + /** + * Récupère un projet par son ID + */ + static getProjectById = asyncHandler(async (req, res) => { + const projectId = req.params.id; + + if (!projectId || isNaN(projectId)) { + return sendError('ID de projet invalide', res, null, 400); + } + + const project = await Project.getProjectById(projectId); + + if (!project) { + return sendError('Projet non trouvé', res, null, 404); + } + + res.json(project); + }); + + /** + * Récupère les vidéos d'un projet + */ + static getProjectVideos = asyncHandler(async (req, res) => { + const projectId = req.params.id; + + if (!projectId || isNaN(projectId)) { + return sendError('ID de projet invalide', res, null, 400); + } + + const videos = await Video.getVideosByProjectId(projectId); + + if (videos.length === 0) { + return sendError('Aucune vidéo trouvée pour ce projet', res, null, 404); + } + + res.json(videos); + }); + + /** + * Récupère les mesures d'un projet + */ + static getProjectMeasurements = asyncHandler(async (req, res) => { + const projectId = req.params.id; + + if (!projectId || isNaN(projectId)) { + return sendError('ID de projet invalide', res, null, 400); + } + + const measurements = await Measurement.getMeasurementsByProjectId(projectId); + + if (measurements.length === 0) { + return sendError('Aucune mesure trouvée pour ce projet', res, null, 404); + } + + res.json(measurements); + }); + + /** + * Crée un nouveau projet + */ + static createProject = asyncHandler(async (req, res) => { + const { name, description } = req.body; + + if (!name || !description) { + return sendError('Le nom et la description sont requis', res, null, 400); + } + + const date = new Date(); + const defaultStatus = config.status.waiting; + + const project = await Project.createProject(name, description, date, defaultStatus); + await StorageService.project.createProjectDirectory(project.id); + + res.status(201).json({ + message: 'Projet ajouté avec succès', + id: project.id + }); + }); + + /** + * Supprime un projet existant + */ + static deleteProject = asyncHandler(async (req, res) => { + const projectId = req.params.id; + + if (!projectId || isNaN(projectId)) { + return sendError('ID de projet invalide', res, null, 400); + } + + // Supprime d'abord le répertoire du projet + await StorageService.project.deleteProjectDirectory(projectId); + + // Puis supprime l'entrée dans la base de données + await Project.deleteProject(projectId); + + res.status(200).json({ + message: 'Projet supprimé avec succès', + id: projectId + }); + }); +} + +module.exports = ProjectController; \ No newline at end of file diff --git a/src/controllers/videoController.js b/src/controllers/videoController.js new file mode 100644 index 0000000..7aa7058 --- /dev/null +++ b/src/controllers/videoController.js @@ -0,0 +1,256 @@ +// src/controllers/videoController.js +const fs = require('fs'); +const rangeParser = require('range-parser'); +const Video = require('../models/Video'); +const Measurement = require('../models/Measurement'); +const StorageService = require('../services/storageService'); +const VideoService = require('../services/videoService'); +const { sendError, asyncHandler } = require('../utils/errorHandler'); +const config = require('../config'); +const path = require('path'); + +/** + * Contrôleur pour les opérations liées aux vidéos + */ +class VideoController { + /** + * Récupère toutes les vidéos + */ + static getAllVideos = asyncHandler(async (req, res) => { + const videos = await Video.getAllVideos(); + res.json(videos); + }); + + /** + * Récupère une vidéo par son ID + */ + static getVideoById = asyncHandler(async (req, res) => { + const videoId = req.params.id; + + if (!videoId || isNaN(videoId)) { + return sendError('ID de vidéo invalide', res, null, 400); + } + + const video = await Video.getVideoById(videoId); + + if (!video) { + return sendError('Vidéo non trouvée', res, null, 404); + } + + res.json(video); + }); + + /** + * Crée une nouvelle vidéo + */ + static createVideo = asyncHandler(async (req, res) => { + const { project_id, measurement_ids, name, resolution, duration } = req.body; + + if (!project_id || !measurement_ids || !name || !resolution || !duration) { + return sendError('Tous les champs sont requis', res, null, 400); + } + + console.log('[VIDEO] Création de vidéo avec les mesures:', measurement_ids); + + try { + // Crée l'entrée vidéo dans la base de données + const video = await Video.createVideo( + project_id, + measurement_ids, + name, + resolution, + duration, + config.status.waiting + ); + + if (!video || !video.id) { + return sendError('Erreur lors de la création de la vidéo', res, null, 500); + } + + console.log('[VIDEO] Vidéo créée avec succès:', video.id); + + // Parse et récupère les chemins des images + const pathList = await this.getMeasurementPathList(measurement_ids, project_id); + + if (!pathList || pathList.length === 0) { + return sendError('Aucun chemin trouvé pour les mesures', res, null, 404); + } + + // Parse la résolution (ex: 1920x1080) + const [resWidth, resHeight] = resolution.split('x').map(Number); + if (isNaN(resWidth) || isNaN(resHeight)) { + return sendError('Format de résolution invalide. Utiliser LARGEURxHAUTEUR (ex: 1920x1080)', res, null, 400); + } + + // Démarre le processus de rendu en arrière-plan + VideoService.createVideoFromImages( + project_id, + pathList, + duration, + video.id, + resWidth, + resHeight + ) + .then(videoFile => { + console.log('[VIDEO] Rendu terminé:', videoFile); + return Video.updateVideoFilePath(video.id, videoFile); + }) + .catch(error => { + console.error('[VIDEO] Échec du rendu:', error); + }); + + // Réponse immédiate + res.json({ + message: 'Vidéo créée avec succès et le rendu a démarré', + id: video.id + }); + } catch (err) { + console.error('[VIDEO] Erreur lors de la création de la vidéo:', err); + sendError('Erreur lors de la création de la vidéo', res, err, 500); + } + }); + + /** + * Supprime une vidéo existante + */ + static deleteVideo = asyncHandler(async (req, res) => { + const videoId = req.params.id; + + if (!videoId || isNaN(videoId)) { + return sendError('ID de vidéo invalide', res, null, 400); + } + + // Récupère les informations de la vidéo + const video = await Video.getVideoById(videoId); + + if (!video) { + return sendError('Vidéo non trouvée', res, null, 404); + } + + // Supprime le fichier vidéo s'il existe + if (video.video_file) { + try { + console.log('[VIDEO] Suppression du fichier vidéo:', video.video_file); + await StorageService.deleteFile(video.video_file); + } catch (err) { + console.error('[VIDEO] Erreur lors de la suppression du fichier vidéo:', err); + } + } else { + console.log('[VIDEO] Pas de fichier vidéo à supprimer'); + } + + // Supprime l'entrée dans la base de données + await Video.deleteVideo(videoId); + + res.json({ message: 'Vidéo supprimée avec succès' }); + }); + + /** + * Récupère le fichier vidéo avec support du streaming + */ + static getVideoFile = asyncHandler(async (req, res) => { + const videoId = req.params.video_id; + const video = await Video.getVideoById(videoId); + + if (!video) { + return sendError('Vidéo non trouvée', res, null, 404); + } + + if (video.status === config.status.waiting || video.status === config.status.inProgress) { + return sendError('Vidéo pas encore produite', res, null, 400); + } + + let videoPath = video.video_file; + + // Vérifie si le fichier existe + try { + await fs.promises.access(videoPath, fs.constants.F_OK); + } catch (err) { + console.error('[VIDEO] Fichier vidéo non trouvé:', err); + return sendError('Fichier vidéo non trouvé', res, err, 404); + } + + const stat = fs.statSync(videoPath); + const fileSize = stat.size; + const range = req.headers.range; + + if (range) { + const parts = rangeParser(fileSize, range); + const start = parts[0].start; + const end = parts[0].end; + const chunksize = (end - start) + 1; + const file = fs.createReadStream(videoPath, { start, end }); + + const head = { + 'Content-Range': `bytes ${start}-${end}/${fileSize}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': chunksize, + 'Content-Type': 'video/mp4', + }; + + res.writeHead(206, head); + file.pipe(res); + } else { + const head = { + 'Content-Length': fileSize, + 'Content-Type': 'video/mp4', + }; + + res.writeHead(200, head); + fs.createReadStream(videoPath).pipe(res); + } + }); + + /** + * Récupère la progression d'une vidéo en cours de rendu + */ + static getVideoProgress = asyncHandler(async (req, res) => { + const videoId = req.params.video_id; + + try { + const progress = await VideoService.getVideoProgress(videoId); + + res.json({ + progress: progress.progress, + elapsed: progress.elapsed, + eta: progress.eta, + status: VideoService.getStatusLabel(progress.status) + }); + } catch (error) { + sendError('Erreur de récupération de la progression', res, error, 500); + } + }); + + /** + * Récupère les chemins d'images à partir d'une liste d'IDs de mesures + * Méthode utilitaire privée + */ + static async getMeasurementPathList(idListJson, projectId) { + let idList; + + try { + idList = JSON.parse(idListJson); + + if (!Array.isArray(idList)) { + throw new Error('Le format de la liste des IDs est invalide'); + } + } catch (e) { + console.error('[VIDEO] Erreur de parsing de la liste d\'IDs:', e); + return []; + } + + const pathList = []; + + for (const orderId of idList) { + const measurement = await Measurement.getMeasurementByProjectAndOrderId(projectId, orderId); + + if (measurement && measurement.path) { + pathList.push(measurement.path); + } + } + + return pathList; + } +} + +module.exports = VideoController; \ No newline at end of file diff --git a/src/data/storage_manager.js b/src/data/storage_manager.js index 097fe45..e120583 100644 --- a/src/data/storage_manager.js +++ b/src/data/storage_manager.js @@ -1,200 +1,30 @@ -const fs = require('fs').promises; -const path = require('path'); -const { Buffer } = require('buffer'); -const PROJECTS_DIR = path.join('.'); -const database_manager = require('../database/database_manager.js'); +/** + * Ce fichier est conservé pour la rétrocompatibilité mais redirige vers le nouveau service de stockage. + * Il sera progressivement supprimé lorsque toutes les références auront été mises à jour. + */ -async function createFolder(name) { - const projectDir = path.join(PROJECTS_DIR, `${name}`); - try { - await fs.access(projectDir); - } catch (error) { - if (error.code === 'ENOENT') { - await fs.mkdir(projectDir, { recursive: true }); - } else { - throw error; - } - } - return projectDir; -} +const StorageService = require('../services/storageService'); -async function deleteFolder(name) { - const projectDir = path.join(PROJECTS_DIR, `${name}`); - try { - await fs.access(projectDir); - await fs.rm(projectDir, { recursive: true, force: true }); - } catch (error) { - if (error.code !== 'ENOENT') { - throw error; - } - } -} - -async function scanAllImages(dir = 'storage') { - const projectDir = path.join(PROJECTS_DIR, dir); - let results = []; - - // check if the directory exists and create it if not - try { - await fs.access(projectDir); - } catch (error) { - if (error.code === 'ENOENT') { - await fs.mkdir(projectDir, { recursive: true }); - } else { - throw error; - } - } - - async function scanDirectory(directory) { - const files = await fs.readdir(directory); - for (const file of files) { - const filePath = path.join(directory, file); - const stat = await fs.stat(filePath); - if (stat.isDirectory()) { - await scanDirectory(filePath); - } else if (file.endsWith('.jpg')) { - results.push(filePath); - } - } - } - - await scanDirectory(projectDir); - return results; -} - -async function saveFile(filePath, content) { - const dir = path.dirname(filePath); - await fs.mkdir(dir, { recursive: true }); - if (Buffer.isBuffer(content)) { - await fs.writeFile(filePath, content); - } else { - throw new Error('Content must be a buffer'); - } -} - -async function getFile(name) { - const filePath = path.join(PROJECTS_DIR, `${name}`); - return await fs.readFile(filePath); -} - -async function deleteFile(name) { - const filePath = path.join(PROJECTS_DIR, `${name}`); - try { - await fs.access(filePath); // Vérifie si le fichier existe - await fs.rm(filePath); // Supprime le fichier - return `File ${filePath} deleted successfully.`; - } catch (error) { - if (error.code === 'ENOENT') { - return `File ${filePath} does not exist.`; - } else { - throw error; // Relance l'erreur si ce n'est pas une erreur de fichier introuvable - } - } -} - -async function handleFileOperation(operation, ...args) { - try { - return await operation(...args); - } catch (error) { - console.error(`[FILE OPERATION ERROR] ${error.message}`); - throw error; - } -} - -const project = { - createProjectDirectory: async function (projectId) { - const projectPath = path.join(PROJECTS_DIR, 'storage', `${projectId}`); - await handleFileOperation(createFolder, projectPath); - await handleFileOperation(createFolder, `${projectPath}/images`); - await handleFileOperation(createFolder, `${projectPath}/videos`); - console.log("[FILE] createProjectDirectory : " + projectPath); - }, - - deleteProjectDirectory: async function (projectId) { - const projectPath = path.join(PROJECTS_DIR, 'storage', `${projectId}`); - await handleFileOperation(deleteFolder, projectPath); - console.log("[FILE] deleteProjectDirectory : " + projectPath); - } +// Structure de redirection pour la compatibilité +const storage_manager = { + project: { + create_project_directory: StorageService.project.createProjectDirectory, + delete_project_directory: StorageService.project.deleteProjectDirectory + }, + measurement: { + get_measurement_image: StorageService.measurement.getMeasurementImage, + upload_measurement_image: StorageService.measurement.uploadMeasurementImage + }, + video: { + get_video: StorageService.video.getVideo, + delete_video: StorageService.video.deleteVideo + }, + scan_images: StorageService.scanImages, + create_directory: StorageService.createDirectory, + delete_directory: StorageService.deleteDirectory, + save_file: StorageService.saveFile, + get_file: StorageService.getFile, + delete_file: StorageService.deleteFile }; -const measurement = { - get_measurement_image: async function (projectId, orderId) { - const projectPath = path.join(PROJECTS_DIR, 'storage', `${projectId}`); - const imagePath = path.join(projectPath, 'images', `${orderId}.jpg`); - console.log("[FILE] get_measurement_image : " + imagePath); - return await handleFileOperation(getFile, imagePath); - }, - - upload_measurement_image: async function (image, projectId, orderId) { - const projectPath = path.join(PROJECTS_DIR, 'storage', `${projectId}`); - const imagePath = path.join(projectPath, 'images', `${orderId}.jpg`); - console.log("[FILE] upload_measurement_image : " + imagePath); - await handleFileOperation(saveFile, imagePath, image.buffer); - return imagePath; - }, - - get_path_from_id: async function (projectId, orderId) { - const query = await database_manager.measurement.get_measurement_by_project_and_order_id(projectId, orderId); - console.log("[FILE] get_path_from_id : " + query.path); - console.log("[FILE] get_path_from_id : " + query); - return query.path; - }, - - get_path_list: async function (IdList, projectId) { - let parsedIdList; - try { - parsedIdList = JSON.parse(IdList); - } catch (e) { - console.error("Error parsing IdList:", e); - return []; - } - - const pathList = []; - for (const orderId of parsedIdList) { - const path = await this.get_path_from_id(projectId, orderId); - pathList.push(path); - } - return pathList; - }, -} - -const video = { - get_video: async function (projectId, orderId) { - const projectPath = path.join(PROJECTS_DIR, `${projectId}`); - const videoPath = `/storage/${projectPath}/videos/${orderId}.mp4`; - console.log("[FILE] get_video : " + videoPath); - return await handleFileOperation(getFile, videoPath); - }, - - delete_video: async function (videoId) { - const query = database_manager.video.get_video_by_id(videoId); - const videoPath = query.video_file; - console.log("[FILE] delete_video : " + videoPath); - return await handleFileOperation(deleteFile, videoPath); - }, - - delete_unfinished_videos: async function () { - const unfinishedVideos = await database_manager.video.get_unfinished_videos(); - - for (const video of unfinishedVideos) { - try { - await this.delete_video(video.id); - console.log(`Deleted unfinished video with id: ${video.id}`); - } catch (error) { - console.error(`Error deleting unfinished video with id: ${video.id}`, error); - } - } - } -} - -module.exports = { - createFolder, - deleteFolder, - scanAllImages, - saveFile, - getFile, - deleteFile, - project, - measurement, - video -}; \ No newline at end of file +module.exports = storage_manager; \ No newline at end of file diff --git a/src/database/connection.js b/src/database/connection.js new file mode 100644 index 0000000..4f4e362 --- /dev/null +++ b/src/database/connection.js @@ -0,0 +1,44 @@ +// src/database/connection.js +const { Client } = require('pg'); +const config = require('../config'); + +// Création du client PostgreSQL avec la configuration centralisée +const client = new Client({ + host: config.database.host, + port: config.database.port, + user: config.database.user, + password: config.database.password, + database: config.database.database +}); + +let isConnecting = false; + +/** + * Initialise la connexion à la base de données + * Réessaie automatiquement si la connexion échoue + */ +function initDatabase() { + if (isConnecting) { + console.log('[DB] Tentative de connexion déjà en cours, ignorer...'); + return; + } + + console.log('[DB] Initialisation de la connexion à PostgreSQL...'); + isConnecting = true; + + client.connect(err => { + isConnecting = false; + + if (err) { + console.error('[DB] Erreur de connexion à la base de données:', err); + setTimeout(initDatabase, config.database.reconnectInterval); + } else { + console.log('[DB] Connecté à la base de données PostgreSQL.'); + } + }); +} + +// Initialise la connexion lors de l'importation de ce module +initDatabase(); + +module.exports = client; \ No newline at end of file diff --git a/src/database/database_manager.js b/src/database/database_manager.js index 9124f28..1d512f4 100644 --- a/src/database/database_manager.js +++ b/src/database/database_manager.js @@ -1,302 +1,47 @@ -const db = require('../../db.js'); +// src/database/database_manager.js +/** + * Ce fichier est conservé pour la rétrocompatibilité mais redirige vers les nouveaux modèles. + * Il sera progressivement supprimé lorsque toutes les références auront été mises à jour. + */ -// Fonctions de gestion de la base de données interne +const Project = require('../models/Project'); +const Measurement = require('../models/Measurement'); +const Video = require('../models/Video'); +const Camera = require('../models/Camera'); -async function create_database() { - const queries = [ - `CREATE TABLE IF NOT EXISTS projects ( - id SERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL, - description TEXT, - start_date DATE, - status INTEGER NOT NULL CHECK (status = ANY (ARRAY [0, 1, 2, 3])) - );`, - `ALTER TABLE projects OWNER TO timelapse;`, - `CREATE TABLE IF NOT EXISTS measurements ( - id SERIAL PRIMARY KEY, - project_id INTEGER REFERENCES projects ON DELETE CASCADE, - timestamp TIMESTAMP NOT NULL, - path VARCHAR(255), - temperature DOUBLE PRECISION, - humidity DOUBLE PRECISION, - order_id INTEGER NOT NULL, - CONSTRAINT unique_project_photo_order UNIQUE (project_id, order_id) - );`, - `ALTER TABLE measurements OWNER TO timelapse;`, - `CREATE TABLE IF NOT EXISTS videos ( - id SERIAL PRIMARY KEY, - project_id INTEGER REFERENCES projects ON DELETE CASCADE, - measurement_ids TEXT NOT NULL, - video_file VARCHAR(255), - resolution VARCHAR(255), - duration INTEGER, - status INTEGER NOT NULL CHECK (status = ANY (ARRAY [0, 1, 2, 3])), - name VARCHAR(255), - progress DOUBLE PRECISION, - started_at TIMESTAMP, - updated_at TIMESTAMP, - eta DOUBLE PRECISION - );`, - `ALTER TABLE videos OWNER TO timelapse;`, - `CREATE TABLE IF NOT EXISTS camera ( - id SERIAL PRIMARY KEY, - interval INTEGER NOT NULL, - maintenance INTEGER NOT NULL, - active INTEGER DEFAULT 0 NOT NULL - );`, - `ALTER TABLE camera OWNER TO timelapse;` - ]; - - try { - for (const query of queries) { - await db.query(query); - } - console.log('Database tables created or verified successfully.'); - } catch (err) { - console.error('Error creating database tables:', err); - throw err; - } -} - -async function check_database_existence() { - const query = ` - SELECT table_name - FROM information_schema.tables - WHERE table_schema = 'public' - AND table_name IN ('projects', 'measurements', 'videos', 'camera'); - `; - - try { - const result = await db.query(query); - const existingTables = result.rows.map(row => row.table_name); - - const requiredTables = ['projects', 'measurements', 'videos', 'camera']; - const missingTables = requiredTables.filter(table => !existingTables.includes(table)); - - if (missingTables.length > 0) { - console.error('Missing or improperly constructed tables:', missingTables); - throw new Error(`The following tables are missing or not properly constructed: ${missingTables.join(', ')}`); - } else { - console.log('All required tables exist and are properly constructed.'); - } - } catch (err) { - console.error('Error checking database tables:', err); - throw err; - } -} - -async function delete_database() { - const queries = [ - `DROP TABLE IF EXISTS videos;`, - `DROP TABLE IF EXISTS measurements;`, - `DROP TABLE IF EXISTS projects;`, - `DROP TABLE IF EXISTS camera;` - ]; - - try { - for (const query of queries) { - await db.query(query); - } - console.log('Database tables deleted successfully.'); - } catch (err) { - console.error('Error deleting database tables:', err); - throw err; - } -} - -async function init_function() { - try { - await check_database_existence(); - } catch (err) { - console.error('Database check failed:', err); - try { - await delete_database(); - await create_database(); - console.log('Database initialized successfully.'); - } catch (err) { - console.error('Error initializing database:', err); - throw err; - } - } finally { - console.log('Database initialization process completed.'); - } -} - -init_function() - .then(() => console.log('Database initialization completed.')) - .catch(err => console.error('Error during database initialization:', err)); - -// Fonctions pour les projets -function handleDatabaseOperation(operation) { - return async (...args) => { - try { - return await operation(...args); - } catch (err) { - console.error(`Error during database operation: ${operation.name}`, err); - throw err; - } - }; -} - -const project = { - get_all_projects: handleDatabaseOperation(async () => { - const query = `SELECT * FROM projects;`; - return (await db.query(query)).rows; - }), - get_project_by_id: handleDatabaseOperation(async (id) => { - const query = `SELECT * FROM projects WHERE id = $1;`; - return (await db.query(query, [id])).rows[0]; - }), - create_project: handleDatabaseOperation(async (name, description, start_date, status) => { - const query = `INSERT INTO projects (name, description, start_date, status) VALUES ($1, $2, $3, $4) RETURNING *;`; - return (await db.query(query, [name, description, start_date, status])).rows[0]; - }), - edit_project_by_id: handleDatabaseOperation(async (id, updates) => { - const fields = Object.keys(updates).map((key, index) => `${key} = $${index + 2}`).join(', '); - const values = [id, ...Object.values(updates)]; - const query = `UPDATE projects SET ${fields} WHERE id = $1 RETURNING *;`; - return (await db.query(query, values)).rows[0]; - }), - delete_project_by_id: handleDatabaseOperation(async (id) => { - const query = `DELETE FROM projects WHERE id = $1;`; - await db.query(query, [id]); - }), - find_current_rendering_project: handleDatabaseOperation(async () => { - const query = `SELECT * FROM projects WHERE status = 1;`; - return (await db.query(query)).rows[0]; - }), +// Structure de redirection pour la compatibilité +const database_manager = { + project: { + get_all_projects: Project.getAllProjects, + get_project_by_id: Project.getProjectById, + create_project: Project.createProject, + edit_project_by_id: Project.updateProject, + delete_project: Project.deleteProject, + find_current_rendering_project: Project.findCurrentRenderingProject + }, + measurement: { + get_all_measurements: Measurement.getAllMeasurements, + get_measurement_by_id: Measurement.getMeasurementById, + get_measurement_by_project_id_and_order_id: Measurement.getMeasurementByProjectAndOrderId, + get_measurements_by_project_id: Measurement.getMeasurementsByProjectId, + create_measurement: Measurement.createMeasurement, + edit_measurement_by_id: Measurement.updateMeasurement, + delete_measurement: Measurement.deleteMeasurement, + get_next_order_id: Measurement.getNextOrderId + }, + video: { + get_all_videos: Video.getAllVideos, + get_video_by_id: Video.getVideoById, + get_videos_by_project_id: Video.getVideosByProjectId, + create_video: Video.createVideo, + edit_video_by_id: Video.updateVideo, + delete_video: Video.deleteVideo + }, + capture: { + get_camera: Camera.getCamera, + edit_camera: Camera.updateCamera, + init_camera: Camera.initializeCamera + } }; -const measurement = { - get_all_measurements: handleDatabaseOperation(async () => { - const query = `SELECT * FROM measurements;`; - return (await db.query(query)).rows; - }), - get_measurement_by_id: handleDatabaseOperation(async (id) => { - const query = `SELECT * FROM measurements WHERE id = $1;`; - return (await db.query(query, [id])).rows[0]; - }), - get_measurement_by_project_and_order_id: handleDatabaseOperation(async (project_id, order_id) => { - const query = `SELECT * FROM measurements WHERE project_id = $1 AND order_id = $2;`; - return (await db.query(query, [project_id, order_id])).rows[0]; - }), - get_measurements_by_project_id: handleDatabaseOperation(async (project_id) => { - const query = `SELECT * FROM measurements WHERE project_id = $1;`; - return (await db.query(query, [project_id])).rows; - }), - create_measurement: handleDatabaseOperation(async (project_id, timestamp, path, temperature, humidity, order_id) => { - const query = `INSERT INTO measurements (project_id, timestamp, path, temperature, humidity, order_id) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *;`; - return (await db.query(query, [project_id, timestamp, path, temperature, humidity, order_id])).rows[0]; - }), - edit_measurement_by_id: handleDatabaseOperation(async (id, updates) => { - const fields = Object.keys(updates).map((key, index) => `${key} = $${index + 2}`).join(', '); - const values = [id, ...Object.values(updates)]; - const query = `UPDATE measurements SET ${fields} WHERE id = $1 RETURNING *;`; - return (await db.query(query, values)).rows[0]; - }), - - edit_measurement_by_project_and_order_id: handleDatabaseOperation(async (project_id, order_id, updates) => { - const fields = Object.keys(updates).map((key, index) => `${key} = $${index + 3}`).join(', '); - const values = [project_id, order_id, ...Object.values(updates)]; - const query = `UPDATE measurements SET ${fields} WHERE project_id = $1 AND order_id = $2 RETURNING *;`; - return (await db.query(query, values)).rows[0]; - }), - - delete_measurement_by_id: handleDatabaseOperation(async (id) => { - const query = `DELETE FROM measurements WHERE id = $1;`; - await db.query(query, [id]); - }), - - get_next_order_id: handleDatabaseOperation(async (project_id) => { - const query = `SELECT COALESCE(MAX(order_id), 0) + 1 AS next_order_id FROM measurements WHERE project_id = $1;`; - const result = await db.query(query, [project_id]); - return result.rows[0].next_order_id; - }) -}; - -const video = { - get_all_videos: handleDatabaseOperation(async () => { - const query = `SELECT * FROM videos;`; - return (await db.query(query)).rows; - }), - - get_video_by_id: handleDatabaseOperation(async (id) => { - const query = `SELECT * FROM videos WHERE id = $1;`; - return (await db.query(query, [id])).rows[0]; - }), - - get_videos_by_project_id: handleDatabaseOperation(async (project_id) => { - const query = `SELECT * FROM videos WHERE project_id = $1;`; - return (await db.query(query, [project_id])).rows; - }), - - create_video: handleDatabaseOperation(async (projectId, measurementIds, name, resolution, duration, status = 0) => { - const query = `INSERT INTO public.videos (project_id, measurement_ids, name, resolution, duration, status) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id;`; - const values = [projectId, measurementIds, name, resolution, duration, status]; - return (await db.query(query, values)).rows[0]; - }), - - edit_video_by_id: handleDatabaseOperation(async (id, updates) => { - const fields = Object.keys(updates).map((key, index) => `${key} = $${index + 2}`).join(', '); - const values = [id, ...Object.values(updates)]; - const query = `UPDATE videos SET ${fields} WHERE id = $1 RETURNING *;`; - return (await db.query(query, values)).rows[0]; - }), - - update_video_file_path_by_id: handleDatabaseOperation(async (id, video_file) => { - const query = `UPDATE videos SET video_file = $1 WHERE id = $2 RETURNING *;`; - return (await db.query(query, [video_file, id])).rows[0]; - } - ), - - delete_video_by_id: handleDatabaseOperation(async (id) => { - const query = `DELETE FROM videos WHERE id = $1;`; - await db.query(query, [id]); - }), - - get_unfinished_videos: handleDatabaseOperation(async () => { - // récupérer liste des vidéos dont le status est = 0, 2 ou 3 - const query = `SELECT * FROM videos WHERE status IN (0, 2, 3);`; - return (await db.query(query)).rows; - }), -}; - -const capture = { - get_camera: handleDatabaseOperation(async () => { - const query = `SELECT * FROM camera WHERE id = 1;`; - return (await db.query(query)).rows[0]; - }), - - edit_camera: handleDatabaseOperation(async (id, updates) => { - const fields = Object.keys(updates).map((key, index) => `${key} = $${index + 2}`).join(', '); - const values = [id, ...Object.values(updates)]; - const query = `UPDATE camera SET ${fields} WHERE id = $1 RETURNING *;`; - return (await db.query(query, values)).rows[0]; - }), - - delete_camera: handleDatabaseOperation(async (id) => { - const query = `DELETE FROM camera WHERE id = $1;`; - await db.query(query, [id]); - }), - - init_camera: handleDatabaseOperation(async (id, interval, nb_images, maintenance, stop_flag, idle) => { - const query = `INSERT INTO camera (id, interval, nb_images, maintenance, stop_flag, idle) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *;`; - const values = [id, interval, nb_images, maintenance, stop_flag, idle]; - return (await db.query(query, values)).rows[0]; - }), -}; - -// zone de test -async function test_zone(){ - // -} - -test_zone(); - -// Export des modules -module.exports = { - project, - measurement, - video, - capture, -}; +module.exports = database_manager; diff --git a/src/models/Camera.js b/src/models/Camera.js new file mode 100644 index 0000000..feb46eb --- /dev/null +++ b/src/models/Camera.js @@ -0,0 +1,72 @@ +// src/models/Camera.js +const db = require('../database/connection'); +const { wrapDatabaseOperation } = require('../utils/errorHandler'); +const config = require('../config'); + +/** + * Modèle pour la gestion de la caméra + */ +class Camera { + /** + * Récupère les paramètres de la caméra + * @returns {Promise} Paramètres de la caméra ou null si non trouvés + */ + static getCamera = wrapDatabaseOperation(async () => { + const query = `SELECT * FROM camera WHERE id = 1;`; + const result = await db.query(query); + return result.rows[0] || null; + }); + + /** + * Met à jour les paramètres de la caméra + * @param {number} id - ID de l'entrée caméra (normalement 1) + * @param {Object} updates - Paramètres à mettre à jour + * @returns {Promise} Paramètres mis à jour ou null si non trouvés + */ + static updateCamera = wrapDatabaseOperation(async (id, updates) => { + const fields = Object.keys(updates).map((key, index) => `${key} = $${index + 2}`).join(', '); + const values = [id, ...Object.values(updates)]; + const query = `UPDATE camera SET ${fields} WHERE id = $1 RETURNING *;`; + const result = await db.query(query, values); + return result.rows[0] || null; + }); + + /** + * Supprime les paramètres de la caméra + * @param {number} id - ID de l'entrée caméra à supprimer + * @returns {Promise} + */ + static deleteCamera = wrapDatabaseOperation(async (id) => { + const query = `DELETE FROM camera WHERE id = $1;`; + await db.query(query, [id]); + }); + + /** + * Initialise les paramètres de la caméra par défaut + * @returns {Promise} Paramètres de caméra créés + */ + static initializeCamera = wrapDatabaseOperation(async () => { + const { defaultSettings } = config.camera; + const query = ` + INSERT INTO camera (id, interval, maintenance, active) + VALUES ($1, $2, $3, $4) + ON CONFLICT (id) DO NOTHING + RETURNING *; + `; + const result = await db.query(query, [ + defaultSettings.id, + defaultSettings.interval, + defaultSettings.maintenance ? 1 : 0, + defaultSettings.idle ? 0 : 1 + ]); + + // Si l'insertion a échoué à cause d'un conflit, récupérer l'enregistrement existant + if (result.rows.length === 0) { + return await this.getCamera(); + } + + return result.rows[0]; + }); +} + +module.exports = Camera; \ No newline at end of file diff --git a/src/models/Measurement.js b/src/models/Measurement.js new file mode 100644 index 0000000..421d264 --- /dev/null +++ b/src/models/Measurement.js @@ -0,0 +1,126 @@ +// src/models/Measurement.js +const db = require('../database/connection'); +const { wrapDatabaseOperation } = require('../utils/errorHandler'); + +/** + * Modèle pour la gestion des mesures (photos avec données) + */ +class Measurement { + /** + * Récupère toutes les mesures + * @returns {Promise} Liste de toutes les mesures + */ + static getAllMeasurements = wrapDatabaseOperation(async () => { + const query = `SELECT * FROM measurements;`; + return (await db.query(query)).rows; + }); + + /** + * Récupère une mesure par son ID + * @param {number} id - ID de la mesure + * @returns {Promise} Détails de la mesure ou null si non trouvée + */ + static getMeasurementById = wrapDatabaseOperation(async (id) => { + const query = `SELECT * FROM measurements WHERE id = $1;`; + const result = await db.query(query, [id]); + return result.rows[0] || null; + }); + + /** + * Récupère une mesure par son projet ID et son ordre ID + * @param {number} projectId - ID du projet + * @param {number} orderId - ID d'ordre de la mesure + * @returns {Promise} Détails de la mesure ou null si non trouvée + */ + static getMeasurementByProjectAndOrderId = wrapDatabaseOperation(async (projectId, orderId) => { + const query = `SELECT * FROM measurements WHERE project_id = $1 AND order_id = $2;`; + const result = await db.query(query, [projectId, orderId]); + return result.rows[0] || null; + }); + + /** + * Récupère toutes les mesures d'un projet + * @param {number} projectId - ID du projet + * @returns {Promise} Liste des mesures du projet + */ + static getMeasurementsByProjectId = wrapDatabaseOperation(async (projectId) => { + const query = `SELECT * FROM measurements WHERE project_id = $1 ORDER BY order_id;`; + return (await db.query(query, [projectId])).rows; + }); + + /** + * Crée une nouvelle mesure + * @param {number} projectId - ID du projet + * @param {string} timestamp - Horodatage de la mesure + * @param {string} path - Chemin vers l'image + * @param {number} temperature - Température mesurée + * @param {number} humidity - Humidité mesurée + * @param {number} orderId - Ordre séquentiel de la mesure + * @returns {Promise} Mesure créée + */ + static createMeasurement = wrapDatabaseOperation(async ( + projectId, timestamp, path, temperature, humidity, orderId + ) => { + const query = ` + INSERT INTO measurements (project_id, timestamp, path, temperature, humidity, order_id) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *; + `; + const result = await db.query( + query, [projectId, timestamp, path, temperature, humidity, orderId] + ); + return result.rows[0]; + }); + + /** + * Met à jour une mesure existante + * @param {number} id - ID de la mesure + * @param {Object} updates - Champs à mettre à jour + * @returns {Promise} Mesure mise à jour ou null si non trouvée + */ + static updateMeasurement = wrapDatabaseOperation(async (id, updates) => { + const fields = Object.keys(updates).map((key, index) => `${key} = $${index + 2}`).join(', '); + const values = [id, ...Object.values(updates)]; + const query = `UPDATE measurements SET ${fields} WHERE id = $1 RETURNING *;`; + const result = await db.query(query, values); + return result.rows[0] || null; + }); + + /** + * Met à jour une mesure par projet ID et ordre ID + * @param {number} projectId - ID du projet + * @param {number} orderId - ID d'ordre de la mesure + * @param {Object} updates - Champs à mettre à jour + * @returns {Promise} Mesure mise à jour ou null si non trouvée + */ + static updateMeasurementByProjectAndOrderId = wrapDatabaseOperation(async (projectId, orderId, updates) => { + const fields = Object.keys(updates).map((key, index) => `${key} = $${index + 3}`).join(', '); + const values = [projectId, orderId, ...Object.values(updates)]; + const query = `UPDATE measurements SET ${fields} WHERE project_id = $1 AND order_id = $2 RETURNING *;`; + const result = await db.query(query, values); + return result.rows[0] || null; + }); + + /** + * Supprime une mesure par son ID + * @param {number} id - ID de la mesure + * @returns {Promise} + */ + static deleteMeasurement = wrapDatabaseOperation(async (id) => { + const query = `DELETE FROM measurements WHERE id = $1;`; + await db.query(query, [id]); + }); + + /** + * Obtient le prochain ordre ID disponible pour un projet + * @param {number} projectId - ID du projet + * @returns {Promise} Prochain ordre ID + */ + static getNextOrderId = wrapDatabaseOperation(async (projectId) => { + const query = `SELECT COALESCE(MAX(order_id), 0) + 1 AS next_order_id FROM measurements WHERE project_id = $1;`; + const result = await db.query(query, [projectId]); + return result.rows[0].next_order_id; + }); +} + +module.exports = Measurement; \ No newline at end of file diff --git a/src/models/Project.js b/src/models/Project.js new file mode 100644 index 0000000..4da29ba --- /dev/null +++ b/src/models/Project.js @@ -0,0 +1,83 @@ +// src/models/Project.js +const db = require('../database/connection'); +const { wrapDatabaseOperation } = require('../utils/errorHandler'); +const config = require('../config'); + +/** + * Modèle pour la gestion des projets + */ +class Project { + /** + * Récupère tous les projets + * @returns {Promise} Liste de tous les projets + */ + static getAllProjects = wrapDatabaseOperation(async () => { + const query = `SELECT * FROM projects;`; + return (await db.query(query)).rows; + }); + + /** + * Récupère un projet par son ID + * @param {number} id - ID du projet + * @returns {Promise} Détails du projet ou null si non trouvé + */ + static getProjectById = wrapDatabaseOperation(async (id) => { + const query = `SELECT * FROM projects WHERE id = $1;`; + const result = await db.query(query, [id]); + return result.rows[0] || null; + }); + + /** + * Crée un nouveau projet + * @param {string} name - Nom du projet + * @param {string} description - Description du projet + * @param {Date} startDate - Date de début du projet + * @param {number} status - Statut du projet + * @returns {Promise} Projet créé + */ + static createProject = wrapDatabaseOperation(async (name, description, startDate, status) => { + const query = ` + INSERT INTO projects (name, description, start_date, status) + VALUES ($1, $2, $3, $4) + RETURNING *; + `; + const result = await db.query(query, [name, description, startDate, status]); + return result.rows[0]; + }); + + /** + * Met à jour un projet existant + * @param {number} id - ID du projet + * @param {Object} updates - Champs à mettre à jour + * @returns {Promise} Projet mis à jour ou null si non trouvé + */ + static updateProject = wrapDatabaseOperation(async (id, updates) => { + const fields = Object.keys(updates).map((key, index) => `${key} = $${index + 2}`).join(', '); + const values = [id, ...Object.values(updates)]; + const query = `UPDATE projects SET ${fields} WHERE id = $1 RETURNING *;`; + const result = await db.query(query, values); + return result.rows[0] || null; + }); + + /** + * Supprime un projet par son ID + * @param {number} id - ID du projet + * @returns {Promise} + */ + static deleteProject = wrapDatabaseOperation(async (id) => { + const query = `DELETE FROM projects WHERE id = $1;`; + await db.query(query, [id]); + }); + + /** + * Trouve le projet en cours de rendu (status = 1) + * @returns {Promise} Projet en cours de rendu ou null + */ + static findCurrentRenderingProject = wrapDatabaseOperation(async () => { + const query = `SELECT * FROM projects WHERE status = ${config.status.inProgress};`; + const result = await db.query(query); + return result.rows[0] || null; + }); +} + +module.exports = Project; \ No newline at end of file diff --git a/src/models/Video.js b/src/models/Video.js new file mode 100644 index 0000000..ed0dcca --- /dev/null +++ b/src/models/Video.js @@ -0,0 +1,135 @@ +// src/models/Video.js +const db = require('../database/connection'); +const { wrapDatabaseOperation } = require('../utils/errorHandler'); +const config = require('../config'); + +/** + * Modèle pour la gestion des vidéos + */ +class Video { + /** + * Récupère toutes les vidéos + * @returns {Promise} Liste de toutes les vidéos + */ + static getAllVideos = wrapDatabaseOperation(async () => { + const query = `SELECT * FROM videos;`; + return (await db.query(query)).rows; + }); + + /** + * Récupère une vidéo par son ID + * @param {number} id - ID de la vidéo + * @returns {Promise} Détails de la vidéo ou null si non trouvée + */ + static getVideoById = wrapDatabaseOperation(async (id) => { + const query = `SELECT * FROM videos WHERE id = $1;`; + const result = await db.query(query, [id]); + return result.rows[0] || null; + }); + + /** + * Récupère toutes les vidéos d'un projet + * @param {number} projectId - ID du projet + * @returns {Promise} Liste des vidéos du projet + */ + static getVideosByProjectId = wrapDatabaseOperation(async (projectId) => { + const query = `SELECT * FROM videos WHERE project_id = $1;`; + return (await db.query(query, [projectId])).rows; + }); + + /** + * Crée une nouvelle vidéo + * @param {number} projectId - ID du projet + * @param {string} measurementIds - IDs des mesures (format JSON) + * @param {string} name - Nom de la vidéo + * @param {string} resolution - Résolution de la vidéo + * @param {number} duration - Durée de la vidéo en secondes + * @param {number} status - Statut de la vidéo (défaut: 0 = en attente) + * @returns {Promise} Vidéo créée + */ + static createVideo = wrapDatabaseOperation(async ( + projectId, measurementIds, name, resolution, duration, status = config.status.waiting + ) => { + const query = ` + INSERT INTO videos (project_id, measurement_ids, name, resolution, duration, status) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *; + `; + const result = await db.query( + query, [projectId, measurementIds, name, resolution, duration, status] + ); + return result.rows[0]; + }); + + /** + * Met à jour une vidéo existante + * @param {number} id - ID de la vidéo + * @param {Object} updates - Champs à mettre à jour + * @returns {Promise} Vidéo mise à jour ou null si non trouvée + */ + static updateVideo = wrapDatabaseOperation(async (id, updates) => { + const fields = Object.keys(updates).map((key, index) => `${key} = $${index + 2}`).join(', '); + const values = [id, ...Object.values(updates)]; + const query = `UPDATE videos SET ${fields} WHERE id = $1 RETURNING *;`; + const result = await db.query(query, values); + return result.rows[0] || null; + }); + + /** + * Met à jour le chemin du fichier vidéo + * @param {number} id - ID de la vidéo + * @param {string} videoFile - Chemin du fichier vidéo + * @returns {Promise} Vidéo mise à jour ou null si non trouvée + */ + static updateVideoFilePath = wrapDatabaseOperation(async (id, videoFile) => { + const query = `UPDATE videos SET video_file = $1 WHERE id = $2 RETURNING *;`; + const result = await db.query(query, [videoFile, id]); + return result.rows[0] || null; + }); + + /** + * Supprime une vidéo par son ID + * @param {number} id - ID de la vidéo + * @returns {Promise} + */ + static deleteVideo = wrapDatabaseOperation(async (id) => { + const query = `DELETE FROM videos WHERE id = $1;`; + await db.query(query, [id]); + }); + + /** + * Récupère toutes les vidéos non terminées (statut 0, 2 ou 3) + * @returns {Promise} Liste des vidéos non terminées + */ + static getUnfinishedVideos = wrapDatabaseOperation(async () => { + const query = ` + SELECT * FROM videos + WHERE status IN ( + ${config.status.waiting}, + ${config.status.failed}, + ${config.status.inProgress} + ); + `; + return (await db.query(query)).rows; + }); + + /** + * Met à jour la progression d'une vidéo en cours de rendu + * @param {number} id - ID de la vidéo + * @param {number} progress - Pourcentage de progression + * @param {number} eta - Temps estimé restant en secondes + * @returns {Promise} Vidéo mise à jour ou null si non trouvée + */ + static updateVideoProgress = wrapDatabaseOperation(async (id, progress, eta) => { + const query = ` + UPDATE videos + SET progress = $1, eta = $2, updated_at = NOW() + WHERE id = $3 + RETURNING *; + `; + const result = await db.query(query, [progress, eta, id]); + return result.rows[0] || null; + }); +} + +module.exports = Video; \ No newline at end of file diff --git a/src/models/database.js b/src/models/database.js new file mode 100644 index 0000000..3f22120 --- /dev/null +++ b/src/models/database.js @@ -0,0 +1,144 @@ +// src/models/database.js +const db = require('../database/connection'); +const { wrapDatabaseOperation } = require('../utils/errorHandler'); +const config = require('../config'); + +/** + * Module gérant l'initialisation et la maintenance de la structure de la base de données + */ +class DatabaseManager { + /** + * Crée les tables de la base de données si elles n'existent pas déjà + */ + static async createDatabase() { + const queries = [ + `CREATE TABLE IF NOT EXISTS projects ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + start_date DATE, + status INTEGER NOT NULL CHECK (status = ANY (ARRAY [0, 1, 2, 3])) + );`, + `ALTER TABLE projects OWNER TO timelapse;`, + `CREATE TABLE IF NOT EXISTS measurements ( + id SERIAL PRIMARY KEY, + project_id INTEGER REFERENCES projects ON DELETE CASCADE, + timestamp TIMESTAMP NOT NULL, + path VARCHAR(255), + temperature DOUBLE PRECISION, + humidity DOUBLE PRECISION, + order_id INTEGER NOT NULL, + CONSTRAINT unique_project_photo_order UNIQUE (project_id, order_id) + );`, + `ALTER TABLE measurements OWNER TO timelapse;`, + `CREATE TABLE IF NOT EXISTS videos ( + id SERIAL PRIMARY KEY, + project_id INTEGER REFERENCES projects ON DELETE CASCADE, + measurement_ids TEXT NOT NULL, + video_file VARCHAR(255), + resolution VARCHAR(255), + duration INTEGER, + status INTEGER NOT NULL CHECK (status = ANY (ARRAY [0, 1, 2, 3])), + name VARCHAR(255), + progress DOUBLE PRECISION, + started_at TIMESTAMP, + updated_at TIMESTAMP, + eta DOUBLE PRECISION + );`, + `ALTER TABLE videos OWNER TO timelapse;`, + `CREATE TABLE IF NOT EXISTS camera ( + id SERIAL PRIMARY KEY, + interval INTEGER NOT NULL, + maintenance INTEGER NOT NULL, + active INTEGER DEFAULT 0 NOT NULL + );`, + `ALTER TABLE camera OWNER TO timelapse;` + ]; + + try { + for (const query of queries) { + await db.query(query); + } + console.log('[DB] Tables créées ou vérifiées avec succès.'); + } catch (err) { + console.error('[DB] Erreur lors de la création des tables:', err); + throw err; + } + } + + /** + * Vérifie l'existence des tables requises dans la base de données + */ + static async checkDatabaseExistence() { + const query = ` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name IN ('projects', 'measurements', 'videos', 'camera'); + `; + + try { + const result = await db.query(query); + const existingTables = result.rows.map(row => row.table_name); + + const requiredTables = ['projects', 'measurements', 'videos', 'camera']; + const missingTables = requiredTables.filter(table => !existingTables.includes(table)); + + if (missingTables.length > 0) { + console.error('[DB] Tables manquantes ou incorrectement construites:', missingTables); + throw new Error(`Les tables suivantes sont manquantes ou incorrectement construites: ${missingTables.join(', ')}`); + } else { + console.log('[DB] Toutes les tables requises existent et sont correctement construites.'); + } + } catch (err) { + console.error('[DB] Erreur lors de la vérification des tables:', err); + throw err; + } + } + + /** + * Supprime toutes les tables de la base de données + */ + static async deleteDatabase() { + const queries = [ + `DROP TABLE IF EXISTS videos;`, + `DROP TABLE IF EXISTS measurements;`, + `DROP TABLE IF EXISTS projects;`, + `DROP TABLE IF EXISTS camera;` + ]; + + try { + for (const query of queries) { + await db.query(query); + } + console.log('[DB] Tables supprimées avec succès.'); + } catch (err) { + console.error('[DB] Erreur lors de la suppression des tables:', err); + throw err; + } + } + + /** + * Initialise la base de données en vérifiant et en créant les tables si nécessaire + */ + static async initialize() { + try { + await this.checkDatabaseExistence(); + console.log('[DB] Structure de base de données validée'); + } catch (err) { + console.error('[DB] Vérification de la base de données échouée:', err); + try { + await this.deleteDatabase(); + await this.createDatabase(); + console.log('[DB] Base de données initialisée avec succès.'); + } catch (err) { + console.error('[DB] Erreur lors de l\'initialisation de la base de données:', err); + throw err; + } + } finally { + console.log('[DB] Processus d\'initialisation de la base de données terminé.'); + } + } +} + +module.exports = DatabaseManager; \ No newline at end of file diff --git a/src/routes/cameraRoutes.js b/src/routes/cameraRoutes.js new file mode 100644 index 0000000..8e87201 --- /dev/null +++ b/src/routes/cameraRoutes.js @@ -0,0 +1,117 @@ +// src/routes/cameraRoutes.js +const express = require('express'); +const router = express.Router(); +const CameraController = require('../controllers/cameraController'); + +/** + * @swagger + * /camera/status: + * get: + * summary: Récupère le statut actuel de la caméra + * responses: + * 200: + * description: Statut de la caméra + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: integer + * interval: + * type: integer + * nullable: true + * maintenance: + * type: integer + * active: + * type: integer + * 500: + * description: Erreur serveur + */ +router.get('/camera/status', CameraController.getCameraStatus); + +/** + * @swagger + * /procedure/start: + * post: + * summary: Démarre une procédure de capture + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * project_id: + * type: integer + * interval: + * type: integer + * nb_images: + * type: integer + * required: + * - project_id + * - interval + * - nb_images + * responses: + * 200: + * description: Procédure démarrée avec succès + * 400: + * description: Paramètres invalides ou procédure déjà en cours + * 500: + * description: Erreur serveur + */ +router.post('/procedure/start', CameraController.startProcedure); + +/** + * @swagger + * /procedure/stop: + * post: + * summary: Initie l'arrêt d'une procédure de capture + * responses: + * 200: + * description: Procédure d'arrêt initiée + * 500: + * description: Erreur serveur + */ +router.post('/procedure/stop', CameraController.stopProcedure); + +/** + * @swagger + * /camera/stop: + * post: + * summary: Confirme l'arrêt de la caméra + * responses: + * 200: + * description: Caméra arrêtée avec succès + * 500: + * description: Erreur serveur + */ +router.post('/camera/stop', CameraController.confirmStopProcedure); + +/** + * @swagger + * /camera/maintenance: + * post: + * summary: Active le mode maintenance + * responses: + * 200: + * description: Mode maintenance activé + * 500: + * description: Erreur serveur + */ +router.post('/camera/maintenance', CameraController.activateMaintenance); + +/** + * @swagger + * /camera/maintenance/deactivate: + * post: + * summary: Désactive le mode maintenance + * responses: + * 200: + * description: Mode maintenance désactivé + * 500: + * description: Erreur serveur + */ +router.post('/camera/maintenance/deactivate', CameraController.deactivateMaintenance); + +module.exports = router; \ No newline at end of file diff --git a/src/routes/imageRoutes.js b/src/routes/imageRoutes.js new file mode 100644 index 0000000..edfe75d --- /dev/null +++ b/src/routes/imageRoutes.js @@ -0,0 +1,143 @@ +// src/routes/imageRoutes.js +const express = require('express'); +const router = express.Router(); +const multer = require('multer'); +const ImageController = require('../controllers/imageController'); + +// Configuration de Multer pour le téléchargement des images +const upload = multer({ storage: multer.memoryStorage() }); + +/** + * @swagger + * /smile: + * get: + * summary: Récupère une image de test (smile) + * responses: + * 200: + * description: Image de test + * 404: + * description: Image non trouvée + * 500: + * description: Erreur serveur + */ +router.get('/smile', ImageController.getSmileImage); + +/** + * @swagger + * /images/{projectId}/{orderId}: + * get: + * summary: Récupère une image par projet ID et ordre ID + * parameters: + * - in: path + * name: projectId + * required: true + * schema: + * type: integer + * description: ID du projet + * - in: path + * name: orderId + * required: true + * schema: + * type: integer + * description: ID d'ordre + * responses: + * 200: + * description: Image + * 400: + * description: IDs invalides + * 404: + * description: Image non trouvée + * 500: + * description: Erreur serveur + */ +router.get('/images/:projectId/:orderId', ImageController.getImageByProjectAndOrderId); + +/** + * @swagger + * /images/{measurementId}: + * get: + * summary: Récupère une image par ID de mesure + * parameters: + * - in: path + * name: measurementId + * required: true + * schema: + * type: integer + * description: ID de la mesure + * responses: + * 200: + * description: Image + * 400: + * description: ID invalide + * 404: + * description: Image non trouvée + * 500: + * description: Erreur serveur + */ +router.get('/images/:measurementId', ImageController.getImageByMeasurementId); + +/** + * @swagger + * /preview/{projectId}/{orderId}: + * get: + * summary: Récupère un aperçu d'une image + * parameters: + * - in: path + * name: projectId + * required: true + * schema: + * type: integer + * description: ID du projet + * - in: path + * name: orderId + * required: true + * schema: + * type: integer + * description: ID d'ordre + * responses: + * 200: + * description: Aperçu d'image redimensionné + * 400: + * description: IDs invalides + * 404: + * description: Image non trouvée + * 500: + * description: Erreur serveur + */ +router.get('/preview/:projectId/:orderId', ImageController.getImagePreview); + +/** + * @swagger + * /camera/upload: + * post: + * summary: Télécharge une image avec données de mesure + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * properties: + * image: + * type: string + * format: binary + * projectId: + * type: integer + * timestamp: + * type: string + * format: date-time + * temperature: + * type: number + * humidity: + * type: number + * responses: + * 200: + * description: Image téléchargée avec succès + * 400: + * description: Paramètres invalides + * 500: + * description: Erreur serveur + */ +router.post('/camera/upload', upload.single('image'), ImageController.uploadImage); + +module.exports = router; \ No newline at end of file diff --git a/src/routes/index.js b/src/routes/index.js new file mode 100644 index 0000000..4fa179e --- /dev/null +++ b/src/routes/index.js @@ -0,0 +1,34 @@ +// src/routes/index.js +const express = require('express'); +const router = express.Router(); +const cors = require('cors'); +const config = require('../config'); + +// Importe toutes les routes modulaires +const projectRoutes = require('./projectRoutes'); +const measurementRoutes = require('./measurementRoutes'); +const videoRoutes = require('./videoRoutes'); +const imageRoutes = require('./imageRoutes'); +const cameraRoutes = require('./cameraRoutes'); + +// Configuration CORS +router.use(cors({ + origin: config.server.cors.origins, + methods: config.server.cors.methods, + allowedHeaders: config.server.cors.allowedHeaders, + credentials: config.server.cors.credentials +})); + +// Enregistre toutes les routes +router.use(projectRoutes); +router.use(measurementRoutes); +router.use(videoRoutes); +router.use(imageRoutes); +router.use(cameraRoutes); + +// Route de test/santé de l'API +router.get('/health', (req, res) => { + res.json({ status: 'ok', uptime: process.uptime() }); +}); + +module.exports = router; \ No newline at end of file diff --git a/src/routes/measurementRoutes.js b/src/routes/measurementRoutes.js new file mode 100644 index 0000000..4a3fa53 --- /dev/null +++ b/src/routes/measurementRoutes.js @@ -0,0 +1,99 @@ +// src/routes/measurementRoutes.js +const express = require('express'); +const router = express.Router(); +const MeasurementController = require('../controllers/measurementController'); + +/** + * @swagger + * /measurements: + * get: + * summary: Récupère toutes les mesures + * responses: + * 200: + * description: Liste de toutes les mesures + * 404: + * description: Aucune mesure trouvée + * 500: + * description: Erreur serveur + */ +router.get('/measurements', MeasurementController.getAllMeasurements); + +/** + * @swagger + * /measurements/{id}: + * get: + * summary: Récupère une mesure par ID + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: ID de la mesure + * responses: + * 200: + * description: Détails de la mesure + * 400: + * description: ID de mesure invalide + * 404: + * description: Mesure non trouvée + * 500: + * description: Erreur serveur + */ +router.get('/measurements/:id', MeasurementController.getMeasurementById); + +/** + * @swagger + * /measurements/{projectId}/{orderId}: + * get: + * summary: Récupère une mesure par projet ID et ordre ID + * parameters: + * - in: path + * name: projectId + * required: true + * schema: + * type: integer + * description: ID du projet + * - in: path + * name: orderId + * required: true + * schema: + * type: integer + * description: ID d'ordre + * responses: + * 200: + * description: Détails de la mesure + * 400: + * description: IDs invalides + * 404: + * description: Mesure non trouvée + * 500: + * description: Erreur serveur + */ +router.get('/measurements/:projectId/:orderId', MeasurementController.getMeasurementByProjectAndOrderId); + +/** + * @swagger + * /measurements/{id}: + * delete: + * summary: Supprime une mesure + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: ID de la mesure + * responses: + * 200: + * description: Mesure supprimée avec succès + * 400: + * description: ID de mesure invalide + * 404: + * description: Mesure non trouvée + * 500: + * description: Erreur serveur + */ +router.delete('/measurements/:id', MeasurementController.deleteMeasurement); + +module.exports = router; \ No newline at end of file diff --git a/src/routes/projectRoutes.js b/src/routes/projectRoutes.js new file mode 100644 index 0000000..58d8faa --- /dev/null +++ b/src/routes/projectRoutes.js @@ -0,0 +1,142 @@ +// src/routes/projectRoutes.js +const express = require('express'); +const router = express.Router(); +const ProjectController = require('../controllers/projectController'); + +/** + * @swagger + * /projects: + * get: + * summary: Récupère tous les projets + * responses: + * 200: + * description: Liste de tous les projets + * 500: + * description: Erreur serveur + */ +router.get('/projects', ProjectController.getAllProjects); + +/** + * @swagger + * /projects/{id}: + * get: + * summary: Récupère un projet par ID + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: ID du projet + * responses: + * 200: + * description: Détails du projet + * 400: + * description: ID de projet invalide + * 404: + * description: Projet non trouvé + * 500: + * description: Erreur serveur + */ +router.get('/projects/:id', ProjectController.getProjectById); + +/** + * @swagger + * /projects/{id}/videos: + * get: + * summary: Récupère les vidéos d'un projet + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: ID du projet + * responses: + * 200: + * description: Liste des vidéos du projet + * 400: + * description: ID de projet invalide + * 404: + * description: Aucune vidéo trouvée + * 500: + * description: Erreur serveur + */ +router.get('/projects/:id/videos', ProjectController.getProjectVideos); + +/** + * @swagger + * /projects/{id}/measurements: + * get: + * summary: Récupère les mesures d'un projet + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: ID du projet + * responses: + * 200: + * description: Liste des mesures du projet + * 400: + * description: ID de projet invalide + * 404: + * description: Aucune mesure trouvée + * 500: + * description: Erreur serveur + */ +router.get('/projects/:id/measurements', ProjectController.getProjectMeasurements); + +/** + * @swagger + * /projects: + * post: + * summary: Crée un nouveau projet + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * description: + * type: string + * required: + * - name + * - description + * responses: + * 201: + * description: Projet créé avec succès + * 400: + * description: Paramètres invalides + * 500: + * description: Erreur serveur + */ +router.post('/projects', ProjectController.createProject); + +/** + * @swagger + * /projects/{id}: + * delete: + * summary: Supprime un projet + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: ID du projet + * responses: + * 200: + * description: Projet supprimé avec succès + * 400: + * description: ID de projet invalide + * 500: + * description: Erreur serveur + */ +router.delete('/projects/:id', ProjectController.deleteProject); + +module.exports = router; \ No newline at end of file diff --git a/src/routes/videoRoutes.js b/src/routes/videoRoutes.js new file mode 100644 index 0000000..85ebb44 --- /dev/null +++ b/src/routes/videoRoutes.js @@ -0,0 +1,169 @@ +// src/routes/videoRoutes.js +const express = require('express'); +const router = express.Router(); +const VideoController = require('../controllers/videoController'); + +/** + * @swagger + * /videos: + * get: + * summary: Récupère toutes les vidéos + * responses: + * 200: + * description: Liste de toutes les vidéos + * 500: + * description: Erreur serveur + */ +router.get('/videos', VideoController.getAllVideos); + +/** + * @swagger + * /videos/{id}: + * get: + * summary: Récupère une vidéo par ID + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: ID de la vidéo + * responses: + * 200: + * description: Détails de la vidéo + * 400: + * description: ID de vidéo invalide + * 404: + * description: Vidéo non trouvée + * 500: + * description: Erreur serveur + */ +router.get('/videos/:id', VideoController.getVideoById); + +/** + * @swagger + * /videos: + * post: + * summary: Crée une nouvelle vidéo + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * project_id: + * type: integer + * measurement_ids: + * type: string + * description: JSON array of measurement IDs + * name: + * type: string + * resolution: + * type: string + * example: "1920x1080" + * duration: + * type: integer + * description: Duration in seconds + * required: + * - project_id + * - measurement_ids + * - name + * - resolution + * - duration + * responses: + * 200: + * description: Vidéo créée avec succès + * 400: + * description: Paramètres invalides + * 500: + * description: Erreur serveur + */ +router.post('/videos', VideoController.createVideo); + +/** + * @swagger + * /videos/{id}: + * delete: + * summary: Supprime une vidéo + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * description: ID de la vidéo + * responses: + * 200: + * description: Vidéo supprimée avec succès + * 400: + * description: ID de vidéo invalide + * 404: + * description: Vidéo non trouvée + * 500: + * description: Erreur serveur + */ +router.delete('/videos/:id', VideoController.deleteVideo); + +/** + * @swagger + * /videos/file/{video_id}: + * get: + * summary: Récupère le fichier vidéo + * parameters: + * - in: path + * name: video_id + * required: true + * schema: + * type: integer + * description: ID de la vidéo + * responses: + * 200: + * description: Fichier vidéo (stream) + * 206: + * description: Fichier vidéo partiel (range request) + * 400: + * description: Vidéo pas encore produite + * 404: + * description: Vidéo non trouvée + * 500: + * description: Erreur serveur + */ +router.get('/videos/file/:video_id', VideoController.getVideoFile); + +/** + * @swagger + * /videos/progress/{video_id}: + * get: + * summary: Récupère la progression du rendu d'une vidéo + * parameters: + * - in: path + * name: video_id + * required: true + * schema: + * type: integer + * description: ID de la vidéo + * responses: + * 200: + * description: Informations de progression + * content: + * application/json: + * schema: + * type: object + * properties: + * progress: + * type: number + * elapsed: + * type: number + * eta: + * type: number + * status: + * type: string + * 404: + * description: Vidéo non trouvée + * 500: + * description: Erreur serveur + */ +router.get('/videos/progress/:video_id', VideoController.getVideoProgress); + +module.exports = router; \ No newline at end of file diff --git a/src/services/storageService.js b/src/services/storageService.js new file mode 100644 index 0000000..5900cc8 --- /dev/null +++ b/src/services/storageService.js @@ -0,0 +1,209 @@ +// src/services/storageService.js +const fs = require('fs').promises; +const path = require('path'); +const { Buffer } = require('buffer'); +const config = require('../config'); + +/** + * Service de gestion du stockage des fichiers + */ +class StorageService { + /** + * Crée un dossier s'il n'existe pas déjà + * @param {string} dirPath - Chemin du dossier à créer + * @returns {Promise} Chemin du dossier créé + */ + static async createDirectory(dirPath) { + try { + await fs.access(dirPath); + } catch (error) { + if (error.code === 'ENOENT') { + await fs.mkdir(dirPath, { recursive: true }); + } else { + throw error; + } + } + return dirPath; + } + + /** + * Supprime un dossier et son contenu + * @param {string} dirPath - Chemin du dossier à supprimer + */ + static async deleteDirectory(dirPath) { + try { + await fs.access(dirPath); + await fs.rm(dirPath, { recursive: true, force: true }); + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + } + } + + /** + * Cherche toutes les images dans un dossier + * @param {string} dirPath - Dossier à scanner + * @returns {Promise>} Liste des chemins d'images trouvées + */ + static async scanImages(dirPath = 'storage') { + const basePath = path.join(config.paths.storage, dirPath); + let results = []; + + try { + await fs.access(basePath); + } catch (error) { + if (error.code === 'ENOENT') { + await fs.mkdir(basePath, { recursive: true }); + } else { + throw error; + } + } + + async function scanDirectory(directory) { + const files = await fs.readdir(directory); + for (const file of files) { + const filePath = path.join(directory, file); + const stat = await fs.stat(filePath); + if (stat.isDirectory()) { + await scanDirectory(filePath); + } else if (file.endsWith('.jpg')) { + results.push(filePath); + } + } + } + + await scanDirectory(basePath); + return results; + } + + /** + * Enregistre un contenu dans un fichier + * @param {string} filePath - Chemin du fichier + * @param {Buffer} content - Contenu à enregistrer + */ + static async saveFile(filePath, content) { + const dirPath = path.dirname(filePath); + await this.createDirectory(dirPath); + if (Buffer.isBuffer(content)) { + await fs.writeFile(filePath, content); + } else { + throw new Error('Le contenu doit être un buffer'); + } + } + + /** + * Récupère le contenu d'un fichier + * @param {string} filePath - Chemin du fichier + * @returns {Promise} Contenu du fichier + */ + static async getFile(filePath) { + return await fs.readFile(filePath); + } + + /** + * Supprime un fichier + * @param {string} filePath - Chemin du fichier à supprimer + * @returns {Promise} Message de confirmation + */ + static async deleteFile(filePath) { + try { + await fs.access(filePath); + await fs.rm(filePath); + return `Fichier ${filePath} supprimé avec succès.`; + } catch (error) { + if (error.code === 'ENOENT') { + return `Fichier ${filePath} inexistant.`; + } else { + throw error; + } + } + } + + /** + * Gestionnaire pour les opérations de projet + */ + static project = { + /** + * Crée le répertoire d'un projet + * @param {number} projectId - ID du projet + */ + createProjectDirectory: async function(projectId) { + const projectPath = path.join(config.paths.storage, `${projectId}`); + await StorageService.createDirectory(projectPath); + await StorageService.createDirectory(path.join(projectPath, 'images')); + await StorageService.createDirectory(path.join(projectPath, 'videos')); + console.log(`[STORAGE] Répertoire créé : ${projectPath}`); + }, + + /** + * Supprime le répertoire d'un projet et son contenu + * @param {number} projectId - ID du projet + */ + deleteProjectDirectory: async function(projectId) { + const projectPath = path.join(config.paths.storage, `${projectId}`); + await StorageService.deleteDirectory(projectPath); + console.log(`[STORAGE] Répertoire supprimé : ${projectPath}`); + } + }; + + /** + * Gestionnaire pour les opérations de mesures (images) + */ + static measurement = { + /** + * Récupère l'image d'une mesure + * @param {number} projectId - ID du projet + * @param {number} orderId - ID d'ordre de la mesure + * @returns {Promise} Contenu de l'image + */ + getMeasurementImage: async function(projectId, orderId) { + const imagePath = path.join(config.paths.storage, `${projectId}`, 'images', `${orderId}.jpg`); + console.log(`[STORAGE] Récupération de l'image : ${imagePath}`); + return await StorageService.getFile(imagePath); + }, + + /** + * Enregistre l'image d'une mesure + * @param {Object} image - Objet image avec buffer + * @param {number} projectId - ID du projet + * @param {number} orderId - ID d'ordre de la mesure + * @returns {Promise} Chemin de l'image enregistrée + */ + uploadMeasurementImage: async function(image, projectId, orderId) { + const imagePath = path.join(config.paths.storage, `${projectId}`, 'images', `${orderId}.jpg`); + console.log(`[STORAGE] Enregistrement de l'image : ${imagePath}`); + await StorageService.saveFile(imagePath, image.buffer); + return imagePath; + } + }; + + /** + * Gestionnaire pour les opérations de vidéos + */ + static video = { + /** + * Récupère une vidéo + * @param {number} projectId - ID du projet + * @param {number} videoId - ID de la vidéo + * @returns {Promise} Contenu de la vidéo + */ + getVideo: async function(projectId, videoId) { + const videoPath = path.join(config.paths.storage, `${projectId}`, 'videos', `${videoId}.mp4`); + console.log(`[STORAGE] Récupération de la vidéo : ${videoPath}`); + return await StorageService.getFile(videoPath); + }, + + /** + * Supprime une vidéo + * @param {string} videoPath - Chemin de la vidéo à supprimer + * @returns {Promise} Message de confirmation + */ + deleteVideo: async function(videoPath) { + console.log(`[STORAGE] Suppression de la vidéo : ${videoPath}`); + return await StorageService.deleteFile(videoPath); + } + }; +} + +module.exports = StorageService; \ No newline at end of file diff --git a/src/services/videoService.js b/src/services/videoService.js new file mode 100644 index 0000000..2d92378 --- /dev/null +++ b/src/services/videoService.js @@ -0,0 +1,218 @@ +// src/services/videoService.js +const fs = require('fs'); +const path = require('path'); +const { spawn } = require('child_process'); +const config = require('../config'); +const Video = require('../models/Video'); + +/** + * Service de gestion des opérations vidéo + */ +class VideoService { + /** + * Crée une vidéo à partir d'une liste d'images + * @param {number} projectId - ID du projet + * @param {Array} pathList - Liste des chemins d'images + * @param {number} duration - Durée souhaitée en secondes + * @param {number} videoId - ID de la vidéo + * @param {number} resWidth - Largeur de la résolution + * @param {number} resHeight - Hauteur de la résolution + * @returns {Promise} Chemin du fichier vidéo créé + */ + static async createVideoFromImages(projectId, pathList, duration, videoId, resWidth, resHeight) { + const tempFile = path.join('temp.txt'); + let ffmpegProcess; + let cleanupDone = false; + + try { + // Configuration des chemins + const workdir = path.join(config.paths.storage, projectId.toString()); + if (!fs.existsSync(workdir)) { + fs.mkdirSync(workdir, { recursive: true }); + } + + // Vérifie que des images ont été fournies + if (!Array.isArray(pathList) || pathList.length === 0) { + throw new Error('Liste d\'images vide ou invalide'); + } + + // Tri des images par ordre numérique + const sortedImages = pathList.sort((a, b) => { + const numA = parseInt(path.basename(a).match(/\d+/)[0], 10); + const numB = parseInt(path.basename(b).match(/\d+/)[0], 10); + return numA - numB; + }); + + // Création du fichier temporaire pour FFmpeg + fs.writeFileSync(tempFile, sortedImages.map(image => `file '${image}'`).join('\n')); + + // Calcul des paramètres vidéo + const totalFrames = sortedImages.length; + const frameRate = Math.ceil(totalFrames / parseInt(duration)); + const timestamp = Date.now(); + const firstImageId = path.basename(sortedImages[0]).match(/\d+/)[0]; + const lastImageId = path.basename(sortedImages[sortedImages.length - 1]).match(/\d+/)[0]; + const outputVideo = path.join( + workdir, + `${projectId}_${firstImageId}_${lastImageId}-${timestamp}.mp4` + ); + + // Mise à jour initiale du statut vidéo + await Video.updateVideo(videoId, { + status: config.status.inProgress, + progress: 0, + started_at: new Date(), + updated_at: new Date(), + eta: null + }); + + // Configuration du scaling vidéo + const scale = resWidth && resHeight ? `scale=${resWidth}:${resHeight}` : 'scale=854:480'; + + // Configuration des arguments FFmpeg + const ffmpegArgs = [ + '-y', // Écrase les fichiers existants sans demander + '-r', frameRate.toString(), // Framerate + '-f', 'concat', // Format de concaténation + '-safe', '0', // Autorise les chemins non sécurisés + '-i', tempFile, // Fichier d'entrée + '-vsync', 'vfr', // Synchronisation vidéo + '-pix_fmt', 'yuv420p', // Format de pixels + '-vf', scale, // Filter vidéo pour le scaling + '-b:v', '1500k', // Bitrate vidéo + outputVideo // Fichier de sortie + ]; + + // Lancement du processus FFmpeg + ffmpegProcess = spawn('ffmpeg', ffmpegArgs, { + stdio: ['ignore', 'ignore', 'pipe'] + }); + + let lastUpdate = 0; + const startTime = Date.now(); + + // Capture de la progression du rendu + ffmpegProcess.stderr.on('data', (data) => { + const output = data.toString(); + const frameMatch = output.match(/frame=\s*(\d+)/); + + if (frameMatch) { + const currentFrame = parseInt(frameMatch[1], 10); + const progress = Math.min((currentFrame / totalFrames) * 100, 99.99); + const now = Date.now(); + + // Calcul du temps restant estimé + const elapsedSeconds = (now - startTime) / 1000; + const eta = elapsedSeconds / (currentFrame / totalFrames) - elapsedSeconds; + + // Mise à jour périodique (max toutes les 500ms) + if (now - lastUpdate > 500) { + Video.updateVideo(videoId, { + progress: progress, + eta: Math.round(eta), + updated_at: new Date() + }); + + console.log(`[VIDEO] Progression: ${progress.toFixed(2)}%, ETA: ${eta.toFixed(0)}s`); + lastUpdate = now; + } + } + }); + + // Attente de la fin du rendu vidéo + await new Promise((resolve, reject) => { + ffmpegProcess.on('close', async (code) => { + if (code === 0) { + try { + // Mise à jour finale de la vidéo + await Video.updateVideo(videoId, { + status: config.status.completed, + progress: 100, + eta: 0, + video_file: outputVideo, + updated_at: new Date() + }); + resolve(); + } catch (e) { + reject(e); + } + } else { + reject(new Error(`FFmpeg s'est terminé avec le code ${code}`)); + } + }); + + ffmpegProcess.on('error', reject); + }); + + return outputVideo; + + } catch (error) { + // Gestion des erreurs + console.error('[VIDEO] Erreur lors de la création de la vidéo:', error); + + try { + // Mise à jour du statut vidéo en cas d'erreur + await Video.updateVideo(videoId, { + status: config.status.failed, + progress: 0, + eta: null, + updated_at: new Date() + }); + } catch (dbError) { + console.error('[VIDEO] Erreur lors de la mise à jour de la base de données:', dbError); + } + + throw error; + + } finally { + // Nettoyage des ressources + if (!cleanupDone) { + if (tempFile && fs.existsSync(tempFile)) { + fs.unlinkSync(tempFile); + } + if (ffmpegProcess) { + ffmpegProcess.kill(); + } + cleanupDone = true; + } + } + } + + /** + * Récupère les informations de progression d'une vidéo + * @param {number} videoId - ID de la vidéo + * @returns {Promise} Informations de progression + */ + static async getVideoProgress(videoId) { + const video = await Video.getVideoById(videoId); + + if (!video) { + throw new Error('Vidéo non trouvée'); + } + + return { + progress: video.progress, + elapsed: video.started_at ? Math.floor((new Date() - new Date(video.started_at)) / 1000) : 0, + eta: video.eta, + status: video.status + }; + } + + /** + * Convertit un code de statut en libellé + * @param {number} status - Code de statut + * @returns {string} Libellé du statut + */ + static getStatusLabel(status) { + const statusMap = { + [config.status.waiting]: 'En attente', + [config.status.completed]: 'Terminé', + [config.status.failed]: 'Échec', + [config.status.inProgress]: 'En cours' + }; + + return statusMap[status] || 'Inconnu'; + } +} + +module.exports = VideoService; \ No newline at end of file diff --git a/src/utils/errorHandler.js b/src/utils/errorHandler.js new file mode 100644 index 0000000..9e96568 --- /dev/null +++ b/src/utils/errorHandler.js @@ -0,0 +1,58 @@ +// src/utils/errorHandler.js + +/** + * Envoie une réponse d'erreur standardisée + * @param {string} message - Message d'erreur à afficher + * @param {Object} res - Objet Response d'Express + * @param {Error|null} error - Objet d'erreur original (facultatif) + * @param {number} statusCode - Code HTTP d'erreur (défaut: 500) + */ +function sendError(message, res, error = null, statusCode = 500) { + console.error(`[ERROR] ${message}`, error); + + res.status(statusCode).json({ + error: { + message, + statusCode, + details: error ? (error.message || String(error)) : null, + timestamp: new Date().toISOString() + } + }); +} + +/** + * Wrapper pour les contrôleurs qui gère automatiquement les erreurs + * @param {Function} controller - Fonction de contrôleur à exécuter + * @returns {Function} - Middleware Express qui gère les erreurs + */ +function asyncHandler(controller) { + return async (req, res, next) => { + try { + await controller(req, res, next); + } catch (error) { + sendError('Une erreur est survenue lors du traitement de la requête', res, error); + } + }; +} + +/** + * Crée un wrapper pour les opérations de base de données qui gère les erreurs + * @param {Function} operation - Fonction à exécuter + * @returns {Function} - Fonction qui execute l'opération et gère les erreurs + */ +function wrapDatabaseOperation(operation) { + return async (...args) => { + try { + return await operation(...args); + } catch (err) { + console.error(`[DB ERROR] Erreur lors de l'opération ${operation.name || 'database'}:`, err); + throw err; + } + }; +} + +module.exports = { + sendError, + asyncHandler, + wrapDatabaseOperation +}; \ No newline at end of file diff --git a/src/video/videoManager.js b/src/video/videoManager.js index f3168e7..e943e51 100644 --- a/src/video/videoManager.js +++ b/src/video/videoManager.js @@ -1,174 +1,13 @@ -const fs = require('fs'); -const path = require('path'); -const { spawn } = require('child_process'); +/** + * Ce fichier est conservé pour la rétrocompatibilité mais redirige vers le nouveau service vidéo. + * Il sera progressivement supprimé lorsque toutes les références auront été mises à jour. + */ -const database_manager = require('../database/database_manager'); - -const PROJECTS_DIR = path.join('.'); +const VideoService = require('../services/videoService'); +// Fonction de pont pour maintenir la compatibilité avec l'ancien code async function createVideoWithList(projectId, pathList, duration, videoId, res_width, res_height) { - const tempFile = path.join('temp.txt'); - let ffmpegProcess; - let cleanupDone = false; - - try { - // Configuration des chemins - const workdir = path.join(PROJECTS_DIR, 'storage', projectId.toString()); - if (!fs.existsSync(workdir)) { - fs.mkdirSync(workdir, { recursive: true }); - } - - // Tri des images - const sortedImages = pathList.sort((a, b) => { - const numA = parseInt(path.basename(a).match(/\d+/)[0], 10); - const numB = parseInt(path.basename(b).match(/\d+/)[0], 10); - return numA - numB; - }); - - // Création du fichier temporaire - fs.writeFileSync(tempFile, sortedImages.map(image => `file '${image}'`).join('\n')); - - // Calcul des paramètres vidéo - const totalFrames = sortedImages.length; - const frameRate = Math.ceil(totalFrames / parseInt(duration)); - const timestamp = Date.now(); - const firstImageId = path.basename(sortedImages[0]).match(/\d+/)[0]; - const lastImageId = path.basename(sortedImages[sortedImages.length - 1]).match(/\d+/)[0]; - const outputVideo = path.join( - workdir, - `${projectId}_${firstImageId}_${lastImageId}-${timestamp}.mp4` - ); - - // Mise à jour initiale de la base de données - let edit_video = { - status: 3, - progress: 0, - started_at: new Date(), - updated_at: new Date(), - eta: null - } - await database_manager.video.edit_video_by_id(videoId, edit_video) - const scale = res_width && res_height ? `scale=${res_width}:${res_height}` : 'scale=854:480'; // Redimensionne la vidéo en 480p par défaut - - // Configuration de FFmpeg - const ffmpegArgs = [ - '-y', - '-r', frameRate.toString(), - '-f', 'concat', - '-safe', '0', - '-i', tempFile, - '-vsync', 'vfr', - '-pix_fmt', 'yuv420p', - '-vf', scale, - '-b:v', '1500k', // Force un bitrate vidéo de 1500 kbps (ajuste si nécessaire) - outputVideo - ]; - - ffmpegProcess = spawn('ffmpeg', ffmpegArgs, { - stdio: ['ignore', 'ignore', 'pipe'] - }); - - let lastUpdate = 0; - const startTime = Date.now(); - - // Écoute de la sortie d'erreur pour la progression - ffmpegProcess.stderr.on('data', (data) => { - const output = data.toString(); - const frameMatch = output.match(/frame=\s*(\d+)/); - - if (frameMatch) { - const currentFrame = parseInt(frameMatch[1], 10); - const progress = Math.min((currentFrame / totalFrames) * 100, 99.99); - const now = Date.now(); - - // Calcul de l'ETA - const elapsedSeconds = (now - startTime) / 1000; - const eta = elapsedSeconds / (currentFrame / totalFrames) - elapsedSeconds; - - // Mise à jour max toutes les 500ms - if (now - lastUpdate > 500) { - let update_video = { - progress: progress, - eta: Math.round(eta), - updated_at: new Date() - } - database_manager.video.edit_video_by_id(videoId, update_video) - - console.log('Progress:', progress.toFixed(2), '%, ETA:', eta.toFixed(0), 's'); - lastUpdate = now; - } - } - }); - - // Attente de la fin du processus - await new Promise((resolve, reject) => { - ffmpegProcess.on('close', async (code) => { - if (code === 0) { - try { - // Mise à jour finale - // await db.query(` - // UPDATE public.videos - // SET - // status = 1, - // progress = 100, - // eta = 0, - // video_file = $1, - // updated_at = NOW() - // WHERE id = $2 - // `, [outputVideo, videoId]); - let latest_update = { - status: 1, - progress: 100, - eta: 0, - video_file: outputVideo, - updated_at: new Date() - } - await database_manager.video.edit_video_by_id(videoId, latest_update) - resolve(); - } catch (e) { - reject(e); - } - } else { - reject(new Error(`FFmpeg process exited with code ${code}`)); - } - }); - - ffmpegProcess.on('error', reject); - }); - - return outputVideo; - - } catch (error) { - // Gestion des erreurs - console.error('Error in video creation:', error); - - try { - // Mise à jour de la base de données en cas d'erreur - let error_video = { - status: 0, - progress: 0, - eta: null, - updated_at: new Date() - } - await database_manager.video.edit_video_by_id(videoId, error_video) - } catch (dbError) { - console.error('Database update error:', dbError); - } - - throw error; - - } finally { - // Nettoyage - if (!cleanupDone) { - if (tempFile && fs.existsSync(tempFile)) { - fs.unlinkSync(tempFile); - } - if (ffmpegProcess) { - ffmpegProcess.kill(); - } - cleanupDone = true; - } - } + return await VideoService.createVideoFromImages(projectId, pathList, duration, videoId, res_width, res_height); } module.exports = { createVideoWithList };