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

+49 -3
View File
@@ -1,5 +1,7 @@
"""Flask app: status/preview page + one settings form per screen + rotation settings."""
from io import BytesIO
import requests
from flask import Flask, redirect, render_template, request, url_for
from PIL import Image
@@ -8,6 +10,11 @@ from werkzeug.utils import secure_filename
from screens.picture import UPLOAD_DIR
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:
@@ -257,9 +264,48 @@ def create_app(config, screens: dict, scheduler) -> Flask:
scheduler.invalidate_screen("picture")
return redirect(url_for("settings_picture", saved=1))
elif url:
config.update_screen_settings("picture", {"mode": "url", "image_url": url, **common_updates})
scheduler.invalidate_screen("picture")
return redirect(url_for("settings_picture", saved=1))
# 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")
return redirect(url_for("settings_picture", saved=1))
else:
# Neither a new upload nor a URL change -- just update fit/boost/grayscale.
config.update_screen_settings("picture", common_updates)
+5 -7
View File
@@ -6,17 +6,15 @@
<div class="toast error">{{ error }}</div>
{% endif %}
{% if settings.mode == "upload" and settings.uploaded_filename %}
{% if settings.uploaded_filename %}
<div class="card">
<div class="section-label">Current picture</div>
<div class="preview-wrap">
<img class="preview" src="{{ url_for('static', filename='uploads/' + settings.uploaded_filename) }}" alt="current picture">
</div>
</div>
{% elif settings.mode == "url" and settings.image_url %}
<div class="card">
<div class="section-label">Current picture</div>
<p class="hint">Fetched from a URL: {{ settings.image_url }}</p>
{% if settings.mode == "url" and settings.image_url %}
<p class="hint">Downloaded from: {{ settings.image_url }}</p>
{% endif %}
</div>
{% endif %}
@@ -35,7 +33,7 @@
<div class="field">
<label for="image_url">Image URL</label>
<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>