Compare commits

...

2 Commits

Author SHA1 Message Date
Erik Thiele
d2938f87f3 fix: Priority-Playlist je Standort statt global
Die Priority-Playlist lag unter config["priority"] und wirkte damit auf
*alle* Standorte. In der Praxis lief so ein Stuttgart-spezifischer
Eintrag (wbxroompresence...find=Stuttgart) auch auf den Karlsruher
Screens.

Sie liegt jetzt unter sites.<site>.priority, die Medien unter
media/<site>/priority/:
- get_priority_config(cfg, site) liefert die Konfiguration des
  Standorts. Ist dort nichts hinterlegt, wird ein globaler Altbestand
  einmalig uebernommen - per deepcopy, damit spaetere Aenderungen nicht
  zwischen Standorten durchschlagen.
- load_priority_files(site), priority_dir(site) sowie Player, Hash,
  Upload, Loeschen, URL-Anlegen, Umsortieren und Einzeleintrag-
  Bearbeitung arbeiten durchgaengig standortbezogen.
- Route /media/priority/<datei> -> /media/<site>/priority/<datei>,
  Templates entsprechend.
- Der Ordnername "priority" ist wie "logos" in der Screen-Erkennung
  ausgenommen. media/<site>/priority/ waere sonst als Screen in der
  Konfiguration gelandet; "priority" ist in den Routen ohnehin als
  Kennzeichen belegt, ein Screen dieses Namens war nie moeglich.

Migration der vorhandenen Konfiguration: der globale Block wurde in alle
drei Standorte uebernommen und der globale Schluessel entfernt. Der
leere globale Ordner media/priority/ ist entfallen.

README, AGENTS.md und Hilfeseite nachgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:32:27 +02:00
Erik Thiele
a9cbe42df9 docs: Luecken in README und AGENTS.md schliessen
Abgleich der Doku gegen den Code ergab:
- /admin/help und /customer fehlten in der Routen-Tabelle
- Der Verzeichnisbaum zeigte nur die Screen-Ordner, nicht die Dateien
  auf Standort-Ebene (background.*, logo_<slot>.png, welcome.html) -
  gerade die sind aber bewusst Dateien und keine Ordner, weil jeder
  Unterordner als Screen interpretiert wird
- Der Docker-Abschnitt erwaehnte weder das noetige --build noch die
  Log-Einstellungen (PYTHONUNBUFFERED, access-logfile), die fuer jede
  Fehlersuche die Grundlage sind

AGENTS.md bekommt dazu einen Abschnitt "Betrieb / Diagnose".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 18:25:13 +02:00
6 changed files with 125 additions and 45 deletions

View File

