feat(database): Implement DatabaseManager for managing database structure and initialization
All checks were successful
SSH Backend Deploy / ssh-deploy (push) Successful in 1m51s

feat(routes): Add camera, image, measurement, project, and video routes with Swagger documentation

feat(services): Create storageService and videoService for file management and video processing

fix(errorHandler): Enhance error handling with standardized responses and database operation wrappers
This commit is contained in:
2025-04-27 01:02:33 +02:00
parent 792bdca965
commit 4513af3aa0
30 changed files with 3006 additions and 763 deletions

86
README.md Normal file
View File

@@ -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`.

25
api.js
View File

@@ -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;

View File

@@ -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;

77
cleanup.js Normal file
View File

@@ -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
`);
}

40
db.js
View File

@@ -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;
module.exports = db;

View File

@@ -2,46 +2,30 @@
const express = require('express');
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`);
});

70
src/config/index.js Normal file
View File

@@ -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
}
}
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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
};
module.exports = storage_manager;

View File

@@ -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;

View File

@@ -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;

72
src/models/Camera.js Normal file
View File

@@ -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<Object|null>} 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<Object|null>} 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<void>}
*/
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<Object>} 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;

126
src/models/Measurement.js Normal file
View File

@@ -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<Array>} 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<Object|null>} 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<Object|null>} 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<Array>} 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<Object>} 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<Object|null>} 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<Object|null>} 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<void>}
*/
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<number>} 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;

83
src/models/Project.js Normal file
View File

@@ -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<Array>} 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<Object|null>} 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<Object>} 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<Object|null>} 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<void>}
*/
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<Object|null>} 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;

135
src/models/Video.js Normal file
View File

@@ -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<Array>} 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<Object|null>} 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<Array>} 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<Object>} 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<Object|null>} 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<Object|null>} 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<void>}
*/
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<Array>} 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<Object|null>} 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;

144
src/models/database.js Normal file
View File

@@ -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;

117
src/routes/cameraRoutes.js Normal file
View File

@@ -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;

143
src/routes/imageRoutes.js Normal file
View File

@@ -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;

34
src/routes/index.js Normal file
View File

@@ -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;

View File

@@ -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;

142
src/routes/projectRoutes.js Normal file
View File

@@ -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;

169
src/routes/videoRoutes.js Normal file
View File

@@ -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;

View File

@@ -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<string>} 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<Array<string>>} 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<Buffer>} Contenu du fichier
*/
static async getFile(filePath) {
return await fs.readFile(filePath);
}
/**
* Supprime un fichier
* @param {string} filePath - Chemin du fichier à supprimer
* @returns {Promise<string>} 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<Buffer>} 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<string>} 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<Buffer>} 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<string>} Message de confirmation
*/
deleteVideo: async function(videoPath) {
console.log(`[STORAGE] Suppression de la vidéo : ${videoPath}`);
return await StorageService.deleteFile(videoPath);
}
};
}
module.exports = StorageService;

View File

@@ -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<string>} 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<string>} 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<Object>} 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;

58
src/utils/errorHandler.js Normal file
View File

@@ -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
};

View File

@@ -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 };