diff --git a/backend/destinations.py b/backend/destinations.py index 6c8cf38..008d547 100644 --- a/backend/destinations.py +++ b/backend/destinations.py @@ -1,7 +1,10 @@ import ftplib import logging import shutil +import threading from pathlib import Path +from urllib.request import Request, urlopen +from urllib.error import URLError from backend.config import charger_config, mettre_a_jour_config @@ -26,6 +29,15 @@ def distribuer_photo(chemin_photo: Path, imprimee: bool = False): if dest.get("ftp", False): envoyer_ftp(chemin_photo, dest) + # Galerie live (booth) + booth = config.get("booth", {}) + if booth.get("actif", False): + threading.Thread( + target=envoyer_booth, + args=(chemin_photo, booth), + daemon=True, + ).start() + # Incrementer le compteur compteur = config.get("compteur", {}) if compteur.get("actif", False): @@ -83,6 +95,73 @@ def envoyer_ftp(chemin_photo: Path, config_dest: dict): return False +def envoyer_booth(chemin_photo: Path, config_booth: dict): + """Envoie une photo vers la galerie live booth.""" + url = config_booth.get("url", "").rstrip("/") + api_key = config_booth.get("api_key", "") + event_id = config_booth.get("event_id", "default") + + if not url: + log.warning("Booth non configure (URL manquante)") + return False + + try: + import mimetypes + boundary = "----BoothUpload" + filename = chemin_photo.name + + with open(chemin_photo, "rb") as f: + file_data = f.read() + + body = ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="photo"; filename="{filename}"\r\n' + f"Content-Type: image/jpeg\r\n\r\n" + ).encode() + file_data + f"\r\n--{boundary}--\r\n".encode() + + req = Request( + f"{url}/api/{event_id}/upload", + data=body, + method="POST", + ) + req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}") + req.add_header("X-Api-Key", api_key) + + with urlopen(req, timeout=10) as resp: + if resp.status == 200: + log.info(f"Photo envoyee au booth : {filename}") + return True + else: + log.error(f"Booth erreur HTTP {resp.status}") + return False + except (URLError, OSError) as e: + log.error(f"Erreur envoi booth : {e}") + return False + + +def recuperer_booth_password() -> dict: + """Recupere le mot de passe et l'info de la session booth.""" + config = charger_config() + booth = config.get("booth", {}) + url = booth.get("url", "").rstrip("/") + api_key = booth.get("api_key", "") + event_id = booth.get("event_id", "default") + + if not url: + return {"password": None} + + try: + req = Request(f"{url}/api/{event_id}/info") + req.add_header("X-Api-Key", api_key) + with urlopen(req, timeout=5) as resp: + import json + data = json.loads(resp.read()) + return data + except (URLError, OSError) as e: + log.error(f"Erreur recuperation booth info : {e}") + return {"password": None} + + def detecter_usb() -> list[str]: """Detecte les cles USB montees.""" chemins_possibles = [Path("/media"), Path("/mnt")] diff --git a/backend/main.py b/backend/main.py index 0aa4327..2817350 100644 --- a/backend/main.py +++ b/backend/main.py @@ -17,7 +17,7 @@ from backend.camera import camera from backend.gallery import lister_photos, compter_photos, supprimer_photo, vider_galerie from backend.effects import appliquer_filtre, appliquer_overlay, chroma_key, lister_overlays, lister_fonds, FILTRES from backend.collage import creer_strip, creer_collage, creer_impression_strip -from backend.destinations import distribuer_photo, detecter_usb, compteur_restant, reset_compteur +from backend.destinations import distribuer_photo, detecter_usb, compteur_restant, reset_compteur, recuperer_booth_password from backend.printer import lister_imprimantes, imprimer from backend.mailer import envoyer_photo from backend.qrcode_gen import generer_qr, qr_galerie @@ -243,6 +243,13 @@ async def api_imprimer(donnees: dict): return {"succes": ok} +# --- API Booth (galerie live) --- + +@app.get("/api/booth/info") +async def api_booth_info(): + return recuperer_booth_password() + + # --- API Email --- @app.post("/api/email") @@ -262,6 +269,21 @@ async def api_email(donnees: dict): # --- API QR Code --- +@app.get("/api/qr") +async def api_qr(url: str = ""): + """Genere un QR code pour une URL arbitraire.""" + if not url: + return JSONResponse({"erreur": "URL requise"}, status_code=400) + import qrcode + import io + qr = qrcode.make(url, box_size=6, border=2) + buf = io.BytesIO() + qr.save(buf, format="PNG") + buf.seek(0) + from fastapi.responses import StreamingResponse + return StreamingResponse(buf, media_type="image/png") + + @app.get("/api/qr/galerie") async def api_qr_galerie(): chemin = qr_galerie() diff --git a/booth/package.json b/booth/package.json new file mode 100644 index 0000000..62845b6 --- /dev/null +++ b/booth/package.json @@ -0,0 +1,16 @@ +{ + "name": "booth-gallery", + "version": "1.0.0", + "description": "Galerie photo live pour photobooth", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js" + }, + "dependencies": { + "express": "^4.21.0", + "ws": "^8.18.0", + "multer": "^1.4.5-lts.1", + "sharp": "^0.33.5" + } +} diff --git a/booth/public/index.html b/booth/public/index.html new file mode 100644 index 0000000..b5c99b7 --- /dev/null +++ b/booth/public/index.html @@ -0,0 +1,432 @@ + + + + + + Galerie Photo + + + + +
+
+
📷
+

