From 742186ec8e1116fc9c697286daa4e0fdeafbe3ee Mon Sep 17 00:00:00 2001 From: jbperrin Date: Fri, 17 Jul 2026 16:44:33 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20UUID=E2=86=92fichier=20via=20API=20photo?= =?UTF-8?q?booth=20(created=5Fat/processed),=20photo=5Fid=20dans=20print?= =?UTF-8?q?=5Fqueue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/api/admin_gallery_api.py | 82 +++++++++----------------- backend/api/gallery.py | 37 ++++-------- backend/services/photobooth_service.py | 23 ++++++++ backend/services/printer_service.py | 38 +++++++----- 4 files changed, 86 insertions(+), 94 deletions(-) diff --git a/backend/api/admin_gallery_api.py b/backend/api/admin_gallery_api.py index 67c02e8..2c24cf7 100644 --- a/backend/api/admin_gallery_api.py +++ b/backend/api/admin_gallery_api.py @@ -1,8 +1,9 @@ """API galerie admin — impression et suppression de photos.""" import logging -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path + from fastapi import APIRouter, Request, Query from fastapi.responses import JSONResponse @@ -21,49 +22,6 @@ def _get_id(item: dict) -> str: return str(item.get("id", item.get("filename", item.get("uid", "")))) -def _find_file(media_dir: str, photo_id: str) -> Path | None: - """Cherche un fichier image correspondant à l'identifiant. - - Cherche dans media_dir, processed_full/ et les chemins photobooth standard. - """ - base = Path(media_dir) - candidates = [ - base, - base / "processed_full", - Path("/home/pi/photobooth-data/media/processed_full"), - Path("/home/pi/photobooth-data/media"), - ] - for d in candidates: - if not d.exists(): - continue - for ext in (".jpg", ".jpeg", ".png"): - f = d / f"{photo_id}{ext}" - if f.exists(): - return f - return None - - -def _build_mtime_index(media_dir: Path) -> dict[str, float]: - """Construit un index {stem: mtime, name: mtime} en cherchant dans les dossiers connus.""" - index: dict[str, float] = {} - checked: set[Path] = set() - candidates = [ - media_dir, - media_dir / "processed_full", - Path("/home/pi/photobooth-data/media/processed_full"), - Path("/home/pi/photobooth-data/media"), - ] - for d in candidates: - if not d.exists() or d in checked: - continue - checked.add(d) - for f in d.iterdir(): - if f.is_file() and f.suffix.lower() in (".jpg", ".jpeg", ".png"): - mt = f.stat().st_mtime - index[f.name] = mt - index[f.stem] = mt - return index - def _require_auth(request: Request): return request.session.get("authenticated") is True @@ -88,21 +46,25 @@ async def admin_get_photos( printer_svc = request.app.state.printer_service cfg = request.app.state.config event_svc = getattr(request.app.state, "event_service", None) - media_dir = Path(cfg.photobooth.media_dir) # Récupère toutes les photos all_photos = await pb.get_media_collection(limit=500) photos = [p for p in all_photos if _is_image(p)] - # Enrichit chaque photo avec mtime depuis le disque - mtime_index = _build_mtime_index(media_dir) - + # Enrichit chaque photo avec son timestamp de création + # photobooth-app retourne 'created_at' en UTC (ex: "2026-07-17T14:15:52") for p in photos: pid = _get_id(p) p["photo_id"] = pid - stem = pid.rsplit(".", 1)[0] if "." in pid else pid - mt = mtime_index.get(pid) or mtime_index.get(stem) or 0.0 + created_str = p.get("created_at", "") + try: + # Interprète created_at comme UTC → timestamp Unix correct + dt = datetime.fromisoformat(created_str).replace(tzinfo=timezone.utc) + mt = dt.timestamp() + except (ValueError, TypeError): + mt = 0.0 p["mtime"] = mt + # Date pour affichage : convertit UTC → heure locale p["date_iso"] = datetime.fromtimestamp(mt).strftime("%Y-%m-%d") if mt else "" p["date_label"] = datetime.fromtimestamp(mt).strftime("%d/%m/%Y %H:%M") if mt else "" @@ -178,12 +140,24 @@ async def admin_print_photo( ws = request.app.state.ws_manager cfg = request.app.state.config - filename = _find_file(cfg.photobooth.media_dir, photo_id) - if not filename: - return JSONResponse({"error": f"Fichier introuvable pour {photo_id}"}, status_code=404) + # Récupère les détails de la photo via l'API photobooth-app + # (le champ 'processed' donne le chemin relatif réel : media/processed_full/YYYYMMDD-xxx.jpg) + item = await pb.get_media_item(photo_id) + if not item: + return JSONResponse({"error": f"Photo introuvable: {photo_id}"}, status_code=404) + + file_path = pb.media_file_path(item) + if not file_path or not file_path.exists(): + logger.error("Fichier manquant pour %s: %s", photo_id, file_path) + return JSONResponse( + {"error": f"Fichier manquant sur le disque: {file_path}"}, status_code=404 + ) + + filename = file_path + logger.info("Fichier pour impression: %s → %s", photo_id, filename) thumb_url = pb.thumbnail_url(photo_id) - entry = await printer_svc.add_request(str(filename), thumb_url, copies) + entry = await printer_svc.add_request(str(filename), thumb_url, copies, photo_id=photo_id) should_print_now = (cfg.print.mode == "direct") or immediate diff --git a/backend/api/gallery.py b/backend/api/gallery.py index ad2fece..01a16a3 100644 --- a/backend/api/gallery.py +++ b/backend/api/gallery.py @@ -2,7 +2,7 @@ import asyncio import logging -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path import httpx @@ -50,31 +50,18 @@ async def api_gallery_photos( if ev: started_at = ev["started_at"] or 0.0 ended_at = ev["ended_at"] or datetime.now().timestamp() - # Construire un index mtime : cherche dans media_dir et ses sous-dossiers connus - mtime_index: dict[str, float] = {} - base = Path(cfg.photobooth.media_dir) - checked: set = set() - for candidate in [ - base, - base / "processed_full", - Path("/home/pi/photobooth-data/media/processed_full"), - Path("/home/pi/photobooth-data/media"), - ]: - if not candidate.exists() or candidate in checked: - continue - checked.add(candidate) - for f in candidate.iterdir(): - if f.is_file() and f.suffix.lower() in (".jpg", ".jpeg", ".png"): - mt = f.stat().st_mtime - mtime_index[f.name] = mt - mtime_index[f.stem] = mt - def _in_event(p: dict) -> bool: - pid = _get_id(p) - stem = pid.rsplit(".", 1)[0] if "." in pid else pid - mt = mtime_index.get(pid) or mtime_index.get(stem) or 0.0 - return mt > 0 and started_at <= mt <= ended_at - photos = [p for p in photos if _in_event(p)] + def _photo_ts(p: dict) -> float: + """Timestamp UTC de la photo depuis le champ created_at de photobooth-app.""" + created_str = p.get("created_at", "") + try: + return datetime.fromisoformat(created_str).replace( + tzinfo=timezone.utc + ).timestamp() + except (ValueError, TypeError): + return 0.0 + + photos = [p for p in photos if started_at <= _photo_ts(p) <= ended_at] total = len(photos) start = (page - 1) * limit diff --git a/backend/services/photobooth_service.py b/backend/services/photobooth_service.py index c207161..1c2c510 100644 --- a/backend/services/photobooth_service.py +++ b/backend/services/photobooth_service.py @@ -53,6 +53,29 @@ class PhotoboothService: items = await self.get_media_collection(limit=1) return items[0] if items else None + async def get_media_item(self, media_id: str) -> dict | None: + """Retourne les détails complets d'un item (id, created_at, processed, ...).""" + try: + r = await self._client.get(f"/api/mediacollection/{media_id}") + if r.status_code == 404: + return None + r.raise_for_status() + return r.json() + except Exception as e: + logger.error("Erreur récupération item %s: %s", media_id, e) + return None + + def media_file_path(self, item: dict) -> Path | None: + """Retourne le chemin absolu du fichier traité depuis le champ 'processed' d'un item. + + photobooth-app retourne 'processed': 'media/processed_full/YYYYMMDD-HHMMSS-xxx.jpg' + On préfixe avec data_dir pour obtenir le chemin complet. + """ + processed = item.get("processed") + if not processed: + return None + return Path(self._cfg.data_dir) / processed + async def delete_media(self, media_id: str) -> bool: """Supprime une photo via l'API photobooth-app.""" try: diff --git a/backend/services/printer_service.py b/backend/services/printer_service.py index 58d95f3..424ef31 100644 --- a/backend/services/printer_service.py +++ b/backend/services/printer_service.py @@ -21,6 +21,7 @@ PrintStatus = Literal["pending", "printing", "done", "cancelled", "error"] CREATE_SQL = """ CREATE TABLE IF NOT EXISTS print_queue ( id TEXT PRIMARY KEY, + photo_id TEXT, filename TEXT NOT NULL, thumb_url TEXT, copies INTEGER DEFAULT 1, @@ -46,6 +47,12 @@ class PrinterService: self._db = await aiosqlite.connect(str(db_path)) self._db.row_factory = aiosqlite.Row await self._db.execute(CREATE_SQL) + # Migration : ajoute photo_id si la colonne n'existe pas encore + try: + await self._db.execute("ALTER TABLE print_queue ADD COLUMN photo_id TEXT") + logger.info("Migration : colonne photo_id ajoutée à print_queue") + except Exception: + pass # Colonne déjà présente await self._db.commit() logger.info("Base print_queue initialisée: %s", db_path) @@ -55,20 +62,23 @@ class PrinterService: # ── File d'attente ──────────────────────────────────────────────────────── - async def add_request(self, filename: str, thumb_url: str = "", copies: int = 1) -> dict: + async def add_request( + self, filename: str, thumb_url: str = "", copies: int = 1, photo_id: str = "" + ) -> dict: """Ajoute une demande d'impression dans la file. Retourne l'entrée créée.""" entry_id = str(uuid.uuid4()) now = time.time() await self._db.execute( - "INSERT INTO print_queue (id, filename, thumb_url, copies, status, requested_at) " - "VALUES (?, ?, ?, ?, 'pending', ?)", - (entry_id, filename, thumb_url, copies, now), + "INSERT INTO print_queue (id, photo_id, filename, thumb_url, copies, status, requested_at) " + "VALUES (?, ?, ?, ?, ?, 'pending', ?)", + (entry_id, photo_id, filename, thumb_url, copies, now), ) await self._db.commit() entry = { "id": entry_id, + "photo_id": photo_id, "filename": filename, "thumb_url": thumb_url, "copies": copies, @@ -101,29 +111,27 @@ class PrinterService: return await self.get_queue("pending") async def get_pending_by_photo_id(self) -> dict[str, list]: - """Retourne un dict {photo_id_stem: [entries]} pour toutes les demandes actives. + """Retourne un dict {photo_id: [entries]} pour toutes les demandes actives. - Permet à la galerie admin de savoir quelles photos ont une demande en attente - sans modifier le schéma SQLite — on match par stem du filename. + Utilise le champ photo_id (UUID photobooth-app) s'il est renseigné, + sinon fall-back sur le stem du filename. """ rows = await self.get_queue("pending") - # Aussi inclure celles "printing" (en cours d'impression) rows += await self.get_queue("printing") result: dict[str, list] = {} for r in rows: - from pathlib import Path - stem = Path(r["filename"]).stem - result.setdefault(stem, []).append(r) + key = r.get("photo_id") or Path(r["filename"]).stem + result.setdefault(key, []).append(r) return result - async def cancel_by_photo_id(self, photo_stem: str) -> int: - """Annule toutes les demandes pending pour un photo_id donné. Retourne le nb annulé.""" + async def cancel_by_photo_id(self, photo_id: str) -> int: + """Annule toutes les demandes pending pour un photo_id (UUID) donné.""" pending = await self.get_queue("pending") - from pathlib import Path cancelled = 0 for r in pending: - if Path(r["filename"]).stem == photo_stem: + key = r.get("photo_id") or Path(r["filename"]).stem + if key == photo_id: ok = await self.cancel(r["id"]) if ok: cancelled += 1