From f540458b748dc878530572323451d4cd46013d6b Mon Sep 17 00:00:00 2001 From: Erik Thiele Date: Sat, 29 Aug 2026 19:42:03 +0200 Subject: [PATCH] fix: Zeitsteuerung reparieren und vervollstaendigen Der vorige Commit 26efed8 war nicht lauffaehig - fehlgeschlagene Patch-Anwendungen hatten Zeilen aus ihren Bloecken geschoben. Reparaturen: - admin(): zerschossene Playlist-Schleife neu aufgebaut - player(): return render_template war in den else-Zweig gerutscht, wodurch die View bei aktiver Priority-Playlist None lieferte (HTTP 500) - playlist_item_enabled() existierte nicht mehr (umbenannt zu is_item_active), zwei Aufrufe im Player blieben stehen -> NameError - update_playlist_item(): Absturz bei new_url=null, und der enabled-Status ging beim Bearbeiten verloren - Dateityp-Erkennung mit else-Fallback, ftype konnte ungebunden bleiben Zeitsteuerung: - Zeitfenster ueber Mitternacht (22:00-02:00) funktionieren; bei weekly gehoert ein solches Fenster zum Wochentag seines Starts - Optionaler Gueltigkeitszeitraum (valid_from/valid_until) fuer daily und weekly, beide Grenzen einzeln optional und inklusiv - daily/weekly speichern nur noch die Uhrzeit ("08:00") statt eines Zeitstempels mit bedeutungslosem Datumsanteil; _schedule_time() liest weiterhin auch Altbestaende mit vollem ISO-Zeitstempel - playlist_hash() bezieht den ausgewerteten Aktiv-Zustand ein, sonst bemerkt der Player ein ablaufendes Zeitfenster erst beim naechsten Reload aus anderem Grund - Zeitsteuerung auch fuer Datei-Eintraege, nicht nur URLs - Neuer Filter schedule_text fuer den Tooltip am "Zeit"-Badge Oberflaeche: - required lag auf ausgeblendeten Feldern und blockierte das Absenden des Formulars "URL hinzufuegen" ohne sichtbare Meldung - updateScheduleDaysVisibility() war innerhalb von DOMContentLoaded definiert, der Edit-Handler darueber -> ReferenceError bei jedem Klick - data-schedule mit tojson zerlegte das Attribut, da tojson " nicht escaped - Zeitsteuerungs-Block als schlichte Formularzeilen statt als card, die global den roten Rand und grauen Header erhaelt - Zeitsteuerung auch in der Priority-Playlist Co-Authored-By: Claude Opus 5 --- app.py | 359 +++++++++++++++++++++++++++++----------- templates/admin.html | 298 +++++++++++++++++++++++---------- templates/priority.html | 330 ++++++++++++++++++++++++++++++++++-- 3 files changed, 788 insertions(+), 199 deletions(-) diff --git a/app.py b/app.py index 48a48be..902d8d6 100755 --- a/app.py +++ b/app.py @@ -22,7 +22,7 @@ import pyotp import qrcode from functools import wraps -from datetime import datetime, timedelta +from datetime import datetime, timedelta, date from pathlib import Path from werkzeug.middleware.proxy_fix import ProxyFix @@ -217,52 +217,164 @@ def playlist_item_name(item): return "" +def _window_start_date(now, start_time, end_time): + """Ermittelt, an welchem Kalendertag das gerade laufende Tageszeitfenster begonnen hat. + Gibt None zurück, wenn die aktuelle Uhrzeit ausserhalb des Fensters liegt. + Fenster über Mitternacht (start > end) gehören zum Tag ihres Starts: + um 01:00 läuft noch das Fenster, das gestern um 22:00 begonnen hat.""" + current_time = now.time() + + if start_time <= end_time: + if start_time <= current_time <= end_time: + return now.date() + return None + + # Fenster über Mitternacht, z. B. 22:00–02:00 + if current_time >= start_time: + return now.date() + if current_time <= end_time: + return (now - timedelta(days=1)).date() + return None + + +def _schedule_time(value): + """Liest die Uhrzeit aus einem Schedule-Feld. + Akzeptiert "HH:MM" (aktuelles Format für daily/weekly) und vollständige + ISO-Zeitstempel aus Altbeständen, bei denen der Datumsanteil ohnehin + ignoriert wurde. Gibt None zurück, wenn nichts Lesbares drinsteht.""" + if not value: + return None + + text = str(value).strip().replace(" ", "T") + + if "T" in text: + try: + return datetime.fromisoformat(text).time() + except (ValueError, TypeError): + return None + + for fmt in ("%H:%M", "%H:%M:%S"): + try: + return datetime.strptime(text, fmt).time() + except ValueError: + continue + return None + + +def _in_validity_range(schedule, day): + """Prüft den optionalen Gültigkeitszeitraum (valid_from / valid_until) eines + Schedules gegen den Tag, an dem das Zeitfenster begonnen hat. + Beide Grenzen sind optional und inklusiv; unlesbare Werte werden ignoriert.""" + for key, is_lower_bound in (("valid_from", True), ("valid_until", False)): + value = schedule.get(key) + if not value: + continue + try: + bound = date.fromisoformat(str(value)[:10]) + except (ValueError, TypeError): + continue + if is_lower_bound and day < bound: + return False + if not is_lower_bound and day > bound: + return False + return True + + +@app.template_filter("schedule_text") +def schedule_text(schedule): + """Kurzbeschreibung einer Zeitsteuerung für Tooltips in der Playlist.""" + if not schedule: + return "" + + start = str(schedule.get("start", "")) + end = str(schedule.get("end", "")) + mode = schedule.get("mode", "once") + + def clock(value): + parsed = _schedule_time(value) + return parsed.strftime("%H:%M") if parsed else value + + if mode == "once": + text = f"Einmalig: {start.replace('T', ' ')} bis {end.replace('T', ' ')}" + elif mode == "daily": + text = f"Täglich {clock(start)}–{clock(end)}" + elif mode == "weekly": + names = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] + days = [] + for d in schedule.get("days", []): + try: + days.append(names[int(d)]) + except (ValueError, TypeError, IndexError): + continue + day_text = ", ".join(days) if days else "kein Tag ausgewählt" + text = f"{day_text}: {clock(start)}–{clock(end)}" + else: + return "" + + valid_from = schedule.get("valid_from") + valid_until = schedule.get("valid_until") + if valid_from and valid_until: + text += f" (vom {valid_from} bis {valid_until})" + elif valid_from: + text += f" (ab {valid_from})" + elif valid_until: + text += f" (bis {valid_until})" + + return text + + def is_item_active(item): """Prüft, ob ein Playlist-Eintrag aktiviert und zeitlich gültig ist. - Berücksichtigt enabled-Status und optionale Zeitsteuerung.""" + Berücksichtigt enabled-Status und optionale Zeitsteuerung. + Zeitfenster dürfen über Mitternacht laufen (start > end).""" now = datetime.now() - - # Standardmäßig aktiv, wenn kein enabled-Status gesetzt ist - if isinstance(item, dict): - if not item.get("enabled", True): + + # Strings haben keinen enabled-Status und keine Zeitsteuerung + if not isinstance(item, dict): + return True + + if not item.get("enabled", True): + return False + + schedule = item.get("schedule") + if not schedule: + return True + + start_str = schedule.get("start") + end_str = schedule.get("end") + if not start_str or not end_str: + return True + + mode = schedule.get("mode", "once") + + # "Einmalig" braucht vollständige Zeitstempel, daily/weekly nur die Uhrzeit. + # Unlesbare Angaben blenden den Eintrag nicht aus - ein Konfigfehler soll + # nicht dazu führen, dass stillschweigend nichts mehr läuft. + if mode == "once": + try: + return datetime.fromisoformat(start_str) <= now <= datetime.fromisoformat(end_str) + except (ValueError, TypeError): + return True + + if mode in ("daily", "weekly"): + start_time = _schedule_time(start_str) + end_time = _schedule_time(end_str) + if start_time is None or end_time is None: + return True + + day = _window_start_date(now, start_time, end_time) + if day is None: return False - - # Zeitsteuerung prüfen - schedule = item.get("schedule") - if schedule: - start_str = schedule.get("start") - end_str = schedule.get("end") - - if not start_str or not end_str: - return item.get("enabled", True) - - try: - start = datetime.fromisoformat(start_str) - end = datetime.fromisoformat(end_str) - - mode = schedule.get("mode", "once") - - if mode == "once": - return start <= now <= end - elif mode == "daily": - current_time = now.time() - start_time = start.time() - end_time = end.time() - return start_time <= current_time <= end_time - elif mode == "weekly": - days = schedule.get("days", []) - current_weekday = now.weekday() # 0=Montag, 6=Sonntag - current_time = now.time() - start_time = start.time() - end_time = end.time() - return (str(current_weekday) in days or - f"{current_weekday}".lower()[:3] in [d.lower() for d in days]) and \ - start_time <= current_time <= end_time - except (ValueError, TypeError): - pass - - # Für Strings oder keine Zeitsteuerung: nur enabled prüfen - return playlist_item_enabled(item) + if not _in_validity_range(schedule, day): + return False + if mode == "daily": + # Bei "täglich" zählt jeder Wochentag - days wird nicht ausgewertet + return True + days = schedule.get("days", []) + return str(day.weekday()) in {str(d) for d in days} + + # Unbekannter Modus: wie ohne Zeitsteuerung behandeln + return True # ------------------------------------------------- @@ -282,33 +394,39 @@ def load_priority_files(): # 1. Einträge aus der config.json-Playlist for item in prio_playlist: item_name = playlist_item_name(item) - if is_url(item): - url_data = normalize_url(item) - if url_data: - priority_files.append({ - "name": url_data["url"], - "type": "url", - "size": "URL", - "zoom": url_data.get("zoom", 1.0), - "enabled": is_item_active(item), - "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False, - "schedule": item.get("schedule") - }) - continue + if is_url(item): + url_data = normalize_url(item) + if url_data: + priority_files.append({ + "name": url_data["url"], + "type": "url", + "size": "URL", + "zoom": url_data.get("zoom", 1.0), + "enabled": is_item_active(item), + "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False, + "schedule": item.get("schedule") if isinstance(item, dict) else None + }) + continue file_path = os.path.join(prio_dir, item_name) if not os.path.exists(file_path): continue ext = os.path.splitext(item_name)[1].lower() - ftype = "video" if ext == ".mp4" else "image" + if ext == ".mp4": + ftype = "video" + elif ext == ".html": + ftype = "html" + else: + ftype = "image" size = os.path.getsize(file_path) // 1024 priority_files.append({ "name": item_name, "type": ftype, "size": size, - "enabled": playlist_item_enabled(item) + "enabled": is_item_active(item), + "schedule": item.get("schedule") if isinstance(item, dict) else None }) # 2. Neue Dateien aus dem Ordner, die noch nicht in der Playlist sind @@ -325,7 +443,8 @@ def load_priority_files(): priority_files.append({ "name": f, "type": ftype, - "size": size + "size": size, + "schedule": None }) return priority_files @@ -1014,13 +1133,13 @@ def player(site, screen): # 1. Playlist-Reihenfolge aus config.json for item in playlist: - if not playlist_item_enabled(item): + if not is_item_active(item): continue item_name = playlist_item_name(item) if is_url(item): url_data = normalize_url(item) if url_data: - normal_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": is_item_active(item), "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False, "schedule": item.get("schedule")}) + normal_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": is_item_active(item), "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False, "schedule": item.get("schedule") if isinstance(item, dict) else None}) continue if item_name and os.path.exists(os.path.join(folder, item_name)) and allowed_file(item_name): @@ -1044,13 +1163,13 @@ def player(site, screen): if priority_cfg.get("enabled", False): prio_files = [] for item in priority_cfg.get("playlist", []): - if not playlist_item_enabled(item): + if not is_item_active(item): continue item_name = playlist_item_name(item) if is_url(item): url_data = normalize_url(item) if url_data: - prio_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": is_item_active(item), "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False, "schedule": item.get("schedule")}) + prio_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": is_item_active(item), "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False, "schedule": item.get("schedule") if isinstance(item, dict) else None}) continue file_path = os.path.join(MEDIA_DIR, "priority", item_name) @@ -1059,30 +1178,30 @@ def player(site, screen): else: prio_files = [] - return render_template( - "player.html", - site=site, - screen=screen, - normal_files=[item for item in normal_files if item.get("enabled", True)], - prio_files=[item for item in prio_files if item.get("enabled", True)], - interval=screen_cfg.get("interval", 10), - newsticker_text=screen_cfg.get("newsticker_text", ""), - newsticker_enabled=screen_cfg.get("newsticker_enabled", False), - custom_url=screen_cfg.get("custom_url", ""), - custom_url_label=screen_cfg.get("custom_url_label", ""), - custom_url_enabled=screen_cfg.get("custom_url_enabled", False), - custom_url_target=screen_cfg.get("custom_url_target", "overlay"), - custom_url_position=screen_cfg.get("custom_url_position", "top-left"), - stay_on_first=screen_cfg.get("stay_on_first", False), - voice_agent_enabled=screen_cfg.get("voice_agent_enabled", False), - voice_agent_show_image=screen_cfg.get("voice_agent_show_image") if screen_cfg.get("voice_agent_show_image") is not None else False, - voice_agent_url=config.get("voice_agent_url", ""), - voice_agent_label=screen_cfg.get("voice_agent_label", "Digitaler Assistent"), - voice_agent_target=screen_cfg.get("voice_agent_target", "overlay"), - voice_agent_position=screen_cfg.get("voice_agent_position", "top-left"), - player_params=dict(request.args), - now=datetime.now().isoformat() - ) + return render_template( + "player.html", + site=site, + screen=screen, + normal_files=[item for item in normal_files if item.get("enabled", True)], + prio_files=[item for item in prio_files if item.get("enabled", True)], + interval=screen_cfg.get("interval", 10), + newsticker_text=screen_cfg.get("newsticker_text", ""), + newsticker_enabled=screen_cfg.get("newsticker_enabled", False), + custom_url=screen_cfg.get("custom_url", ""), + custom_url_label=screen_cfg.get("custom_url_label", ""), + custom_url_enabled=screen_cfg.get("custom_url_enabled", False), + custom_url_target=screen_cfg.get("custom_url_target", "overlay"), + custom_url_position=screen_cfg.get("custom_url_position", "top-left"), + stay_on_first=screen_cfg.get("stay_on_first", False), + voice_agent_enabled=screen_cfg.get("voice_agent_enabled", False), + voice_agent_show_image=screen_cfg.get("voice_agent_show_image") if screen_cfg.get("voice_agent_show_image") is not None else False, + voice_agent_url=config.get("voice_agent_url", ""), + voice_agent_label=screen_cfg.get("voice_agent_label", "Digitaler Assistent"), + voice_agent_target=screen_cfg.get("voice_agent_target", "overlay"), + voice_agent_position=screen_cfg.get("voice_agent_position", "top-left"), + player_params=dict(request.args), + now=datetime.now().isoformat() + ) # ------------------------------------------------- @@ -1103,11 +1222,21 @@ def playlist_hash(site, screen): record_heartbeat(site, screen, ip=ip, ua=ua, resolution=resolution) config = load_config() + screen_playlist = ( + config.get("sites", {}).get(site, {}).get("screens", {}).get(screen, {}).get("playlist", []) + ) + prio_cfg = config.get("priority", {}) + prio_playlist = prio_cfg.get("playlist", []) if prio_cfg.get("enabled", False) else [] + relevant = { "sites": { site: config.get("sites", {}).get(site, {}) }, - "priority": config.get("priority", {}) + "priority": prio_cfg, + # Aktueller Zeitsteuerungs-Zustand: sonst würde der Player ein + # ablaufendes Zeitfenster erst beim nächsten Reload bemerken. + "active": [is_item_active(item) for item in screen_playlist] + + [is_item_active(item) for item in prio_playlist] } blob = json.dumps(relevant, sort_keys=True).encode() @@ -1232,6 +1361,7 @@ def admin(site): # 1. Playlist-Einträge in Config-Reihenfolge for item in playlist: item_name = playlist_item_name(item) + schedule = item.get("schedule") if isinstance(item, dict) else None if is_url(item): url_data = normalize_url(item) @@ -1243,7 +1373,7 @@ def admin(site): "zoom": url_data.get("zoom", 1.0), "enabled": is_item_active(item), "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False, - "schedule": item.get("schedule") + "schedule": schedule }) continue @@ -1254,10 +1384,10 @@ def admin(site): ext = os.path.splitext(item_name)[1].lower() if ext == ".mp4": ftype = "video" - if ext in (".jpg", ".jpeg", ".png"): - ftype = "image" - if ext == ".html": + elif ext == ".html": ftype = "html" + else: + ftype = "image" size = os.path.getsize(file_path) // 1024 @@ -1266,7 +1396,7 @@ def admin(site): "type": ftype, "size": size, "enabled": is_item_active(item), - "schedule": item.get("schedule") + "schedule": schedule }) # 2. Neue Dateien (ohne Playlist-Eintrag) anhängen @@ -1283,7 +1413,8 @@ def admin(site): files.append({ "name": f, "type": ftype, - "size": size + "size": size, + "schedule": None }) screens[screen_name] = screen_cfg @@ -1536,9 +1667,15 @@ def add_url(site, screen): if schedule_enabled: mode = request.form.get("schedule_mode", "once") - start = request.form.get("schedule_start") - end = request.form.get("schedule_end") - + + # "Einmalig" braucht Datum + Uhrzeit, daily/weekly nur die Uhrzeit + if mode in ("daily", "weekly"): + start = request.form.get("schedule_start_time") + end = request.form.get("schedule_end_time") + else: + start = request.form.get("schedule_start") + end = request.form.get("schedule_end") + if not start or not end: flash("Start- und Endzeit müssen angegeben werden", "error") return redirect(f"/admin/{site}") @@ -1553,6 +1690,15 @@ def add_url(site, screen): days = request.form.getlist("schedule_days") schedule["days"] = days + # Optionaler Gültigkeitszeitraum (nur bei daily/weekly sinnvoll) + if mode in ("daily", "weekly"): + valid_from = (request.form.get("schedule_valid_from") or "").strip() + valid_until = (request.form.get("schedule_valid_until") or "").strip() + if valid_from: + schedule["valid_from"] = valid_from + if valid_until: + schedule["valid_until"] = valid_until + url_data = {"url": url, "zoom": zoom_factor} if schedule: url_data["schedule"] = schedule @@ -1654,12 +1800,16 @@ def update_playlist_item(site, screen): """Aktualisiert URL, Zoom und Zeitsteuerung eines einzelnen Playlist-Items.""" data = request.get_json() old_name = data.get("old_name") - new_url = data.get("new_url", "").strip() + new_url = (data.get("new_url") or "").strip() zoom = data.get("zoom", 1.0) passthrough = data.get("passthrough") schedule = data.get("schedule") # Kann None, {} oder schedule-Objekt sein - if not is_url(new_url): + # new_url ist nur bei URL-Einträgen gesetzt; bei Dateien wird ausschliesslich + # die Zeitsteuerung bearbeitet und der Dateiname bleibt unverändert. + is_url_item = bool(new_url) + + if is_url_item and not is_url(new_url): return jsonify({"error": "Ungültige URL"}), 400 try: @@ -1679,8 +1829,15 @@ def update_playlist_item(site, screen): for i, item in enumerate(playlist): name = playlist_item_name(item) if name == old_name: - updated = {"url": new_url, "zoom": zoom} - + if is_url_item: + updated = {"url": new_url, "zoom": zoom} + else: + updated = {"name": old_name} + + # enabled-Status des bestehenden Eintrags nicht verlieren + if isinstance(item, dict) and "enabled" in item: + updated["enabled"] = item["enabled"] + # Passthrough beibehalten oder aktualisieren if passthrough is not None: updated["passthrough_params"] = bool(passthrough) diff --git a/templates/admin.html b/templates/admin.html index 24b55b1..02c2a19 100755 --- a/templates/admin.html +++ b/templates/admin.html @@ -240,21 +240,19 @@ {% if file.enabled %}checked{% endif %} title="Element aktivieren"> - - + + {{ file.name }}{% if file.type == "url" and file.zoom != 1.0 %} [{{ file.zoom }}x]{% endif %}{% if file.type == "url" %}{% if file.get('passthrough', False) %} {% else %} {% endif %}{% endif %} - {% if file.schedule %}Zeit{% endif %} - + {% if file.schedule %}Zeit{% endif %} + {% if file.type == "image" %} @@ -284,23 +282,20 @@ - - {% if file.type == "url" %} - - {% else %} - - {% endif %} + + ⇅ @@ -498,16 +493,15 @@ -
-
-
- - -
-
- - +
@@ -613,8 +632,39 @@