Galerie Photo

+

Saisissez le code affiche sur la borne

+
+ + + + +
+ +
+
+ + + + + + + + + + diff --git a/booth/server.js b/booth/server.js new file mode 100644 index 0000000..ac6e5d5 --- /dev/null +++ b/booth/server.js @@ -0,0 +1,224 @@ +const express = require('express'); +const http = require('http'); +const WebSocket = require('ws'); +const multer = require('multer'); +const sharp = require('sharp'); +const path = require('path'); +const fs = require('fs'); +const crypto = require('crypto'); + +const app = express(); +const server = http.createServer(app); +const wss = new WebSocket.Server({ server }); + +// --- Configuration --- +const PORT = process.env.PORT || 3000; +const PHOTOS_DIR = path.join(__dirname, 'data', 'photos'); +const THUMBS_DIR = path.join(__dirname, 'data', 'thumbs'); + +// Creer les dossiers +fs.mkdirSync(PHOTOS_DIR, { recursive: true }); +fs.mkdirSync(THUMBS_DIR, { recursive: true }); + +// --- Sessions evenement --- +// { eventId: { password, photos: [{name, timestamp}], createdAt } } +const sessions = new Map(); + +function genererPassword() { + return crypto.randomInt(1000, 9999).toString(); +} + +function getSession(eventId) { + if (!sessions.has(eventId)) { + sessions.set(eventId, { + password: genererPassword(), + photos: [], + createdAt: Date.now(), + }); + // Creer le dossier + fs.mkdirSync(path.join(PHOTOS_DIR, eventId), { recursive: true }); + fs.mkdirSync(path.join(THUMBS_DIR, eventId), { recursive: true }); + } + return sessions.get(eventId); +} + +// --- Upload photos depuis la borne --- +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 20 * 1024 * 1024 }, // 20 Mo max +}); + +// API Key pour l'upload (simple) +const API_KEY = process.env.API_KEY || 'booth-secret-key'; + +function verifierApiKey(req, res, next) { + const key = req.headers['x-api-key'] || req.query.key; + if (key !== API_KEY) { + return res.status(401).json({ error: 'Cle API invalide' }); + } + next(); +} + +// --- Routes API --- + +// Upload photo depuis la borne +app.post('/api/:eventId/upload', verifierApiKey, upload.single('photo'), async (req, res) => { + try { + const { eventId } = req.params; + const session = getSession(eventId); + const timestamp = Date.now(); + const filename = `${timestamp}.jpg`; + + // Sauvegarder la photo originale + const photoPath = path.join(PHOTOS_DIR, eventId, filename); + await sharp(req.file.buffer) + .jpeg({ quality: 90 }) + .toFile(photoPath); + + // Creer la miniature + const thumbPath = path.join(THUMBS_DIR, eventId, filename); + await sharp(req.file.buffer) + .resize(400, 400, { fit: 'cover' }) + .jpeg({ quality: 75 }) + .toFile(thumbPath); + + // Ajouter a la session + const photoInfo = { name: filename, timestamp }; + session.photos.push(photoInfo); + + // Notifier les clients WebSocket + broadcast(eventId, { + type: 'new_photo', + photo: photoInfo, + }); + + res.json({ ok: true, filename }); + } catch (err) { + console.error('Erreur upload:', err); + res.status(500).json({ error: 'Erreur upload' }); + } +}); + +// Info session (mot de passe pour la borne) +app.get('/api/:eventId/info', verifierApiKey, (req, res) => { + const session = getSession(req.params.eventId); + res.json({ + password: session.password, + photoCount: session.photos.length, + }); +}); + +// Regenerer le mot de passe +app.post('/api/:eventId/reset-password', verifierApiKey, (req, res) => { + const session = getSession(req.params.eventId); + session.password = genererPassword(); + res.json({ password: session.password }); +}); + +// --- Routes publiques (invites) --- + +// Verifier le mot de passe +app.post('/api/:eventId/auth', express.json(), (req, res) => { + const session = sessions.get(req.params.eventId); + if (!session) { + return res.status(404).json({ error: 'Evenement introuvable' }); + } + if (req.body.password !== session.password) { + return res.status(403).json({ error: 'Mot de passe incorrect' }); + } + // Generer un token simple + const token = crypto.randomBytes(16).toString('hex'); + session.token = session.token || new Set(); + session.token.add(token); + res.json({ ok: true, token, photoCount: session.photos.length }); +}); + +// Middleware pour verifier le token invite +function verifierToken(req, res, next) { + const token = req.headers['x-token'] || req.query.token; + const session = sessions.get(req.params.eventId); + if (!session || !session.token || !session.token.has(token)) { + return res.status(403).json({ error: 'Acces refuse' }); + } + req.session = session; + next(); +} + +// Liste des photos +app.get('/api/:eventId/photos', verifierToken, (req, res) => { + res.json({ photos: req.session.photos }); +}); + +// Servir les photos +app.get('/photos/:eventId/:filename', (req, res) => { + const token = req.query.token; + const session = sessions.get(req.params.eventId); + if (!session || !session.token || !session.token.has(token)) { + return res.status(403).send('Acces refuse'); + } + const filePath = path.join(PHOTOS_DIR, req.params.eventId, req.params.filename); + if (!fs.existsSync(filePath)) return res.status(404).send('Not found'); + res.sendFile(filePath); +}); + +// Servir les miniatures +app.get('/thumbs/:eventId/:filename', (req, res) => { + const token = req.query.token; + const session = sessions.get(req.params.eventId); + if (!session || !session.token || !session.token.has(token)) { + return res.status(403).send('Acces refuse'); + } + const filePath = path.join(THUMBS_DIR, req.params.eventId, req.params.filename); + if (!fs.existsSync(filePath)) return res.status(404).send('Not found'); + res.sendFile(filePath); +}); + +// --- WebSocket --- +wss.on('connection', (ws, req) => { + const url = new URL(req.url, `http://localhost:${PORT}`); + const eventId = url.searchParams.get('event'); + const token = url.searchParams.get('token'); + + const session = sessions.get(eventId); + if (!session || !session.token || !session.token.has(token)) { + ws.close(4001, 'Acces refuse'); + return; + } + + ws.eventId = eventId; + ws.isAlive = true; + + ws.on('pong', () => { ws.isAlive = true; }); + ws.on('error', () => {}); +}); + +function broadcast(eventId, data) { + const msg = JSON.stringify(data); + wss.clients.forEach(client => { + if (client.eventId === eventId && client.readyState === WebSocket.OPEN) { + client.send(msg); + } + }); +} + +// Ping pour garder les connexions +setInterval(() => { + wss.clients.forEach(ws => { + if (!ws.isAlive) return ws.terminate(); + ws.isAlive = false; + ws.ping(); + }); +}, 30000); + +// --- Frontend statique --- +app.use(express.static(path.join(__dirname, 'public'))); + +// SPA fallback +app.get('/:eventId', (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'index.html')); +}); + +// --- Demarrage --- +server.listen(PORT, () => { + console.log(`Booth gallery running on port ${PORT}`); +}); diff --git a/frontend/css/style.css b/frontend/css/style.css index 47db76b..ddb6811 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -249,6 +249,36 @@ html, body { font-size: 3rem; } +.btn-mode .mode-icone-large { + font-size: 5rem; +} + +/* === Booth info (code + QR sur accueil) === */ +.booth-info { + position: absolute; + bottom: 1rem; + left: 1rem; + display: flex; + align-items: center; + gap: 0.8rem; + background: rgba(0,0,0,0.5); + padding: 0.5rem 1rem; + border-radius: 12px; +} + +.booth-qr { + width: 64px; + height: 64px; + border-radius: 6px; +} + +.booth-code { + font-size: 1.4rem; + font-weight: bold; + color: rgba(255,255,255,0.7); + letter-spacing: 4px; +} + /* === Capture / Preview live === */ .preview-live { width: 100%; diff --git a/frontend/index.html b/frontend/index.html index 4e640a1..aa7ad8a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -24,6 +24,11 @@
400 / 400
+ +
+ + +
⚙
@@ -33,12 +38,12 @@

Choisissez votre mode

diff --git a/frontend/js/app.js b/frontend/js/app.js index add1b14..72dae5e 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -14,6 +14,7 @@ async function init() { setupEcranAccueil(); setupModes(); setupOnglets(); + chargerBoothInfo(); } function appliquerConfig() { @@ -209,6 +210,28 @@ function toggleVisible(id, visible) { } } +async function chargerBoothInfo() { + const booth = config.booth || {}; + const el = document.getElementById('booth-info'); + if (!booth.actif || !booth.url) { + el.classList.add('cache'); + return; + } + try { + const info = await apiGet('/api/booth/info'); + if (info.password) { + document.getElementById('booth-code').textContent = info.password; + // QR code vers le site booth + const eventId = booth.event_id || 'default'; + const qrUrl = `${booth.url}/${eventId}`; + document.getElementById('booth-qr').src = `/api/qr?url=${encodeURIComponent(qrUrl)}`; + el.classList.remove('cache'); + } + } catch (e) { + el.classList.add('cache'); + } +} + function afficherStatut(message, type = 'succes') { const el = document.getElementById('statut-partage'); el.textContent = message;