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
+65 -31

No files matched your search

+13 -23
View File
@@ -1,13 +1,17 @@
"""Shows a picture -- either uploaded through the web UI or fetched from a """Shows a picture -- either uploaded through the web UI or downloaded from a
URL on a timer. Same contrast-boost/grayscale treatment as the Navidrome URL. Either way it ends up as a local file in UPLOAD_DIR and check() just
cover art, since the same e-ink legibility concerns apply. 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 import logging
from io import BytesIO
from pathlib import Path from pathlib import Path
import requests
from PIL import Image, ImageDraw, ImageEnhance from PIL import Image, ImageDraw, ImageEnhance
from screens.base import Screen 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__) logger = logging.getLogger(__name__)
HTTP_TIMEOUT = 15
UPLOAD_DIR = Path(__file__).resolve().parent.parent / "web" / "static" / "uploads" UPLOAD_DIR = Path(__file__).resolve().parent.parent / "web" / "static" / "uploads"
class PictureScreen(Screen): class PictureScreen(Screen):
id = "picture" id = "picture"
display_name = "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: def default_settings(self) -> dict:
return { 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": "", "image_url": "",
"uploaded_filename": "", "uploaded_filename": "",
"fit": "cover", # "cover" or "contain" "fit": "cover", # "cover" or "contain"
@@ -35,30 +38,17 @@ class PictureScreen(Screen):
} }
def check(self, settings: dict): def check(self, settings: dict):
mode = settings.get("mode", "url")
if mode == "upload":
filename = settings.get("uploaded_filename") filename = settings.get("uploaded_filename")
if not filename: if not filename:
return None return None
path = UPLOAD_DIR / filename path = UPLOAD_DIR / filename
if not path.exists(): if not path.exists():
logger.warning("Uploaded picture %s is missing", path) logger.warning("Picture %s is missing", path)
return None return None
try: try:
return Image.open(path).convert("RGB") return Image.open(path).convert("RGB")
except OSError as exc: except OSError as exc:
logger.warning("Could not open uploaded picture %s: %s", path, exc) logger.warning("Could not open picture %s: %s", path, exc)
return None
url = (settings.get("image_url") or "").strip()
if not url:
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 None return None
def render(self, state, settings: dict, resolution: tuple[int, int]) -> Image.Image: def render(self, state, settings: dict, resolution: tuple[int, int]) -> Image.Image:
+47 -1
View File
@@ -1,5 +1,7 @@
"""Flask app: status/preview page + one settings form per screen + rotation settings.""" """Flask app: status/preview page + one settings form per screen + rotation settings."""
from io import BytesIO
import requests import requests
from flask import Flask, redirect, render_template, request, url_for from flask import Flask, redirect, render_template, request, url_for
from PIL import Image from PIL import Image
@@ -8,6 +10,11 @@ from werkzeug.utils import secure_filename
from screens.picture import UPLOAD_DIR from screens.picture import UPLOAD_DIR
ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "bmp", "webp"} ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "bmp", "webp"}
PIL_FORMAT_TO_EXT = {"JPEG": "jpg", "PNG": "png", "GIF": "gif", "BMP": "bmp", "WEBP": "webp"}
PICTURE_URL_TIMEOUT = 15
PICTURE_URL_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
}
def create_app(config, screens: dict, scheduler) -> Flask: def create_app(config, screens: dict, scheduler) -> Flask:
@@ -257,7 +264,46 @@ def create_app(config, screens: dict, scheduler) -> Flask:
scheduler.invalidate_screen("picture") scheduler.invalidate_screen("picture")
return redirect(url_for("settings_picture", saved=1)) return redirect(url_for("settings_picture", saved=1))
elif url: elif url:
config.update_screen_settings("picture", {"mode": "url", "image_url": url, **common_updates}) # Downloaded once, here, rather than re-fetched by the
# screen's own check() on every poll -- otherwise a remote
# image that comes back even slightly byte-different between
# polls (common with dynamically-generated images) would
# force a real e-ink flash every poll_interval for no
# visible reason.
try:
# Some image hosts (Wikimedia confirmed live -- 403s with
# "set a user-agent") reject the default requests/python
# User-Agent as bot traffic.
response = requests.get(url, timeout=PICTURE_URL_TIMEOUT, headers=PICTURE_URL_HEADERS)
response.raise_for_status()
content = response.content
fmt = Image.open(BytesIO(content)).format
except (requests.exceptions.RequestException, OSError):
fmt = None
ext = PIL_FORMAT_TO_EXT.get(fmt or "")
if not ext:
error = "Could not download a valid image from that URL -- try a different link, or upload a file instead."
else:
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
# Only one picture is ever active -- clear any previous
# upload/download first so a different extension doesn't
# leave an orphaned file behind.
for old in UPLOAD_DIR.glob("current.*"):
old.unlink()
dest = UPLOAD_DIR / f"current.{ext}"
dest.write_bytes(content)
try:
Image.open(dest).verify()
except Exception:
dest.unlink(missing_ok=True)
error = "That URL doesn't look like a valid image."
else:
config.update_screen_settings("picture", {
"mode": "url",
"image_url": url,
"uploaded_filename": dest.name,
**common_updates,
})
scheduler.invalidate_screen("picture") scheduler.invalidate_screen("picture")
return redirect(url_for("settings_picture", saved=1)) return redirect(url_for("settings_picture", saved=1))
else: else:
+5 -7
View File
@@ -6,17 +6,15 @@
<div class="toast error">{{ error }}</div> <div class="toast error">{{ error }}</div>
{% endif %} {% endif %}
{% if settings.mode == "upload" and settings.uploaded_filename %} {% if settings.uploaded_filename %}
<div class="card"> <div class="card">
<div class="section-label">Current picture</div> <div class="section-label">Current picture</div>
<div class="preview-wrap"> <div class="preview-wrap">
<img class="preview" src="{{ url_for('static', filename='uploads/' + settings.uploaded_filename) }}" alt="current picture"> <img class="preview" src="{{ url_for('static', filename='uploads/' + settings.uploaded_filename) }}" alt="current picture">
</div> </div>
</div> {% if settings.mode == "url" and settings.image_url %}
{% elif settings.mode == "url" and settings.image_url %} <p class="hint">Downloaded from: {{ settings.image_url }}</p>
<div class="card"> {% endif %}
<div class="section-label">Current picture</div>
<p class="hint">Fetched from a URL: {{ settings.image_url }}</p>
</div> </div>
{% endif %} {% endif %}
@@ -35,7 +33,7 @@
<div class="field"> <div class="field">
<label for="image_url">Image URL</label> <label for="image_url">Image URL</label>
<input type="text" id="image_url" name="image_url" placeholder="https://example.com/photo.jpg"> <input type="text" id="image_url" name="image_url" placeholder="https://example.com/photo.jpg">
<p class="hint">Re-fetched every 30 minutes. Leave both this and the upload above alone to just change the options below.</p> <p class="hint">Downloaded once and cached -- won't keep re-fetching, so this can't cause the panel to flash on its own. Enter a new URL any time to replace it. Leave both this and the upload above alone to just change the options below.</p>
</div> </div>
</div> </div>