From 98bce1b0da675715b2e5482c0ad61ba21135f62f Mon Sep 17 00:00:00 2001
From: Server-Bibliothek <server-bibliothek@lokal>
Date: Di, 04 Aug 2026 07:00:04 +0200
Subject: [PATCH] Add a desktop client that knows what is installed locally

---
 desktop/backend/bibliothek.py |   85 ++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 85 insertions(+), 0 deletions(-)

diff --git a/desktop/backend/bibliothek.py b/desktop/backend/bibliothek.py
new file mode 100644
index 0000000..6274a70
--- /dev/null
+++ b/desktop/backend/bibliothek.py
@@ -0,0 +1,85 @@
+"""Zugriff auf den Server der Software-Bibliothek.
+
+Nur die beiden Routen, die aus dem Netzwerk erreichbar sind: Katalog lesen und
+Datei holen. Die Verwaltung bleibt der Weboberflaeche am Server vorbehalten.
+
+Bewusst nur mit der Standardbibliothek — jede Abhaengigkeit mehr muss beim Bau
+der exe mitgeschleppt werden, und fuer zwei GET-Aufrufe lohnt das nicht.
+"""
+
+from __future__ import annotations
+
+import json
+import urllib.error
+import urllib.request
+from pathlib import Path
+from typing import Any, Callable
+
+ZEITGRENZE_KATALOG_S = 15
+ZEITGRENZE_DOWNLOAD_S = 600
+
+
+class BibliothekNichtErreichbar(Exception):
+    """Der Server antwortet nicht oder liefert etwas Unerwartetes."""
+
+
+def _lesbarer_fehler(adresse: str, fehler: Exception) -> BibliothekNichtErreichbar:
+    if isinstance(fehler, urllib.error.HTTPError):
+        return BibliothekNichtErreichbar(f"{adresse} antwortet mit HTTP {fehler.code}.")
+    if isinstance(fehler, urllib.error.URLError):
+        return BibliothekNichtErreichbar(f"{adresse} ist nicht erreichbar: {fehler.reason}")
+    return BibliothekNichtErreichbar(f"{adresse}: {fehler}")
+
+
+def katalog(server_adresse: str) -> list[dict[str, Any]]:
+    """Holt alle Katalogeintraege."""
+    adresse = f"{server_adresse.rstrip('/')}/api/software"
+    try:
+        with urllib.request.urlopen(adresse, timeout=ZEITGRENZE_KATALOG_S) as antwort:
+            gelesen = json.loads(antwort.read().decode("utf-8"))
+    except Exception as fehler:  # noqa: BLE001 - jede Ursache wird zur selben Meldung
+        raise _lesbarer_fehler(server_adresse, fehler) from fehler
+    if not isinstance(gelesen, list):
+        raise BibliothekNichtErreichbar(f"{server_adresse} liefert keinen Katalog.")
+    return gelesen
+
+
+def datei_holen(
+    server_adresse: str,
+    software_id: int,
+    ziel: Path,
+    fortschritt: Callable[[int, int], None] | None = None,
+) -> Path:
+    """Laedt die bereitgestellte Datei nach `ziel`.
+
+    Geschrieben wird zuerst nach `<ziel>.teil` und erst nach vollstaendigem
+    Empfang umbenannt. Ein Abbruch mitten im Download darf keine halbe Datei
+    hinterlassen, die spaeter fuer ein fertiges Programm gehalten wird.
+    """
+    adresse = f"{server_adresse.rstrip('/')}/api/software/{software_id}/download"
+    unfertig = ziel.with_name(ziel.name + ".teil")
+    ziel.parent.mkdir(parents=True, exist_ok=True)
+    try:
+        with urllib.request.urlopen(adresse, timeout=ZEITGRENZE_DOWNLOAD_S) as antwort:
+            gesamt = int(antwort.headers.get("Content-Length") or 0)
+            geladen = 0
+            with unfertig.open("wb") as datei:
+                while True:
+                    brocken = antwort.read(256 * 1024)
+                    if not brocken:
+                        break
+                    datei.write(brocken)
+                    geladen += len(brocken)
+                    if fortschritt:
+                        fortschritt(geladen, gesamt)
+    except Exception as fehler:  # noqa: BLE001
+        unfertig.unlink(missing_ok=True)
+        raise _lesbarer_fehler(server_adresse, fehler) from fehler
+
+    if unfertig.stat().st_size == 0:
+        unfertig.unlink(missing_ok=True)
+        raise BibliothekNichtErreichbar("Der Server hat eine leere Datei geliefert.")
+
+    ziel.unlink(missing_ok=True)
+    unfertig.replace(ziel)
+    return ziel

--
Gitblit v1.9.3