"""Service d'impression — file d'attente SQLite + appel script_print.sh.""" from __future__ import annotations import asyncio import logging import subprocess import time import uuid from pathlib import Path from typing import Literal import aiosqlite from backend.services.config_service import Config logger = logging.getLogger(__name__) PrintStatus = Literal["pending", "printing", "done", "cancelled", "error"] CREATE_SQL = """ CREATE TABLE IF NOT EXISTS print_queue ( id TEXT PRIMARY KEY, filename TEXT NOT NULL, thumb_url TEXT, copies INTEGER DEFAULT 1, status TEXT DEFAULT 'pending', printer TEXT, requested_at REAL, processed_at REAL, error_msg TEXT ); """ class PrinterService: def __init__(self, config: Config): self._cfg = config.print self._db_path: Path | None = None self._db: aiosqlite.Connection | None = None self._lock = asyncio.Lock() async def init_db(self, db_path: Path): db_path.parent.mkdir(parents=True, exist_ok=True) self._db_path = db_path self._db = await aiosqlite.connect(str(db_path)) self._db.row_factory = aiosqlite.Row await self._db.execute(CREATE_SQL) await self._db.commit() logger.info("Base print_queue initialisée: %s", db_path) async def close(self): if self._db: await self._db.close() # ── File d'attente ──────────────────────────────────────────────────────── async def add_request(self, filename: str, thumb_url: str = "", copies: int = 1) -> 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), ) await self._db.commit() entry = { "id": entry_id, "filename": filename, "thumb_url": thumb_url, "copies": copies, "status": "pending", "requested_at": now, } # Mode direct : impression immédiate sans validation if self._cfg.mode == "direct": asyncio.create_task(self.execute_print(entry_id, copies)) logger.info("Demande d'impression ajoutée: %s (%s)", entry_id, filename) return entry async def get_queue(self, status: str | None = None) -> list[dict]: """Liste les entrées de la file d'attente.""" if status: cursor = await self._db.execute( "SELECT * FROM print_queue WHERE status = ? ORDER BY requested_at DESC", (status,), ) else: cursor = await self._db.execute( "SELECT * FROM print_queue ORDER BY requested_at DESC LIMIT 100" ) rows = await cursor.fetchall() return [dict(r) for r in rows] async def get_pending(self) -> list[dict]: 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. 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. """ 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) 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é.""" pending = await self.get_queue("pending") from pathlib import Path cancelled = 0 for r in pending: if Path(r["filename"]).stem == photo_stem: ok = await self.cancel(r["id"]) if ok: cancelled += 1 return cancelled async def cancel(self, entry_id: str) -> bool: async with self._lock: cursor = await self._db.execute( "SELECT status FROM print_queue WHERE id = ?", (entry_id,) ) row = await cursor.fetchone() if not row or row["status"] not in ("pending",): return False await self._db.execute( "UPDATE print_queue SET status='cancelled', processed_at=? WHERE id=?", (time.time(), entry_id), ) await self._db.commit() return True # ── Impression ──────────────────────────────────────────────────────────── async def execute_print(self, entry_id: str, copies: int = 1, printer: str = "") -> dict: """Lance l'impression via script_print.sh.""" async with self._lock: cursor = await self._db.execute( "SELECT * FROM print_queue WHERE id = ?", (entry_id,) ) entry = await cursor.fetchone() if not entry: return {"success": False, "error": "Entrée introuvable"} if entry["status"] not in ("pending",): return {"success": False, "error": f"Statut incompatible: {entry['status']}"} await self._db.execute( "UPDATE print_queue SET status='printing', copies=? WHERE id=?", (copies, entry_id), ) await self._db.commit() filename = entry["filename"] script = self._cfg.script_path # Appel async du script d'impression try: result = await asyncio.to_thread( self._run_print_script, script, filename, copies ) except Exception as e: result = {"success": False, "error": str(e), "printer": ""} now = time.time() if result["success"]: await self._db.execute( "UPDATE print_queue SET status='done', printer=?, processed_at=? WHERE id=?", (result.get("printer", ""), now, entry_id), ) else: await self._db.execute( "UPDATE print_queue SET status='error', error_msg=?, processed_at=? WHERE id=?", (result.get("error", ""), now, entry_id), ) await self._db.commit() logger.info("Impression %s: %s", entry_id, "OK" if result["success"] else result.get("error")) return result def _run_print_script(self, script: str, filename: str, copies: int) -> dict: """Appelle script_print.sh de façon synchrone.""" if not Path(script).exists(): return {"success": False, "error": f"Script introuvable: {script}"} if not Path(filename).exists(): return {"success": False, "error": f"Fichier introuvable: {filename}"} cmd = ["/bin/bash", script, filename, "image", "default", str(copies)] try: proc = subprocess.run( cmd, capture_output=True, text=True, timeout=60 ) stdout = proc.stdout.strip() if proc.returncode == 0 and "PRINTED:" in stdout: parts = stdout.split(":") printer = parts[1] if len(parts) > 1 else "" return {"success": True, "printer": printer, "output": stdout} else: return {"success": False, "error": proc.stderr.strip() or stdout, "printer": ""} except subprocess.TimeoutExpired: return {"success": False, "error": "Timeout impression (60s)"} except Exception as e: return {"success": False, "error": str(e)} # ── Helpers ─────────────────────────────────────────────────────────────── def _printer_name(self, p) -> str: """Accepte une entrée printers qui soit un str ou un dict.""" return p["name"] if isinstance(p, dict) else str(p) def _printer_label(self, p) -> str: return p.get("label", self._printer_name(p)) if isinstance(p, dict) else str(p) # ── Statut imprimantes CUPS ─────────────────────────────────────────────── async def get_printers_status(self) -> list[dict]: """Retourne le statut détaillé des imprimantes CUPS configurées.""" statuses = [] for p in self._cfg.printers: name = self._printer_name(p) status = await asyncio.to_thread(self._get_printer_status, name) statuses.append({ "name": name, "label": self._printer_label(p), **status, }) return statuses def _get_printer_status(self, printer_name: str) -> dict: """Statut CUPS complet pour une imprimante (état, jobs, accepting).""" try: # État de l'imprimante r_state = subprocess.run( ["lpstat", "-p", printer_name], capture_output=True, text=True, timeout=5 ) out = r_state.stdout.lower() if r_state.returncode != 0 or "not found" in (r_state.stderr or "").lower(): return {"state": "offline", "accepting": False, "jobs": [], "jobs_count": 0} if "idle" in out: state = "idle" elif "printing" in out or "processing" in out: state = "printing" elif "stopped" in out or "disabled" in out: state = "disabled" else: state = "unknown" # Est-ce que l'imprimante accepte les nouveaux jobs ? r_accept = subprocess.run( ["lpstat", "-a", printer_name], capture_output=True, text=True, timeout=5 ) accepting = "accepting" in r_accept.stdout.lower() # Liste des jobs CUPS en cours jobs = self._get_cups_jobs(printer_name) return { "state": state, "accepting": accepting, "jobs": jobs, "jobs_count": len(jobs), } except Exception as e: return {"state": "error", "accepting": False, "jobs": [], "jobs_count": 0, "error": str(e)} def _get_cups_jobs(self, printer_name: str) -> list[dict]: """Retourne la liste des jobs CUPS en cours pour une imprimante.""" try: r = subprocess.run( ["lpstat", "-o", printer_name, "-l"], capture_output=True, text=True, timeout=5 ) jobs = [] current = {} for line in r.stdout.splitlines(): line = line.strip() if not line: continue if line.startswith(printer_name): if current: jobs.append(current) parts = line.split() current = { "id": parts[0] if parts else "", "user": parts[1] if len(parts) > 1 else "", "size": parts[3] if len(parts) > 3 else "", "date": " ".join(parts[4:7]) if len(parts) > 6 else "", "details": [], } elif current and ":" in line: current.setdefault("details", []).append(line) if current: jobs.append(current) return jobs except Exception: return [] async def get_cups_jobs(self, printer_name: str) -> list[dict]: return await asyncio.to_thread(self._get_cups_jobs, printer_name) async def cancel_cups_jobs(self, printer_name: str) -> bool: """Annule tous les jobs CUPS pour une imprimante.""" try: result = subprocess.run( ["cancel", "-a", printer_name], capture_output=True, text=True, timeout=10 ) return result.returncode == 0 except Exception as e: logger.error("Erreur cancel CUPS: %s", e) return False async def cancel_cups_job(self, job_id: str) -> bool: """Annule un job CUPS précis.""" try: result = subprocess.run( ["cancel", job_id], capture_output=True, text=True, timeout=10 ) return result.returncode == 0 except Exception as e: logger.error("Erreur cancel CUPS job %s: %s", job_id, e) return False async def enable_printer(self, printer_name: str) -> bool: """Active l'imprimante CUPS (cupsenable) et accepte les nouveaux jobs.""" try: r1 = subprocess.run(["cupsenable", printer_name], capture_output=True, timeout=10) r2 = subprocess.run(["cupsaccept", printer_name], capture_output=True, timeout=10) return r1.returncode == 0 and r2.returncode == 0 except Exception as e: logger.error("Erreur enable printer %s: %s", printer_name, e) return False async def disable_printer(self, printer_name: str) -> bool: """Désactive l'imprimante CUPS (cupsdisable).""" try: result = subprocess.run(["cupsdisable", printer_name], capture_output=True, timeout=10) return result.returncode == 0 except Exception as e: logger.error("Erreur disable printer %s: %s", printer_name, e) return False async def reject_jobs(self, printer_name: str) -> bool: """Refuse les nouveaux jobs (cupsreject) sans stopper l'impression en cours.""" try: result = subprocess.run(["cupsreject", printer_name], capture_output=True, timeout=10) return result.returncode == 0 except Exception as e: logger.error("Erreur reject printer %s: %s", printer_name, e) return False