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:
1 parent
45fb370279
commit
5889422c31
3 files changed
+65
-31
No files matched your search
+13
-23
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user