@@ -29,7 +29,7 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
| `GET /admin/<site>` | Admin-Dashboard für einen Standort (`@site_access_required`) | | `GET /admin/<site>` | Admin-Dashboard für einen Standort (`@site_access_required`) |
| `GET /admin/<site>/priority` | Priority-Playlist als separate Seite | | `GET /admin/<site>/priority` | Priority-Playlist als separate Seite |
| `GET /media/<site>/<screen>/<file>` | Medien-Datei ausliefern | | `GET /media/<site>/<screen>/<file>` | Medien-Datei ausliefern |
| `GET /media/priority/<file>` | Priority-Medien (global) | | `GET /media/<site>/priority/<file>` | Priority-Medien des Standorts |
| `GET /media/<site>/background/<filename>` | Hintergrundbild der Willkommensseite | | `GET /media/<site>/background/<filename>` | Hintergrundbild der Willkommensseite |
| `GET /playlist/<site>/<screen>/hash` | Playlist-Checksumme für Auto-Reload + zeichnet Heartbeat auf (Online-Status) | | `GET /playlist/<site>/<screen>/hash` | Playlist-Checksumme für Auto-Reload + zeichnet Heartbeat auf (Online-Status) |
| `GET /willkommen?site=<site>` | Willkommensseite für Standort generieren | | `GET /willkommen?site=<site>` | Willkommensseite für Standort generieren |
@@ -109,10 +109,11 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
- `GET /admin` requires login; redirects to first accessible site for the user. - `GET /admin` requires login; redirects to first accessible site for the user.
- URL playlist items are stored as dicts like `{"url": "https://...", "zoom": 0.8}` and the zoom value must survive save/reorder flows. File items may also be dicts (`{"name": "bild.jpg", ...}`); `save_playlist` copies existing dicts, so extra keys like `schedule` survive reordering. - URL playlist items are stored as dicts like `{"url": "https://...", "zoom": 0.8}` and the zoom value must survive save/reorder flows. File items may also be dicts (`{"name": "bild.jpg", ...}`); `save_playlist` copies existing dicts, so extra keys like `schedule` survive reordering.
- `.html` items in `media/` are rendered inline as content, not in an iframe. - `.html` items in `media/` are rendered inline as content, not in an iframe.
- `config.priority.enabled` makes the priority playlist show on every screen. - `sites.<site>.priority.enabled` makes the priority playlist show on every screen **of that site**.
- `POST /api/customer` generates `welcome.html` and inserts it at the front of the playlist of every target screen for the specified site. Target screens are those with `welcome_enabled`; if no screen of the site has the key at all, the old behaviour applies (`lobby` only). Optional `valid_until` (`"YYYY-MM-DD"`) attaches a schedule so the page disappears on its own. - `POST /api/customer` generates `welcome.html` and inserts it at the front of the playlist of every target screen for the specified site. Target screens are those with `welcome_enabled`; if no screen of the site has the key at all, the old behaviour applies (`lobby` only). Optional `valid_until` (`"YYYY-MM-DD"`) attaches a schedule so the page disappears on its own.
- New standorte can be added by creating `media/<neuer-standort>/<screen>/` directories and optionally adding config to `config.json["sites"][<neuer-standort>]`. - New standorte can be added by creating `media/<neuer-standort>/<screen>/` directories and optionally adding config to `config.json["sites"][<neuer-standort>]`.
- Priority playlist (`config.priority`) is global and affects all sites/screens. - **Die Priority-Playlist gilt je Standort** (`sites.<site>.priority`), nicht global. Frueher lag sie unter `config.priority` und wirkte auf alle Standorte - dadurch lief Stuttgarter Inhalt auch in Karlsruhe. `get_priority_config(cfg, site)` liefert die Konfiguration des Standorts und uebernimmt einen globalen Altbestand beim ersten Zugriff einmalig (mit `deepcopy`, damit Aenderungen nicht zwischen Standorten durchschlagen).
- Priority-Medien liegen unter `media/<site>/priority/`, ausgeliefert ueber `/media/<site>/priority/<datei>`. Die Ordnernamen `priority` und `logos` sind in der Screen-Erkennung ausgenommen - sonst wuerden sie als Screens in der Konfiguration landen.
- Willkommensseite (`customer.html`) accepts up to 3 customer names; logos are fetched via OpenAI→Brandfetch and displayed in a flex row (portrait: column). - Willkommensseite (`customer.html`) accepts up to 3 customer names; logos are fetched via OpenAI→Brandfetch and displayed in a flex row (portrait: column).
- `generate_welcome_html(customer_names, logo_urls, contacts=None, site="stuttgart", background_url=None)` takes lists for up to 3 customers; logos have equal width (280px) with fixed height 120px, text left-aligned below each logo. - `generate_welcome_html(customer_names, logo_urls, contacts=None, site="stuttgart", background_url=None)` takes lists for up to 3 customers; logos have equal width (280px) with fixed height 120px, text left-aligned below each logo.
- Contact info supports Markdown: `**bold**`, `*italic*`, `` `code` ``, `- list items`, and line breaks (preserved as `<br>`). - Contact info supports Markdown: `**bold**`, `*italic*`, `` `code` ``, `- list items`, and line breaks (preserved as `<br>`).
@@ -153,13 +154,20 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
- User-Dropdown im Header: zeigt E-Mail + Role-Badge, Menü mit Passwort ändern, MFA einrichten (mit "Aktiv"-Badge wenn MFA eingeschaltet), Userverwaltung (Admin), Abmelden. - User-Dropdown im Header: zeigt E-Mail + Role-Badge, Menü mit Passwort ändern, MFA einrichten (mit "Aktiv"-Badge wenn MFA eingeschaltet), Userverwaltung (Admin), Abmelden.
- Info-Tab in Screen-Cards: zeigt Client-Info-Tabelle (IP, Browser, Auflösung, zuletzt gesehen) aus dem Heartbeat-JSON für den jeweiligen Screen - Info-Tab in Screen-Cards: zeigt Client-Info-Tabelle (IP, Browser, Auflösung, zuletzt gesehen) aus dem Heartbeat-JSON für den jeweiligen Screen
## Betrieb / Diagnose
- `docker compose up -d` startet nur neu und **baut nicht**. Nach Code-Aenderungen `--build` verwenden, sonst laeuft weiter das alte Image - das hat bei der Fehlersuche mehrfach zu falschen Schluessen gefuehrt.
- Das Dockerfile setzt `PYTHONUNBUFFERED=1`. Ohne das puffert Python `stdout` blockweise (kein TTY) und **keine** `print()`-Ausgabe der App erscheint in `docker compose logs`. Tracebacks waren sichtbar, weil Flask sie ueber stderr schreibt - das machte den blinden Fleck schwer erkennbar.
- gunicorn laeuft mit `--access-logfile -`, protokolliert also jeden Request. Ohne das laesst sich nicht feststellen, ob eine Anfrage den Container ueberhaupt erreicht.
- `add_customer` protokolliert je Kundenfeld `Upload = <dateiname>` bzw. `Upload = keiner`. Ein geleertes `<input type="file">` - etwa nach einem Reload der Seite - sieht serverseitig sonst genauso aus wie "kein Logo hochgeladen".
## Repo Quirks ## Repo Quirks
- `media/` and media file extensions are gitignored. - `media/` and media file extensions are gitignored.
- `users.json` is NOT gitignored (tracked for initial admin setup, contains no secrets by default). - `users.json` is NOT gitignored (tracked for initial admin setup, contains no secrets by default).
- The app has no configured tests, lint, typecheck, formatter, or CI. - The app has no configured tests, lint, typecheck, formatter, or CI.
- Hardcoded secrets exist in tracked files; do not commit new secrets or reshuffle them casually. - Hardcoded secrets exist in tracked files; do not commit new secrets or reshuffle them casually.
- Wichtige Helper-Funktionen in `app.py`: `load_config()`, `save_config()`, `get_site_list()`, `get_screen_config()`, `is_url()`, `normalize_url()`, `playlist_item_name()`, `is_item_active()`, `_schedule_time()`, `_window_start_date()`, `_in_validity_range()`, `load_priority_files()`, `prio_redirect()`, `get_background_url()`, `load_users()`, `save_users()`, `get_user()`, `init_user_db()`, `get_accessible_sites()`, `record_heartbeat()`, `screen_is_active()`, `get_client_info()`, `add_history_entry()`. - Wichtige Helper-Funktionen in `app.py`: `load_config()`, `save_config()`, `get_site_list()`, `get_screen_config()`, `is_url()`, `normalize_url()`, `playlist_item_name()`, `is_item_active()`, `_schedule_time()`, `_window_start_date()`, `_in_validity_range()`, `load_priority_files(site)`, `get_priority_config(cfg, site)`, `priority_dir(site)`, `prio_redirect()`, `get_background_url()`, `load_users()`, `save_users()`, `get_user()`, `init_user_db()`, `get_accessible_sites()`, `record_heartbeat()`, `screen_is_active()`, `get_client_info()`, `add_history_entry()`.
- Zugriffs-Dekoratoren in `app.py`: `admin_required`, `site_access_required`. - Zugriffs-Dekoratoren in `app.py`: `admin_required`, `site_access_required`.
- Hintergrundbild der Willkommensseite wird pro Standort unter `media/<site>/background.*` gespeichert; Fallback auf `static/wallpaper.png` wenn keine Datei existiert. - Hintergrundbild der Willkommensseite wird pro Standort unter `media/<site>/background.*` gespeichert; Fallback auf `static/wallpaper.png` wenn keine Datei existiert.
- `get_background_url(site)` prüft auf benutzerdefiniertes Hintergrundbild für einen Standort. - `get_background_url(site)` prüft auf benutzerdefiniertes Hintergrundbild für einen Standort.

