Files
Vorrania/backend/app/services/images.py
Scarriffle 8d4954610b Bilder blockieren nicht mehr die halbe Seite
Warum eine angeklickte Artikelseite nur zur Haelfte lud: Die Bild-Route wird je
Tabellenzeile einmal aufgerufen, und ein Browser haelt je Host nur rund sechs
Verbindungen offen. Was diese Route aufhaelt, legt die ganze Oberflaeche lahm.
Sie hielt gleich dreifach auf:

1. Fehlte die lokale Kopie, holte ``images.ensure`` das Bild MITTEN in der
   Anfrage von der fremden Adresse - bis zu 15 Sekunden, je Zeile, und bei
   jedem Aufruf erneut, weil ein Fehlschlag nirgends vermerkt wurde. Artikel
   aus einem Backup-Import haben genau diese Ausgangslage. Gemessen: 2,01 s
   je Aufruf, dreimal hintereinander. Jetzt: 0,01 s, das Nachholen laeuft nach
   der Antwort im Hintergrund und ein toter Link wird eine Stunde gesperrt.

2. Ein Vorschaubild ist 34x34 Pixel gross - ausgeliefert wurde das Original.
   Gemessen: 2530 KB je Briefmarke, bei 40 Zeilen 99 MB fuer eine Liste. Neu
   erzeugt die Bild-Route (Pillow) eine Vorschau und legt sie daneben ab;
   ``?thumb=1`` liefert sie aus. Gemessen: 0,6 KB.

3. If-None-Match wurde ignoriert. Nach den 5 Minuten Cache-Frist lud der
   Browser jedes Bild komplett neu - das erklaert, warum es "alle 20 Minuten"
   wieder losging. Jetzt 304 ohne Daten.

Dazu im Web: laufende Bildabrufe werden beim Seitenwechsel abgebrochen (sonst
steht die neue Seite hinter Bildern Schlange, die niemand mehr sieht), und wo
``image_version`` bekannt ist, entfaellt die Anfrage fuer bildlose Artikel ganz.

Und der Verbindungsvorrat der Datenbank: 5 (+10) gegen 40 Arbeits-Threads von
FastAPI. Ab der 16. gleichzeitigen Anfrage wartete eine Route stillschweigend
30 Sekunden - von aussen ein haengender Server. Jetzt 10 (+20) mit 10 Sekunden
Frist: lieber ein Fehler als eine halbe Minute Stille.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:14:43 +02:00

188 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()