Picture: download URLs once at save-time, not on every poll

Previously check() re-fetched the configured image URL over HTTP on
every poll (every 30 min by default). If the remote image ever came
back even slightly byte-different between fetches -- common with
dynamically-generated images -- that forced a real e-ink flash for no
visible reason, since this panel has no partial refresh.

Now the URL is downloaded exactly once, server-side in the settings
route, and saved into web/static/uploads/current.<ext> the same way a
direct upload already was -- clearing any previous upload/download
first so switching sources doesn't accumulate orphaned files.
check() just reads that local file regardless of whether it came from
an upload or a URL, with zero network calls. Also added a browser
User-Agent to the download request -- confirmed live that Wikimedia
(plausibly a common source people would link to) 403s the default
requests/python User-Agent as bot traffic, same class of issue as the
EFA transit endpoints handled earlier.

Verified end-to-end against real URLs: download+cache works, a second
URL replaces rather than accumulates, and a settings-only save (no
new URL entered) no longer wipes the cached picture.
This commit is contained in:
Honey committed 2026-07-25 10:54:41 +02:00
1 parent 45fb370279
commit 5889422c31
3 files changed
+74 -40

No files matched your search

+20 -30
View File
@@ -1,13 +1,17 @@
"""Shows a picture -- either uploaded through the web UI or fetched from a
URL on a timer. Same contrast-boost/grayscale treatment as the Navidrome
cover art, since the same e-ink legibility concerns apply.
"""Shows a picture -- either uploaded through the web UI or downloaded from a
URL. Either way it ends up as a local file in UPLOAD_DIR and check() just
reads that file; a URL is downloaded once, at settings-save time (in
web/app.py), not re-fetched on every poll -- this screen has no partial
refresh any more than the rest of the panel does, so re-fetching on a timer
would risk a pointless flash every poll_interval if the remote image ever
came back byte-different despite looking the same. Same contrast-boost/
grayscale treatment as the Navidrome cover art, since the same e-ink
legibility concerns apply.
"""
import logging
from io import BytesIO
from pathlib import Path
import requests
from PIL import Image, ImageDraw, ImageEnhance
from screens.base import Screen
@@ -15,18 +19,17 @@ from screens.render_utils import fit_contain, fit_cover, load_font, text_width
logger = logging.getLogger(__name__)
HTTP_TIMEOUT = 15
UPLOAD_DIR = Path(__file__).resolve().parent.parent / "web" / "static" / "uploads"
class PictureScreen(Screen):
id = "picture"
display_name = "Picture"
poll_interval = 1800 # 30 minutes -- only matters for url mode
poll_interval = 1800 # just a cheap local file re-read, doesn't need to be frequent
def default_settings(self) -> dict:
return {
"mode": "url", # "url" or "upload"
"mode": "url", # "url" or "upload" -- purely informational (which source the current cached file came from), check() treats both the same
"image_url": "",
"uploaded_filename": "",
"fit": "cover", # "cover" or "contain"
@@ -35,30 +38,17 @@ class PictureScreen(Screen):
}
def check(self, settings: dict):
mode = settings.get("mode", "url")
if mode == "upload":
filename = settings.get("uploaded_filename")
if not filename:
return None
path = UPLOAD_DIR / filename
if not path.exists():
logger.warning("Uploaded picture %s is missing", path)
return None
try:
return Image.open(path).convert("RGB")
except OSError as exc:
logger.warning("Could not open uploaded picture %s: %s", path, exc)
return None
url = (settings.get("image_url") or "").strip()
if not url:
filename = settings.get("uploaded_filename")
if not filename:
return None
path = UPLOAD_DIR / filename
if not path.exists():
logger.warning("Picture %s is missing", path)
return None
try:
response = requests.get(url, timeout=HTTP_TIMEOUT)
response.raise_for_status()
return Image.open(BytesIO(response.content)).convert("RGB")
except (requests.exceptions.RequestException, OSError) as exc:
logger.warning("Could not load picture from %s: %s", url, exc)
return Image.open(path).convert("RGB")
except OSError as exc:
logger.warning("Could not open picture %s: %s", path, exc)
return None
def render(self, state, settings: dict, resolution: tuple[int, int]) -> Image.Image: