fix: UUID→fichier via API photobooth (created_at/processed), photo_id dans print_queue
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user