Files
CSG-Picker-Shipper-Helper/app/images.py
T
2026-07-06 00:24:30 -05:00

230 lines
8.1 KiB
Python

"""Part image lookup with on-disk cache.
Lookup order, per part:
1. SMB share (if `smb_image_dir` is configured) — looks for `{SKU}.{ext}`
across common extensions.
2. On-disk cache keyed by SKU.
3. The CSV's Image URL (downloaded, then cached).
4. Bundled placeholder image.
All network work happens off the UI thread. The UI passes a callback to
:meth:`ImageCache.load_async` and receives the rendered Pillow image when
ready.
"""
from __future__ import annotations
import hashlib
import logging
import os
import re
import sys
import threading
from pathlib import Path
from typing import Callable
import requests
from PIL import Image, ImageDraw, ImageFont
log = logging.getLogger(__name__)
_SMB_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".bmp")
_SAFE = re.compile(r"[^A-Za-z0-9._-]")
def _safe_filename(sku: str, suffix: str) -> str:
"""Make a filesystem-safe filename from a SKU."""
clean = _SAFE.sub("_", sku.strip())
if not clean:
clean = hashlib.sha1(sku.encode()).hexdigest()[:12]
return f"{clean}{suffix}"
def _bundle_path(relpath: str) -> Path:
"""Resolve a path relative to either the source tree or the PyInstaller bundle."""
base = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))
return base / relpath
class ImageCache:
def __init__(self, cache_dir: str, smb_dir: str = "", placeholder_size: tuple[int, int] = (320, 240)):
self.cache_dir = Path(cache_dir) if cache_dir else None
self.smb_dir = Path(smb_dir) if smb_dir else None
self.placeholder_size = placeholder_size
if self.cache_dir:
self.cache_dir.mkdir(parents=True, exist_ok=True)
self._placeholder: Image.Image | None = None
# SKU -> list of pending callbacks. When a fetch is in progress for a
# SKU, additional load_async calls for the same SKU just queue their
# callback here instead of starting a duplicate fetch. All queued
# callbacks fire with the same final image when the worker completes.
self._inflight: dict[str, list] = {}
self._lock = threading.Lock()
# ---------- placeholder ----------
def placeholder(self) -> Image.Image:
if self._placeholder is not None:
return self._placeholder.copy()
# Try bundled asset first.
bundled = _bundle_path("assets/placeholder.png")
if bundled.exists():
self._placeholder = Image.open(bundled).convert("RGBA")
else:
# Draw one on the fly so the app never crashes for lack of an asset.
w, h = self.placeholder_size
img = Image.new("RGBA", (w, h), (235, 235, 240, 255))
draw = ImageDraw.Draw(img)
try:
font = ImageFont.truetype("arial.ttf", 22)
except Exception:
font = ImageFont.load_default()
text = "No image"
bbox = draw.textbbox((0, 0), text, font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
draw.text(((w - tw) / 2, (h - th) / 2), text, fill=(120, 120, 130), font=font)
self._placeholder = img
return self._placeholder.copy()
# ---------- lookup paths ----------
def _smb_path_for(self, sku: str) -> Path | None:
if not self.smb_dir:
return None
for ext in _SMB_EXTS:
p = self.smb_dir / _safe_filename(sku, ext)
if p.exists():
return p
return None
def _cache_path_for(self, sku: str, suffix: str = ".png") -> Path | None:
if not self.cache_dir:
return None
return self.cache_dir / _safe_filename(sku, suffix)
# ---------- synchronous load (used internally + for tests) ----------
def load(self, sku: str, url: str | None) -> Image.Image:
# 1. SMB.
smb = self._smb_path_for(sku)
if smb is not None:
try:
return Image.open(smb).convert("RGBA")
except Exception as e:
log.warning("SMB image failed for %s: %s", sku, e)
# 2. Disk cache.
if self.cache_dir:
for ext in _SMB_EXTS:
p = self.cache_dir / _safe_filename(sku, ext)
if p.exists():
try:
return Image.open(p).convert("RGBA")
except Exception as e:
log.warning("Cached image failed for %s: %s", sku, e)
try:
p.unlink()
except OSError:
pass
# 3. URL.
if url:
try:
resp = requests.get(url, timeout=8, stream=True)
resp.raise_for_status()
# Pick extension from URL if we can; default to .png.
lower = url.lower().split("?", 1)[0]
suffix = ".png"
for ext in _SMB_EXTS:
if lower.endswith(ext):
suffix = ext
break
if self.cache_dir:
dest = self.cache_dir / _safe_filename(sku, suffix)
with open(dest, "wb") as f:
for chunk in resp.iter_content(8192):
f.write(chunk)
return Image.open(dest).convert("RGBA")
# No cache dir — load straight from bytes.
from io import BytesIO
return Image.open(BytesIO(resp.content)).convert("RGBA")
except Exception as e:
log.warning("Image download failed for %s (%s): %s", sku, url, e)
# 4. Fallback.
return self.placeholder()
# ---------- async load ----------
def load_async(
self,
sku: str,
url: str | None,
callback: Callable[[str, Image.Image], None],
) -> None:
"""Fetch the image on a worker thread; call `callback(sku, image)` when done.
The callback runs on the worker thread, so the UI layer should marshal
it back to the Tk thread (we use Tk's `.after(0, ...)` for that).
"""
# Quick path: if it's already on disk, return synchronously to avoid a
# visible flash of the placeholder.
cached = self._fast_local(sku)
if cached is not None:
callback(sku, cached)
return
# De-duplicate concurrent fetches: if another caller is already
# fetching this SKU, just queue our callback to fire alongside theirs.
# Without this, the second caller's callback would be silently dropped
# and the UI would stay stuck on "Loading image..." because each
# _show_part call captures its own stale-detection token.
with self._lock:
if sku in self._inflight:
self._inflight[sku].append(callback)
return
self._inflight[sku] = [callback]
def worker() -> None:
try:
img = self.load(sku, url)
except Exception as e:
log.warning("Image fetch failed for %s: %s", sku, e)
img = self.placeholder()
# Snapshot and clear the callback list under the lock, then fire
# them outside the lock so a slow callback can't block other SKUs.
with self._lock:
callbacks = self._inflight.pop(sku, [])
for cb in callbacks:
try:
cb(sku, img)
except Exception:
log.exception("Image callback raised for %s", sku)
threading.Thread(target=worker, daemon=True).start()
def _fast_local(self, sku: str) -> Image.Image | None:
# SMB.
smb = self._smb_path_for(sku)
if smb is not None:
try:
return Image.open(smb).convert("RGBA")
except Exception:
pass
# Cache.
if self.cache_dir:
for ext in _SMB_EXTS:
p = self.cache_dir / _safe_filename(sku, ext)
if p.exists():
try:
return Image.open(p).convert("RGBA")
except Exception:
pass
return None