diff --git a/screens/picture.py b/screens/picture.py index 17826a4..a530884 100644 --- a/screens/picture.py +++ b/screens/picture.py @@ -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: diff --git a/web/app.py b/web/app.py index e8fdf42..6fe0f83 100644 --- a/web/app.py +++ b/web/app.py @@ -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) diff --git a/web/templates/settings_picture.html b/web/templates/settings_picture.html index d4db8cb..0187efc 100644 --- a/web/templates/settings_picture.html +++ b/web/templates/settings_picture.html @@ -6,17 +6,15 @@
Fetched from a URL: {{ settings.image_url }}
+ {% if settings.mode == "url" and settings.image_url %} +Downloaded from: {{ settings.image_url }}
+ {% endif %}Re-fetched every 30 minutes. Leave both this and the upload above alone to just change the options below.
+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.