fix: UUID→fichier via API photobooth (created_at/processed), photo_id dans print_queue
🚀 Deploy — JH Photomaton / 🔍 Vérification (push) Has been cancelled
🚀 Deploy — JH Photomaton / 🍓 Deploy sur le Pi (push) Has been cancelled

This commit is contained in:
2026-07-17 16:44:33 +02:00
parent 4592f20be3
commit 742186ec8e
4 changed files with 86 additions and 94 deletions
+28 -54
View File
@@ -1,8 +1,9 @@
"""API galerie admin — impression et suppression de photos.""" """API galerie admin — impression et suppression de photos."""
import logging import logging
from datetime import datetime from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Request, Query from fastapi import APIRouter, Request, Query
from fastapi.responses import JSONResponse 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", "")))) 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): def _require_auth(request: Request):
return request.session.get("authenticated") is True return request.session.get("authenticated") is True
@@ -88,21 +46,25 @@ async def admin_get_photos(
printer_svc = request.app.state.printer_service printer_svc = request.app.state.printer_service
cfg = request.app.state.config cfg = request.app.state.config
event_svc = getattr(request.app.state, "event_service", None) event_svc = getattr(request.app.state, "event_service", None)
media_dir = Path(cfg.photobooth.media_dir)
# Récupère toutes les photos # Récupère toutes les photos
all_photos = await pb.get_media_collection(limit=500) all_photos = await pb.get_media_collection(limit=500)
photos = [p for p in all_photos if _is_image(p)] photos = [p for p in all_photos if _is_image(p)]
# Enrichit chaque photo avec mtime depuis le disque # Enrichit chaque photo avec son timestamp de création
mtime_index = _build_mtime_index(media_dir) # photobooth-app retourne 'created_at' en UTC (ex: "2026-07-17T14:15:52")
for p in photos: for p in photos:
pid = _get_id(p) pid = _get_id(p)
p["photo_id"] = pid p["photo_id"] = pid
stem = pid.rsplit(".", 1)[0] if "." in pid else pid created_str = p.get("created_at", "")
mt = mtime_index.get(pid) or mtime_index.get(stem) or 0.0 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 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_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 "" 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 ws = request.app.state.ws_manager
cfg = request.app.state.config cfg = request.app.state.config
filename = _find_file(cfg.photobooth.media_dir, photo_id) # Récupère les détails de la photo via l'API photobooth-app
if not filename: # (le champ 'processed' donne le chemin relatif réel : media/processed_full/YYYYMMDD-xxx.jpg)
return JSONResponse({"error": f"Fichier introuvable pour {photo_id}"}, status_code=404) 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) 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 should_print_now = (cfg.print.mode == "direct") or immediate
+12 -25
View File
@@ -2,7 +2,7 @@
import asyncio import asyncio
import logging import logging
from datetime import datetime from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
import httpx import httpx
@@ -50,31 +50,18 @@ async def api_gallery_photos(
if ev: if ev:
started_at = ev["started_at"] or 0.0 started_at = ev["started_at"] or 0.0
ended_at = ev["ended_at"] or datetime.now().timestamp() 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: def _photo_ts(p: dict) -> float:
pid = _get_id(p) """Timestamp UTC de la photo depuis le champ created_at de photobooth-app."""
stem = pid.rsplit(".", 1)[0] if "." in pid else pid created_str = p.get("created_at", "")
mt = mtime_index.get(pid) or mtime_index.get(stem) or 0.0 try:
return mt > 0 and started_at <= mt <= ended_at return datetime.fromisoformat(created_str).replace(
photos = [p for p in photos if _in_event(p)] 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) total = len(photos)
start = (page - 1) * limit start = (page - 1) * limit
+23
View File
@@ -53,6 +53,29 @@ class PhotoboothService:
items = await self.get_media_collection(limit=1) items = await self.get_media_collection(limit=1)
return items[0] if items else None 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: async def delete_media(self, media_id: str) -> bool:
"""Supprime une photo via l'API photobooth-app.""" """Supprime une photo via l'API photobooth-app."""
try: try:
+23 -15
View File
@@ -21,6 +21,7 @@ PrintStatus = Literal["pending", "printing", "done", "cancelled", "error"]
CREATE_SQL = """ CREATE_SQL = """
CREATE TABLE IF NOT EXISTS print_queue ( CREATE TABLE IF NOT EXISTS print_queue (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
photo_id TEXT,
filename TEXT NOT NULL, filename TEXT NOT NULL,
thumb_url TEXT, thumb_url TEXT,
copies INTEGER DEFAULT 1, copies INTEGER DEFAULT 1,
@@ -46,6 +47,12 @@ class PrinterService:
self._db = await aiosqlite.connect(str(db_path)) self._db = await aiosqlite.connect(str(db_path))
self._db.row_factory = aiosqlite.Row self._db.row_factory = aiosqlite.Row
await self._db.execute(CREATE_SQL) 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() await self._db.commit()
logger.info("Base print_queue initialisée: %s", db_path) logger.info("Base print_queue initialisée: %s", db_path)
@@ -55,20 +62,23 @@ class PrinterService:
# ── File d'attente ──────────────────────────────────────────────────────── # ── 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.""" """Ajoute une demande d'impression dans la file. Retourne l'entrée créée."""
entry_id = str(uuid.uuid4()) entry_id = str(uuid.uuid4())
now = time.time() now = time.time()
await self._db.execute( await self._db.execute(
"INSERT INTO print_queue (id, filename, thumb_url, copies, status, requested_at) " "INSERT INTO print_queue (id, photo_id, filename, thumb_url, copies, status, requested_at) "
"VALUES (?, ?, ?, ?, 'pending', ?)", "VALUES (?, ?, ?, ?, ?, 'pending', ?)",
(entry_id, filename, thumb_url, copies, now), (entry_id, photo_id, filename, thumb_url, copies, now),
) )
await self._db.commit() await self._db.commit()
entry = { entry = {
"id": entry_id, "id": entry_id,
"photo_id": photo_id,
"filename": filename, "filename": filename,
"thumb_url": thumb_url, "thumb_url": thumb_url,
"copies": copies, "copies": copies,
@@ -101,29 +111,27 @@ class PrinterService:
return await self.get_queue("pending") return await self.get_queue("pending")
async def get_pending_by_photo_id(self) -> dict[str, list]: 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 Utilise le champ photo_id (UUID photobooth-app) s'il est renseigné,
sans modifier le schéma SQLite — on match par stem du filename. sinon fall-back sur le stem du filename.
""" """
rows = await self.get_queue("pending") rows = await self.get_queue("pending")
# Aussi inclure celles "printing" (en cours d'impression)
rows += await self.get_queue("printing") rows += await self.get_queue("printing")
result: dict[str, list] = {} result: dict[str, list] = {}
for r in rows: for r in rows:
from pathlib import Path key = r.get("photo_id") or Path(r["filename"]).stem
stem = Path(r["filename"]).stem result.setdefault(key, []).append(r)
result.setdefault(stem, []).append(r)
return result return result
async def cancel_by_photo_id(self, photo_stem: str) -> int: async def cancel_by_photo_id(self, photo_id: str) -> int:
"""Annule toutes les demandes pending pour un photo_id donné. Retourne le nb annulé.""" """Annule toutes les demandes pending pour un photo_id (UUID) donné."""
pending = await self.get_queue("pending") pending = await self.get_queue("pending")
from pathlib import Path
cancelled = 0 cancelled = 0
for r in pending: 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"]) ok = await self.cancel(r["id"])
if ok: if ok:
cancelled += 1 cancelled += 1