"""Artikelbilder holen, verkleinern und lokal vorhalten. Open Food Facts liefert nur eine Bild-*Adresse*. Würde die Oberfläche direkt dorthin verlinken, hinge jede Artikelseite an einem fremden Dienst: Das Bild verschwindet, wenn OFF es austauscht oder nicht erreichbar ist, die Installation funktioniert nicht mehr offline, und jeder Seitenaufruf verrät OFF, welche Artikel jemand ansieht. Deshalb wird das Bild einmal geholt und danach aus der eigenen Datenbank ausgeliefert. Die Bilder liegen in einer eigenen Tabelle und nicht als Spalte an ``products``: Sonst zöge jede Artikelliste die Blobs mit. **Der Abruf gehört NIE in eine laufende Anfrage.** Er hing früher in ``ensure`` mitten in ``GET /products/{id}/image`` – und diese Route wird je Tabellenzeile einmal aufgerufen. Ein toter Bildlink kostete damit 15 Sekunden pro Zeile, bei jedem Aufruf aufs Neue; die ~6 Verbindungen, die ein Browser je Host offen hält, waren belegt, und alle übrigen Abfragen der Seite standen dahinter Schlange. Die Seite lud dann „nur zur Hälfte". Nachgeholt wird deshalb nur noch im Hintergrund, und ein Fehlschlag wird gemerkt (siehe ``nachholen_faellig``). """ from __future__ import annotations import io import time import httpx from sqlalchemy import update from sqlalchemy.orm import Session from ..models import Product, ProductImage # Grosszuegig genug fuer ein Produktfoto (auch ein Handy-Foto), eng genug, dass # niemand die Datenbank mit einer versehentlich verlinkten Datei volllaeuft. # Bleibt unter dem nginx-Limit (client_max_body_size, siehe web/nginx.conf), # damit Uebergroesse eine klare Meldung statt eines nackten 413 ergibt. MAX_BYTES = 8 * 1024 * 1024 ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"} TIMEOUT_SECONDS = 15.0 # Vorschaubilder erscheinen mit 34 CSS-Pixeln Kantenlaenge. 96 deckt auch einen # dreifach aufloesenden Bildschirm ab und bleibt bei ~3 KB je Bild. THUMB_PIXEL = 96 THUMB_TYPE = "image/jpeg" # Ein toter Bildlink darf nicht bei jedem Seitenaufruf erneut ins Netz gehen. # Der Merker liegt im Arbeitsspeicher: Er darf ruhig beim Neustart verfallen – # dann wird eben einmal erneut versucht, was genau richtig ist, wenn der Dienst # zwischenzeitlich wieder da ist. SPERRE_SEKUNDEN = 3600.0 _gesperrt: dict[int, float] = {} def sperre_zuruecksetzen() -> None: """Nur fuer Tests – der Merker ist sonst modulweit und ueberlebt sie.""" _gesperrt.clear() def nachholen_faellig(product_id: int) -> bool: """Darf fuer diesen Artikel (wieder) ins Netz gegriffen werden?""" bis = _gesperrt.get(product_id) return bis is None or time.monotonic() >= bis def vorschau(daten: bytes, content_type: str) -> bytes | None: """Kleines Vorschaubild erzeugen. ``None``, wenn es nicht geht. Ohne das laedt eine Liste mit 40 Zeilen bis zu 40 Handy-Fotos in voller Aufloesung – Dutzende Megabyte, um 34x34 Pixel je Zeile darzustellen. Pillow wird erst hier importiert und jeder Fehler geschluckt: Ein Bild, das sich nicht verkleinern laesst, darf die Seite nicht mitreissen. Der Aufrufer liefert dann eben das Original aus – langsam, aber richtig. """ try: from PIL import Image, ImageOps except ImportError: # pragma: no cover return None try: with Image.open(io.BytesIO(daten)) as roh: # draft() verkleinert JPEG schon beim Dekodieren – aus 2 MB werden # so Millisekunden statt eines vollen Bildaufbaus im Speicher. roh.draft("RGB", (THUMB_PIXEL, THUMB_PIXEL)) # Handy-Fotos tragen ihre Ausrichtung in den EXIF-Daten. Beim neuen # Kodieren gehen die verloren – ohne diesen Schritt laege jedes # zweite Vorschaubild quer. klein = ImageOps.exif_transpose(roh).convert("RGB") klein.thumbnail((THUMB_PIXEL, THUMB_PIXEL)) puffer = io.BytesIO() klein.save(puffer, format="JPEG", quality=80, optimize=True) except Exception: # noqa: BLE001 – s.o. return None return puffer.getvalue() def vorschau_nachziehen(db: Session, bild: ProductImage) -> bytes | None: """Fehlende Vorschau einmalig erzeugen und ablegen. Fuer Bilder, die vor dieser Funktion gespeichert wurden. ``updated_at`` bleibt bewusst stehen: Das Bild hat sich nicht geaendert, und der Zeitstempel ist die Marke, an der Browser und App ihre Kopie erkennen. """ neu = vorschau(bild.data, bild.content_type) if neu is None: return None db.execute( update(ProductImage) .where(ProductImage.product_id == bild.product_id) .values(thumb=neu, updated_at=bild.updated_at) ) db.commit() return neu def fetch(url: str) -> tuple[bytes, str] | None: """Bild herunterladen. Gibt (Daten, Inhaltstyp) zurück oder None. Fehler sind hier bewusst kein Ausnahmefall, der nach oben durchschlägt: Ein fehlendes Bild darf niemals das Anlegen eines Artikels verhindern. """ if not url or not url.startswith(("http://", "https://")): return None try: resp = httpx.get( url, timeout=TIMEOUT_SECONDS, follow_redirects=True, headers={"User-Agent": "Vorrania-Selfhosted/1.0"}, ) except httpx.HTTPError: return None if resp.status_code != 200: return None typ = (resp.headers.get("content-type") or "").split(";")[0].strip().lower() if typ not in ALLOWED_TYPES: return None daten = resp.content if not daten or len(daten) > MAX_BYTES: return None return daten, typ def store(db: Session, product: Product, url: str) -> ProductImage | None: """Bild holen und beim Artikel ablegen. Ohne Erfolg bleibt alles wie es war.""" geholt = fetch(url) if geholt is None: return None daten, typ = geholt bild = db.get(ProductImage, product.id) if bild is None: bild = ProductImage( product_id=product.id, content_type=typ, data=daten, thumb=vorschau(daten, typ), source_url=url, ) db.add(bild) else: bild.content_type, bild.data, bild.source_url = typ, daten, url bild.thumb = vorschau(daten, typ) return bild def nachholen(product_id: int, url: str) -> None: """Bild nach der Antwort im Hintergrund holen – mit eigener Sitzung. Laeuft als Hintergrundaufgabe: beim Anlegen eines Artikels und beim ersten Abruf eines Bildes, dessen lokale Kopie fehlt. Schlaegt der Abruf fehl, wird der Artikel fuer eine Weile gesperrt – sonst klopft jede Tabellenzeile bei jedem Seitenaufruf erneut bei einem Dienst an, der nicht antwortet. """ from ..database import SessionLocal db = SessionLocal() try: product = db.get(Product, product_id) if product is None: return if store(db, product, url) is None: _gesperrt[product_id] = time.monotonic() + SPERRE_SEKUNDEN else: _gesperrt.pop(product_id, None) db.commit() finally: db.close()