View File

@@ -9,7 +9,7 @@ Browserbasiertes Digital-Signage-System für interne Info-Screens.
- Browserbasierter Player (Kiosk-Modus, Chromium, Firefox) - Browserbasierter Player (Kiosk-Modus, Chromium, Firefox)
- Bilder, Videos (MP4/H.264), HTML-Seiten, URL-Playlist-Einträge - Bilder, Videos (MP4/H.264), HTML-Seiten, URL-Playlist-Einträge
- **Multi-Standort**: Standorte (sites) gruppieren Screens - **Multi-Standort**: Standorte (sites) gruppieren Screens
- **Priority-Playlist**: globale Inhalte wirken auf alle Player - **Priority-Playlist**: pro Standort; die Inhalte laufen auf allen Screens **dieses** Standorts, nicht standortübergreifend
- **Willkommensseite**: bis zu 3 Kundenlogos via OpenAI + Brandfetch oder **manueller Upload mit automatischem Freistellen** (transparenter Hintergrund), pro Standort konfigurierbares Hintergrundbild; erscheint auf allen Screens mit `welcome_enabled` (mehrere möglich), optional mit Ablaufdatum - **Willkommensseite**: bis zu 3 Kundenlogos via OpenAI + Brandfetch oder **manueller Upload mit automatischem Freistellen** (transparenter Hintergrund), pro Standort konfigurierbares Hintergrundbild; erscheint auf allen Screens mit `welcome_enabled` (mehrere möglich), optional mit Ablaufdatum
- Pro Screen eigene Playlist mit Drag-&-Drop-Reihenfolge - Pro Screen eigene Playlist mit Drag-&-Drop-Reihenfolge
- Auto-Reload bei Playlist-Änderungen - Auto-Reload bei Playlist-Änderungen
@@ -53,7 +53,7 @@ Flask App (Server)
│ │ │ ├── bild.jpg │ │ │ ├── bild.jpg
│ │ │ ├── video.mp4 │ │ │ ├── video.mp4
│ │ │ └── welcome.html │ │ │ └── welcome.html
│ └── priority/ │ │ └── priority/
└── generate_welcome_page.py └── generate_welcome_page.py
``` ```
@@ -75,10 +75,13 @@ signage/
├── history.json # Aktivitätsverlauf (Dashboard) ├── history.json # Aktivitätsverlauf (Dashboard)
├── media/ ├── media/
│ ├── <site>/ │ ├── <site>/
│ │ ├── lobby/ │ │ ├── lobby/ # Screen-Ordner (jeder Unterordner = ein Screen!)
│ │ ├── casino/ │ │ ├── casino/
│ │ └── videosysteme/ │ │ ├── videosysteme/
│ └── priority/ │ │ ├── background.jpg # Hintergrundbild der Willkommensseite (optional)
│ │ ├── priority/ # Priority-Medien dieses Standorts
│ │ ├── logo_1.png # hochgeladene Kundenlogos (Slot 1-3)
│ │ └── welcome.html # Standort-Kopie der Willkommensseite
├── templates/ ├── templates/
│ ├── admin.html # Admin-Dashboard (Übersicht) │ ├── admin.html # Admin-Dashboard (Übersicht)
│ ├── admin_dashboard.html # Admin Dashboard (Statistiken + Verlauf) │ ├── admin_dashboard.html # Admin Dashboard (Statistiken + Verlauf)
@@ -175,6 +178,25 @@ docker compose up -d
Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User). Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User).
**Wichtig:** `docker compose up -d` startet nur neu, es baut nicht. Nach Code-Änderungen:
```bash
docker compose up -d --build
```
### Logs
```bash
docker compose logs -f signage
```
Der Container ist so eingestellt, dass dort alles Nötige ankommt:
- `ENV PYTHONUNBUFFERED=1` im Dockerfile – ohne das puffert Python `stdout` blockweise, sobald es kein Terminal ist, und sämtliche `print()`-Ausgaben der App bleiben unsichtbar.
- `gunicorn --access-logfile -` – protokolliert jeden Request. Ohne das ist nicht einmal erkennbar, ob eine Anfrage den Container überhaupt erreicht.
Beides fehlte anfangs und machte die Fehlersuche unnötig schwer.
--- ---
## Routen ## Routen
@@ -220,8 +242,10 @@ Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User
| `POST /admin/users/reset-password/<email>` | Passwort-Reset mit temporärem Passwort (Admin) | | `POST /admin/users/reset-password/<email>` | Passwort-Reset mit temporärem Passwort (Admin) |
| `POST /admin/users/edit/<email>` | User bearbeiten speichern (Admin) | | `POST /admin/users/edit/<email>` | User bearbeiten speichern (Admin) |
| `GET /media/<site>/<screen>/<file>` | Medien-Datei ausliefern | | `GET /media/<site>/<screen>/<file>` | Medien-Datei ausliefern |
| `GET /media/priority/<file>` | Priority-Medien (global) | | `GET /media/<site>/priority/<file>` | Priority-Medien des Standorts |
| `GET /media/<site>/logos/<file>` | Manuell hochgeladenes Kundenlogo | | `GET /media/<site>/logos/<file>` | Manuell hochgeladenes Kundenlogo |
| `GET /admin/help` | Hilfeseite zur Bedienung |
| `GET /customer` | Alias für `/willkommen` |
| `GET /media/<site>/background/<filename>` | Hintergrundbild der Willkommensseite | | `GET /media/<site>/background/<filename>` | Hintergrundbild der Willkommensseite |
### Heartbeat / Online-Status ### Heartbeat / Online-Status
@@ -403,10 +427,6 @@ OPENAI_API_KEY=sk-...
"background_url": null "background_url": null
} }
} }
},
"priority": {
"enabled": true,
"playlist": [...]
} }
} }
``` ```
@@ -436,6 +456,30 @@ OPENAI_API_KEY=sk-...
| `voice_agent_target` | string | `"overlay"` (iframe) oder `"redirect"` | | `voice_agent_target` | string | `"overlay"` (iframe) oder `"redirect"` |
| `voice_agent_position` | string | Position des Buttons: `top-left`, `top-center`, `top-right`, `middle-left`, `middle-center`, `middle-right`, `bottom-left`, `bottom-center`, `bottom-right` | | `voice_agent_position` | string | Position des Buttons: `top-left`, `top-center`, `top-right`, `middle-left`, `middle-center`, `middle-right`, `bottom-left`, `bottom-center`, `bottom-right` |
## Priority-Playlist (pro Standort)
Liegt in `config.json` unter `sites.<site>.priority`, die Medien unter `media/<site>/priority/`:
```jsonc
"sites": {
"stuttgart": {
"priority": {
"enabled": true, // schaltet die Priority-Playlist dieses Standorts
"playlist": [ ... ] // gleiches Eintragsformat wie eine Screen-Playlist
},
"screens": { ... }
}
}
```
Die Einträge laufen auf allen Screens **dieses** Standorts, eingestreut zwischen den normalen Playlist-Einträgen. Ein anderer Standort ist davon nicht betroffen.
> Früher lag die Priority-Playlist global unter `config.priority` und wirkte auf *alle* Standorte. `get_priority_config()` übernimmt einen solchen Altbestand beim ersten Zugriff einmalig in den jeweiligen Standort.
Die Ordnernamen `priority` und `logos` sind unter `media/<site>/` **reserviert** und werden bei der Screen-Erkennung übersprungen – sonst würden sie als Screens in der Oberfläche auftauchen.
---
<a id="playlist-eintraege"></a> <a id="playlist-eintraege"></a>
## Playlist-Einträge ## Playlist-Einträge
@@ -507,7 +551,9 @@ Optional pro Eintrag. Ohne `schedule` läuft der Eintrag immer (sofern `enabled`
- `_window_start_date(now, start, end)` – Kalendertag, an dem das laufende Zeitfenster begann (`None` = ausserhalb) - `_window_start_date(now, start, end)` – Kalendertag, an dem das laufende Zeitfenster begann (`None` = ausserhalb)
- `_in_validity_range(schedule, day)` – optionalen Gültigkeitszeitraum prüfen - `_in_validity_range(schedule, day)` – optionalen Gültigkeitszeitraum prüfen
- `schedule_text(schedule)` – Jinja-Filter: Klartext einer Zeitsteuerung für Tooltips - `schedule_text(schedule)` – Jinja-Filter: Klartext einer Zeitsteuerung für Tooltips
- `load_priority_files()` – Priority-Playlist + Dateien laden - `load_priority_files(site)` – Priority-Playlist eines Standorts + Dateien laden
- `get_priority_config(cfg, site)` – Priority-Konfiguration des Standorts (übernimmt einmalig einen globalen Altbestand)
- `priority_dir(site)` – Pfad zu `media/<site>/priority/`
- `welcome_target_screens(config, site)` – Zielscreens der Willkommensseite (Fallback `lobby`) - `welcome_target_screens(config, site)` – Zielscreens der Willkommensseite (Fallback `lobby`)
- `save_uploaded_logo(file, site, slot, freistellen=True)` – hochgeladenes Logo freistellen und als PNG ablegen - `save_uploaded_logo(file, site, slot, freistellen=True)` – hochgeladenes Logo freistellen und als PNG ablegen
- `remove_logo_background(image)` – Hintergrund per Flood Fill entfernen, gibt `(bild, hinweis)` zurück - `remove_logo_background(image)` – Hintergrund per Flood Fill entfernen, gibt `(bild, hinweis)` zurück

75
app.py
View File

@@ -10,6 +10,7 @@ zugeschaltet werden, die auf allen Playern abwechselnd eingeblendet wird.
import os import os
import json import json
import copy
import hashlib import hashlib
import re import re
import time import time
@@ -380,16 +381,38 @@ def is_item_active(item):
# ------------------------------------------------- # -------------------------------------------------
# Priority-Playlist-Helfer # Priority-Playlist-Helfer
# ------------------------------------------------- # -------------------------------------------------
def load_priority_files(): PRIORITY_DIRNAME = "priority"
"""Lädt die Priority-Playlist aus config.json plus die dazugehörigen Dateien
aus media/priority/. Gibt eine Liste von Dicts mit name/type/size/enabled zurück.
def priority_dir(site):
"""Verzeichnis der Priority-Medien eines Standorts: media/<site>/priority/."""
return os.path.join(MEDIA_DIR, site, PRIORITY_DIRNAME)
def get_priority_config(cfg, site):
"""Priority-Konfiguration eines Standorts (erzeugt fehlende Ebenen).
Die Priority-Playlist gilt ausschliesslich fuer den eigenen Standort.
Aeltere Konfigurationen hatten sie global unter cfg["priority"]; ist beim
Standort noch nichts hinterlegt, wird der Altbestand einmalig uebernommen."""
site_cfg = cfg.setdefault("sites", {}).setdefault(site, {})
if "priority" not in site_cfg:
alt = cfg.get("priority")
site_cfg["priority"] = copy.deepcopy(alt) if isinstance(alt, dict) else {}
return site_cfg["priority"]
def load_priority_files(site):
"""Lädt die Priority-Playlist eines Standorts aus config.json plus die
dazugehörigen Dateien aus media/<site>/priority/.
Gibt eine Liste von Dicts mit name/type/size/enabled zurück.
Neue Dateien im Ordner, die noch nicht in der Playlist sind, werden angehängt.""" Neue Dateien im Ordner, die noch nicht in der Playlist sind, werden angehängt."""
cfg = load_config() cfg = load_config()
prio_dir = os.path.join(MEDIA_DIR, "priority") prio_dir = priority_dir(site)
os.makedirs(prio_dir, exist_ok=True) os.makedirs(prio_dir, exist_ok=True)
priority_files = [] priority_files = []
prio_playlist = cfg.get("priority", {}).get("playlist", []) prio_playlist = get_priority_config(cfg, site).get("playlist", [])
# 1. Einträge aus der config.json-Playlist # 1. Einträge aus der config.json-Playlist
for item in prio_playlist: for item in prio_playlist:
@@ -1143,11 +1166,10 @@ def api_add_customer():
# ------------------------------------------------- # -------------------------------------------------
# Medien ausliefern (statische Dateien) # Medien ausliefern (statische Dateien)
# ------------------------------------------------- # -------------------------------------------------
@app.route("/media/priority/<path:filename>") @app.route("/media/<site>/priority/<path:filename>")
def media_priority(filename): def media_priority(site, filename):
"""Liefert globale Priority-Medien aus media/priority/ aus.""" """Liefert die Priority-Medien eines Standorts aus media/<site>/priority/ aus."""
path = os.path.join(MEDIA_DIR, "priority") return send_from_directory(priority_dir(site), filename)
return send_from_directory(path, filename)
@app.route("/media/<site>/<screen>/<path:filename>") @app.route("/media/<site>/<screen>/<path:filename>")
@@ -1229,7 +1251,7 @@ def player(site, screen):
normal_files.append({"kind": "file", "name": f}) normal_files.append({"kind": "file", "name": f})
# ─── Priority-Playlist (global) ─── # ─── Priority-Playlist (global) ───
priority_cfg = config.get("priority", {}) priority_cfg = get_priority_config(config, site)
if priority_cfg.get("enabled", False): if priority_cfg.get("enabled", False):
prio_files = [] prio_files = []
@@ -1243,7 +1265,7 @@ def player(site, screen):
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}) 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 continue
file_path = os.path.join(MEDIA_DIR, "priority", item_name) file_path = os.path.join(priority_dir(site), item_name)
if os.path.exists(file_path): if os.path.exists(file_path):
prio_files.append({"kind": "file", "name": item_name, "enabled": True}) prio_files.append({"kind": "file", "name": item_name, "enabled": True})
else: else:
@@ -1296,7 +1318,7 @@ def playlist_hash(site, screen):
screen_playlist = ( screen_playlist = (
config.get("sites", {}).get(site, {}).get("screens", {}).get(screen, {}).get("playlist", []) config.get("sites", {}).get(site, {}).get("screens", {}).get(screen, {}).get("playlist", [])
) )
prio_cfg = config.get("priority", {}) prio_cfg = get_priority_config(config, site)
prio_playlist = prio_cfg.get("playlist", []) if prio_cfg.get("enabled", False) else [] prio_playlist = prio_cfg.get("playlist", []) if prio_cfg.get("enabled", False) else []
relevant = { relevant = {
@@ -1409,15 +1431,17 @@ def admin(site):
config_screens = site_cfg.setdefault("screens", {}) config_screens = site_cfg.setdefault("screens", {})
# Ordner im Medienverzeichnis = Screens. # Ordner im Medienverzeichnis = Screens.
# "logos" ist reserviert: aeltere Versionen legten dort hochgeladene # Reservierte Namen: "logos" (hochgeladene Kundenlogos) und "priority"
# Kundenlogos ab, und ein solcher Ordner wuerde sonst dauerhaft als Screen # (Priority-Medien des Standorts). Solche Ordner wuerden sonst dauerhaft als
# in der Konfiguration landen. # Screen in der Konfiguration landen; "priority" ist ausserdem in den Routen
# bereits als Kennzeichen fuer die Priority-Playlist belegt.
reserviert = {generate_welcome_page.LOGO_URL_PREFIX, PRIORITY_DIRNAME}
if os.path.isdir(site_media_base): if os.path.isdir(site_media_base):
for entry in os.listdir(site_media_base): for entry in os.listdir(site_media_base):
entry_path = os.path.join(site_media_base, entry) entry_path = os.path.join(site_media_base, entry)
if (os.path.isdir(entry_path) if (os.path.isdir(entry_path)
and not entry.startswith("._") and not entry.startswith("._")
and entry != generate_welcome_page.LOGO_URL_PREFIX): and entry not in reserviert):
known_screens.add(entry) known_screens.add(entry)
# Screens aus Config ergänzen (auch ohne Ordner) # Screens aus Config ergänzen (auch ohne Ordner)
@@ -1523,7 +1547,7 @@ def admin_priority(site):
"""Zeigt die Priority-Playlist-Seite mit Tabs (Playlist + Medien).""" """Zeigt die Priority-Playlist-Seite mit Tabs (Playlist + Medien)."""
cfg = load_config() cfg = load_config()
site_list = get_accessible_sites(cfg, current_user) site_list = get_accessible_sites(cfg, current_user)
priority_files = load_priority_files() priority_files = load_priority_files(site)
return render_template( return render_template(
"priority.html", "priority.html",
@@ -1719,7 +1743,8 @@ def upload(site, screen):
filename = secure_filename(file.filename) filename = secure_filename(file.filename)
if screen == "priority": if screen == "priority":
file.save(os.path.join(MEDIA_DIR, "priority", filename)) os.makedirs(priority_dir(site), exist_ok=True)
file.save(os.path.join(priority_dir(site), filename))
else: else:
file.save(os.path.join(MEDIA_DIR, site, screen, filename)) file.save(os.path.join(MEDIA_DIR, site, screen, filename))
@@ -1790,7 +1815,7 @@ def add_url(site, screen):
config = load_config() config = load_config()
if screen == "priority": if screen == "priority":
config.setdefault("priority", {}).setdefault("playlist", []).append(url_data) get_priority_config(config, site).setdefault("playlist", []).append(url_data)
else: else:
cfg = get_screen_config(config, site, screen) cfg = get_screen_config(config, site, screen)
cfg.setdefault("playlist", []).append(url_data) cfg.setdefault("playlist", []).append(url_data)
@@ -1809,8 +1834,8 @@ def delete_file(site, screen, filename):
cfg = load_config() cfg = load_config()
if screen == "priority": if screen == "priority":
path = os.path.join(MEDIA_DIR, "priority", filename) path = os.path.join(priority_dir(site), filename)
playlist = cfg.get("priority", {}).get("playlist", []) playlist = get_priority_config(cfg, site).get("playlist", [])
playlist[:] = [item for item in playlist if not ( playlist[:] = [item for item in playlist if not (
(isinstance(item, str) and item == filename) or (isinstance(item, str) and item == filename) or
(isinstance(item, dict) and item.get("url") == filename) (isinstance(item, dict) and item.get("url") == filename)
@@ -1847,7 +1872,7 @@ def save_playlist(site, screen):
enabled_map = data.get("enabled", {}) enabled_map = data.get("enabled", {})
if screen == "priority": if screen == "priority":
old_playlist = cfg.get("priority", {}).get("playlist", []) old_playlist = get_priority_config(cfg, site).get("playlist", [])
else: else:
screens_cfg = cfg.get("sites", {}).get(site, {}).get("screens", {}) screens_cfg = cfg.get("sites", {}).get(site, {}).get("screens", {})
old_playlist = screens_cfg.get(screen, {}).get("playlist", []) old_playlist = screens_cfg.get(screen, {}).get("playlist", [])
@@ -1868,7 +1893,7 @@ def save_playlist(site, screen):
new_playlist.append({"url": key, "zoom": 1.0, "enabled": enabled_map.get(key, True)} if is_url(key) else {"name": key, "enabled": enabled_map.get(key, True)}) new_playlist.append({"url": key, "zoom": 1.0, "enabled": enabled_map.get(key, True)} if is_url(key) else {"name": key, "enabled": enabled_map.get(key, True)})
if screen == "priority": if screen == "priority":
cfg.setdefault("priority", {})["playlist"] = new_playlist get_priority_config(cfg, site)["playlist"] = new_playlist
else: else:
cfg.setdefault("sites", {}).setdefault(site, {}).setdefault("screens", {}).setdefault(screen, {})["playlist"] = new_playlist cfg.setdefault("sites", {}).setdefault(site, {}).setdefault("screens", {}).setdefault(screen, {})["playlist"] = new_playlist
@@ -1907,7 +1932,7 @@ def update_playlist_item(site, screen):
config = load_config() config = load_config()
if screen == "priority": if screen == "priority":
playlist = config.setdefault("priority", {}).setdefault("playlist", []) playlist = get_priority_config(config, site).setdefault("playlist", [])
else: else:
playlist = config["sites"][site]["screens"][screen].setdefault("playlist", []) playlist = config["sites"][site]["screens"][screen].setdefault("playlist", [])

View File

@@ -170,12 +170,13 @@
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header"><h3 class="card-title" id="help-priority"><i class="ti ti-player-play me-1"></i>Priority-Playlist</h3></div> <div class="card-header"><h3 class="card-title" id="help-priority"><i class="ti ti-player-play me-1"></i>Priority-Playlist</h3></div>
<div class="card-body"> <div class="card-body">
<p>Die Priority-Playlist ist <strong>global</strong> und wird auf <em>allen</em> Playern eines Standorts eingeblendet – zwischen den normalen Playlist-Einträgen.</p> <p>Die Priority-Playlist gilt <strong>pro Standort</strong>. Ihre Inhalte werden auf <em>allen</em> Screens dieses Standorts eingeblendet – zwischen den normalen Playlist-Einträgen.</p>
<ul> <ul>
<li>Aufruf über <code>/admin/&lt;standort&gt;/priority</code></li> <li>Aufruf über <code>/admin/&lt;standort&gt;/priority</code></li>
<li>Globale Medien und URLs verwalten, ebenfalls per Drag & Drop</li> <li>Medien und URLs dieses Standorts verwalten, ebenfalls per Drag & Drop</li>
<li>Priority muss in der Konfiguration aktiviert sein (<code>config.json → priority.enabled</code>)</li> <li>Priority muss für den Standort aktiviert sein (<code>config.json → sites.&lt;standort&gt;.priority.enabled</code>)</li>
<li>Die <a href="#help-zeitsteuerung">Zeitsteuerung</a> steht hier genauso zur Verfügung wie in den Screen-Playlists</li> <li>Die <a href="#help-zeitsteuerung">Zeitsteuerung</a> steht hier genauso zur Verfügung wie in den Screen-Playlists</li>
<li>Jeder Standort hat seine <strong>eigene</strong> Priority-Playlist – Inhalte eines Standorts erscheinen nie auf den Screens eines anderen.</li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -472,7 +472,7 @@ function playNext() {
} }
// Datei aus dem Medienverzeichnis // Datei aus dem Medienverzeichnis
const src = entry.isPrio ? `/media/priority/${item.name}` : `/media/${site}/${basePath}/${item.name}`; const src = `/media/${site}/${basePath}/${item.name}`;
if (isVideo(item)) { if (isVideo(item)) {
vid.style.display = "block"; vid.style.display = "block";
vid.src = src; vid.src = src;

View File

@@ -147,7 +147,7 @@
{% if file.type == "image" %} {% if file.type == "image" %}
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-html="true" data-bs-html="true"
title="<img src='/media/priority/{{ file.name }}' style='max-width: 200px; max-height: 150px; border: none; background: white; padding: 2px;' alt='Vorschau'>" title="<img src='/media/{{ current_site }}/priority/{{ file.name }}' style='max-width: 200px; max-height: 150px; border: none; background: white; padding: 2px;' alt='Vorschau'>"
{% elif file.type == "url" %} {% elif file.type == "url" %}
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}" title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}"