Files
Paperwall/web/app.py
T
Honey 474f8640fa Picture: surface the real reason a URL download fails
Couldn't reproduce the reported URL failing -- downloads fine from
here (200, valid 2.5MB PNG, verified through the actual running app
too) -- but found the error handling was swallowing the real
exception either way: network failure and "not a valid image" both
landed on the same generic message, and nothing was logged, so there
was no way to tell which one it actually was, from the Pi or from
here.

Now: timeouts, connection/HTTP errors, and decode failures each get
their own specific error message, and failures get logged via the
standard logger (visible in journalctl -u paperwall.service). Also
bumped the download timeout 15s -> 30s -- it's a one-time save-time
download now, not a recurring poll, so there's no real cost to being
more patient with a slow host or a large image.

Verified against the real app: the exact reported URL, a non-image
URL, and an unresolvable domain each now produce distinct, logged
error messages instead of one generic one.
2026-07-26 22:28:37 +02:00

330 lines
16 KiB
Python

"""Flask app: status/preview page + one settings form per screen + rotation settings."""
import logging
from io import BytesIO
import requests
from flask import Flask, redirect, render_template, request, url_for
from PIL import Image
from werkzeug.utils import secure_filename
from screens.picture import UPLOAD_DIR
logger = logging.getLogger(__name__)
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 = 30 # a one-time download at save-time, not a recurring poll -- fine to be generous
MAX_SCHEDULE_RULES = 4
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:
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 20 * 1024 * 1024 # 20 MB, generous for a photo
@app.route("/")
def index():
status = scheduler.get_status()
return render_template(
"index.html",
status=status,
screens=screens,
rotation=config.get_rotation(),
navidrome_priority=config.get_navidrome_priority(),
)
@app.route("/actions/refresh", methods=["POST"])
def refresh_now():
scheduler.request_manual_refresh()
return redirect(url_for("index"))
@app.route("/actions/switch/<screen_id>", methods=["POST"])
def switch_now(screen_id):
if screen_id in screens:
scheduler.request_switch(screen_id)
return redirect(url_for("index"))
@app.route("/actions/resume", methods=["POST"])
def resume_rotation():
scheduler.request_resume_rotation()
return redirect(url_for("index"))
@app.route("/settings/navidrome", methods=["GET", "POST"])
def settings_navidrome():
if request.method == "POST":
config.update_screen_settings("navidrome", {
"url": request.form.get("url", "").strip(),
"username": request.form.get("username", "").strip(),
"password": request.form.get("password", ""),
"filter_user": request.form.get("filter_user", "").strip(),
"show_progress": "show_progress" in request.form,
"show_genre": "show_genre" in request.form,
"show_source": "show_source" in request.form,
"boost_cover": "boost_cover" in request.form,
"grayscale_cover": "grayscale_cover" in request.form,
})
scheduler.invalidate_screen("navidrome")
return redirect(url_for("settings_navidrome", saved=1))
return render_template("settings_navidrome.html", settings=config.get_screen_settings("navidrome"))
@app.route("/settings/weather", methods=["GET", "POST"])
def settings_weather():
error = None
if request.method == "POST":
city = request.form.get("city", "").strip()
lat_raw = request.form.get("latitude", "").strip()
lon_raw = request.form.get("longitude", "").strip()
forecast_days = int(request.form.get("forecast_days", 5) or 5)
if forecast_days not in (3, 5, 7):
forecast_days = 5
update_minutes = max(1, int(request.form.get("update_minutes", 30) or 30))
display_toggles = {
"show_city": "show_city" in request.form,
"show_date": "show_date" in request.form,
"show_hourly_graph": "show_hourly_graph" in request.form,
"show_feels_like": "show_feels_like" in request.form,
"show_high_low": "show_high_low" in request.form,
"show_rain_chance": "show_rain_chance" in request.form,
}
if city:
try:
result = screens["weather"].geocode(city)
except requests.exceptions.RequestException:
result = None
if result is None:
error = f'Could not find "{city}" -- try a different spelling, or fill in coordinates manually below instead.'
else:
config.update_screen_settings("weather", {
"city": result["label"],
"latitude": result["latitude"],
"longitude": result["longitude"],
"forecast_days": forecast_days,
"update_minutes": update_minutes,
**display_toggles,
})
scheduler.invalidate_screen("weather")
return redirect(url_for("settings_weather", saved=1))
else:
# The city text field isn't pre-filled (unlike the manual
# lat/lon fields below it), so it's blank on every
# settings-only save -- toggles, forecast_days, etc. Only
# clear the resolved city label when the coordinates
# actually changed; otherwise this would silently wipe the
# city on every save even though lat/lon round-trip fine.
current = config.get_screen_settings("weather")
new_lat = float(lat_raw) if lat_raw else None
new_lon = float(lon_raw) if lon_raw else None
coords_unchanged = new_lat == current.get("latitude") and new_lon == current.get("longitude")
config.update_screen_settings("weather", {
"city": current.get("city", "") if coords_unchanged else "",
"latitude": new_lat,
"longitude": new_lon,
"forecast_days": forecast_days,
"update_minutes": update_minutes,
**display_toggles,
})
scheduler.invalidate_screen("weather")
return redirect(url_for("settings_weather", saved=1))
return render_template("settings_weather.html", settings=config.get_screen_settings("weather"), error=error)
@app.route("/settings/journey", methods=["GET", "POST"])
def settings_journey():
error = None
if request.method == "POST":
origin_query = request.form.get("origin", "").strip()
destination_query = request.form.get("destination", "").strip()
try:
results = max(1, min(5, int(request.form.get("results", 3) or 3)))
except (TypeError, ValueError):
results = 3
update_minutes = max(1, int(request.form.get("update_minutes", 2) or 2))
connection_type = request.form.get("connection_type", "all")
if connection_type not in ("all", "regional", "long_distance"):
connection_type = "all"
updates = {"results": results, "update_minutes": update_minutes, "connection_type": connection_type}
journey_screen = screens["journey"]
if origin_query:
try:
found = journey_screen.geocode(origin_query)
except requests.exceptions.RequestException:
found = None
if found is None:
error = f'Could not find "{origin_query}" as a starting point -- try a different spelling.'
else:
updates.update({
"origin_query": origin_query,
"origin_label": found["label"],
"origin_lat": found["latitude"],
"origin_lon": found["longitude"],
})
if not error and destination_query:
try:
found = journey_screen.geocode(destination_query)
except requests.exceptions.RequestException:
found = None
if found is None:
error = f'Could not find "{destination_query}" as a destination -- try a different spelling.'
else:
updates.update({
"destination_query": destination_query,
"destination_label": found["label"],
"destination_lat": found["latitude"],
"destination_lon": found["longitude"],
})
if not error:
config.update_screen_settings("journey", updates)
scheduler.invalidate_screen("journey")
found_parts = [v for k, v in (("origin", origin_query), ("destination", destination_query)) if v]
if found_parts:
current = config.get_screen_settings("journey")
return redirect(url_for(
"settings_journey",
found=f'{current["origin_label"]} → {current["destination_label"]}',
))
return redirect(url_for("settings_journey", saved=1))
return render_template("settings_journey.html", settings=config.get_screen_settings("journey"), error=error)
@app.route("/settings/picture", methods=["GET", "POST"])
def settings_picture():
error = None
if request.method == "POST":
url = request.form.get("image_url", "").strip()
common_updates = {
"fit": request.form.get("fit", "cover"),
"boost_contrast": "boost_contrast" in request.form,
"grayscale": "grayscale" in request.form,
}
uploaded = request.files.get("image")
if uploaded and uploaded.filename:
filename = secure_filename(uploaded.filename)
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
if ext not in ALLOWED_IMAGE_EXTENSIONS:
error = "Unsupported file type -- use PNG, JPG, GIF, BMP, or WebP."
else:
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
# Only one picture is ever active -- clear any previous
# upload 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}"
uploaded.save(dest)
try:
Image.open(dest).verify()
except Exception:
dest.unlink(missing_ok=True)
error = "That file doesn't look like a valid image."
else:
config.update_screen_settings("picture", {
"mode": "upload",
"uploaded_filename": dest.name,
**common_updates,
})
scheduler.invalidate_screen("picture")
return redirect(url_for("settings_picture", saved=1))
elif url:
# 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 = None
except requests.exceptions.Timeout:
content = fmt = None
error = f"Timed out downloading that URL after {PICTURE_URL_TIMEOUT}s -- the host might be slow or blocking this server."
except requests.exceptions.RequestException as exc:
content = fmt = None
logger.warning("Picture URL download failed for %s: %s", url, exc)
error = f"Could not download that URL -- {exc}"
else:
try:
fmt = Image.open(BytesIO(content)).format
except OSError as exc:
logger.warning("Picture URL %s downloaded but didn't decode as an image: %s", url, exc)
fmt = None
ext = PIL_FORMAT_TO_EXT.get(fmt or "")
if not ext:
if not error:
error = "That URL doesn't look like a valid image -- 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)
scheduler.invalidate_screen("picture")
return redirect(url_for("settings_picture", saved=1))
return render_template("settings_picture.html", settings=config.get_screen_settings("picture"), error=error)
@app.route("/settings/rotation", methods=["GET", "POST"])
def settings_rotation():
if request.method == "POST":
raw = request.form.get("screens", "")
selected = [s.strip() for s in raw.split(",") if s.strip() in screens]
interval_minutes = int(request.form.get("interval_minutes", 5) or 5)
enabled = "rotation_enabled" in request.form
config.set_rotation(selected, interval_minutes, enabled)
config.set_navidrome_priority("navidrome_priority" in request.form)
schedule_rules = []
for i in range(1, MAX_SCHEDULE_RULES + 1):
rule_screen = request.form.get(f"schedule_screen_{i}", "").strip()
start = request.form.get(f"schedule_start_{i}", "").strip()
end = request.form.get(f"schedule_end_{i}", "").strip()
if rule_screen in screens and start and end:
schedule_rules.append({"screen_id": rule_screen, "start": start, "end": end})
config.set_schedules(schedule_rules)
return redirect(url_for("settings_rotation", saved=1))
return render_template(
"settings_rotation.html",
screens=screens,
rotation=config.get_rotation(),
navidrome_priority=config.get_navidrome_priority(),
schedules=config.get_schedules(),
max_schedule_rules=MAX_SCHEDULE_RULES,
)
return app