From 5a2fdd65d01595dea1f6070e88b7855b2f89051a Mon Sep 17 00:00:00 2001 From: moritzrfs Date: Sun, 8 Feb 2026 20:48:59 +0100 Subject: [PATCH] Add api server2 2 --- .dockerignore | 4 + Dockerfile | 37 ++++++ api/__init__.py | 0 api/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 140 bytes api/__pycache__/config.cpython-312.pyc | Bin 0 -> 885 bytes api/__pycache__/main.cpython-312.pyc | Bin 0 -> 3675 bytes api/__pycache__/models.cpython-312.pyc | Bin 0 -> 1965 bytes api/__pycache__/scanner.cpython-312.pyc | Bin 0 -> 7958 bytes api/config.py | 12 ++ api/main.py | 70 ++++++++++ api/models.py | 38 ++++++ api/scanner.py | 155 +++++++++++++++++++++++ docker-compose.yml | 10 ++ requirements.txt | 2 + 14 files changed, 328 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 api/__init__.py create mode 100644 api/__pycache__/__init__.cpython-312.pyc create mode 100644 api/__pycache__/config.cpython-312.pyc create mode 100644 api/__pycache__/main.cpython-312.pyc create mode 100644 api/__pycache__/models.cpython-312.pyc create mode 100644 api/__pycache__/scanner.cpython-312.pyc create mode 100644 api/config.py create mode 100644 api/main.py create mode 100644 api/models.py create mode 100644 api/scanner.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..df10e2e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +__pycache__ +*.pyc +scans/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..70b8c1e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + sane-utils \ + imagemagick \ + poppler-utils \ + tesseract-ocr \ + tesseract-ocr-deu \ + tesseract-ocr-eng \ + unpaper \ + bc \ + python3 \ + python3-pip \ + && rm -rf /var/lib/apt/lists/* + +# Allow ImageMagick to process PDF files +RUN sed -i 's/rights="none" pattern="PDF"/rights="read|write" pattern="PDF"/' \ + /etc/ImageMagick-6/policy.xml || true + +WORKDIR /app + +COPY requirements.txt . +RUN pip3 install --no-cache-dir --break-system-packages -r requirements.txt + +COPY scan.sh . +RUN chmod +x scan.sh + +COPY api/ api/ + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/status')" || exit 1 + +CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/__pycache__/__init__.cpython-312.pyc b/api/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b9c8b75e51e2518c6e04fc4e82fd155f8d149fd9 GIT binary patch literal 140 zcmX@j%ge>Uz`(HYc}FIQehebe@n;qW28QVjl?3usK7-W!($>$&&rQ|O z%`eIEyyp{FHTO((@jiC(@!kO)Q^wP%*!l^kJl@xyv1RYo1apelWJGQ%D}+D V$iTo*3}Sp_W@Kb6VrF1q003PLAZh>r literal 0 HcmV?d00001 diff --git a/api/__pycache__/config.cpython-312.pyc b/api/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..957d1b12fab938580143628e361ba0aa613feaad GIT binary patch literal 885 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 literal 0 HcmV?d00001 diff --git a/api/__pycache__/scanner.cpython-312.pyc b/api/__pycache__/scanner.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8f22f1aec664c25e7efc4b81579af6c9129d1d90 GIT binary patch literal 7958 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 None: + self._lock = asyncio.Lock() + self._scanning = False + self._last_result: dict[str, Any] | None = None + self._current_scan_info: dict[str, str] | None = None + + @property + def is_scanning(self) -> bool: + return self._scanning + + @property + def last_result(self) -> dict[str, Any] | None: + return self._last_result + + @property + def current_scan_info(self) -> dict[str, str] | None: + return self._current_scan_info + + async def start_scan( + self, + mode: str, + resolution: int, + language: str, + output: str | None = None, + ) -> str: + if self._scanning: + raise ScannerBusyError("A scan is already in progress") + + SCAN_DIR.mkdir(parents=True, exist_ok=True) + + if output is None: + ts = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H-%M-%S") + output = str(SCAN_DIR / f"scan_{ts}.pdf") + + self._scanning = True + self._current_scan_info = { + "output": output, + "mode": mode, + "resolution": str(resolution), + "started_at": datetime.now(timezone.utc).isoformat(), + } + + asyncio.create_task(self._run_scan(mode, resolution, language, output)) + return output + + async def _run_scan( + self, + mode: str, + resolution: int, + language: str, + output: str, + ) -> None: + async with self._lock: + try: + proc = await asyncio.create_subprocess_exec( + "bash", + str(SCRIPT_PATH), + "--mode", mode, + "--resolution", str(resolution), + "--language", language, + "--output", output, + "--overwrite-output-file", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + stdout, stderr = await asyncio.wait_for( + proc.communicate(), timeout=SCAN_TIMEOUT + ) + except asyncio.TimeoutError: + proc.kill() + await proc.communicate() + log.error("Scan timed out after %ds", SCAN_TIMEOUT) + self._last_result = {"status": "timeout", "output": output} + return + + if proc.returncode != 0: + log.error("scan.sh failed: %s", stderr.decode()) + + json_path = Path(output.replace(".pdf", ".json")) + if json_path.exists(): + self._last_result = json.loads(json_path.read_text()) + self._last_result["output"] = output + else: + self._last_result = { + "status": "failed", + "output": output, + "returncode": proc.returncode, + } + except Exception: + log.exception("Unexpected error during scan") + self._last_result = {"status": "error", "output": output} + finally: + self._scanning = False + self._current_scan_info = None + + async def check_paper(self) -> dict[str, bool | str]: + if self._scanning: + raise ScannerBusyError( + "Cannot check paper while a scan is in progress" + ) + + async with self._lock: + try: + proc = await asyncio.create_subprocess_exec( + "scanimage", "-A", "--device-name", DEVICE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for( + proc.communicate(), timeout=10 + ) + except asyncio.TimeoutError: + raise ScannerTimeoutError("scanimage -A timed out") + except FileNotFoundError: + raise ScannerUnavailableError("scanimage not found") + + if proc.returncode != 0: + raise ScannerUnavailableError( + f"scanimage failed: {stderr.decode().strip()}" + ) + + output_text = stdout.decode() + match = re.search( + r"--page-loaded\[=\(yes\|no\)\]\s+\[(yes|no)\]", output_text + ) + paper_loaded = match.group(1) == "yes" if match else False + + return {"paper_loaded": paper_loaded, "raw": output_text} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..74c469c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +services: + scanner: + build: . + ports: + - "8000:8000" + devices: + - /dev/bus/usb:/dev/bus/usb + volumes: + - ./scans:/app/scans + restart: unless-stopped diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7924492 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.115.0 +uvicorn[standard]==0.32.0