diff --git a/AGENTS.md b/AGENTS.md index 9b7bbfe..8248bc2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens. | `GET /admin/` | Admin-Dashboard für einen Standort (`@site_access_required`) | | `GET /admin//priority` | Priority-Playlist als separate Seite | | `GET /media///` | Medien-Datei ausliefern | -| `GET /media/priority/` | Priority-Medien (global) | +| `GET /media//priority/` | Priority-Medien des Standorts | | `GET /media//background/` | Hintergrundbild der Willkommensseite | | `GET /playlist///hash` | Playlist-Checksumme für Auto-Reload + zeichnet Heartbeat auf (Online-Status) | | `GET /willkommen?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. - 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. -- `config.priority.enabled` makes the priority playlist show on every screen. +- `sites..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. - New standorte can be added by creating `media///` directories and optionally adding config to `config.json["sites"][]`. -- Priority playlist (`config.priority`) is global and affects all sites/screens. +- **Die Priority-Playlist gilt je Standort** (`sites..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//priority/`, ausgeliefert ueber `/media//priority/`. 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). - `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 `
`). @@ -166,7 +167,7 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens. - `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. - 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`. - Hintergrundbild der Willkommensseite wird pro Standort unter `media//background.*` gespeichert; Fallback auf `static/wallpaper.png` wenn keine Datei existiert. - `get_background_url(site)` prüft auf benutzerdefiniertes Hintergrundbild für einen Standort. diff --git a/README.md b/README.md index 7265c1f..d22d7e9 100755 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Browserbasiertes Digital-Signage-System für interne Info-Screens. - Browserbasierter Player (Kiosk-Modus, Chromium, Firefox) - Bilder, Videos (MP4/H.264), HTML-Seiten, URL-Playlist-Einträge - **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 - Pro Screen eigene Playlist mit Drag-&-Drop-Reihenfolge - Auto-Reload bei Playlist-Änderungen @@ -53,7 +53,7 @@ Flask App (Server) │ │ │ ├── bild.jpg │ │ │ ├── video.mp4 │ │ │ └── welcome.html -│ └── priority/ +│ │ └── priority/ └── generate_welcome_page.py ``` @@ -79,9 +79,9 @@ signage/ │ │ ├── casino/ │ │ ├── videosysteme/ │ │ ├── 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 -│ └── priority/ ├── templates/ │ ├── admin.html # Admin-Dashboard (Übersicht) │ ├── admin_dashboard.html # Admin Dashboard (Statistiken + Verlauf) @@ -242,7 +242,7 @@ Beides fehlte anfangs und machte die Fehlersuche unnötig schwer. | `POST /admin/users/reset-password/` | Passwort-Reset mit temporärem Passwort (Admin) | | `POST /admin/users/edit/` | User bearbeiten speichern (Admin) | | `GET /media///` | Medien-Datei ausliefern | -| `GET /media/priority/` | Priority-Medien (global) | +| `GET /media//priority/` | Priority-Medien des Standorts | | `GET /media//logos/` | Manuell hochgeladenes Kundenlogo | | `GET /admin/help` | Hilfeseite zur Bedienung | | `GET /customer` | Alias für `/willkommen` | @@ -427,10 +427,6 @@ OPENAI_API_KEY=sk-... "background_url": null } } - }, - "priority": { - "enabled": true, - "playlist": [...] } } ``` @@ -460,6 +456,30 @@ OPENAI_API_KEY=sk-... | `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` | +## Priority-Playlist (pro Standort) + +Liegt in `config.json` unter `sites..priority`, die Medien unter `media//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//` **reserviert** und werden bei der Screen-Erkennung übersprungen – sonst würden sie als Screens in der Oberfläche auftauchen. + +--- + ## Playlist-Einträge @@ -531,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) - `_in_validity_range(schedule, day)` – optionalen Gültigkeitszeitraum prüfen - `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//priority/` - `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 - `remove_logo_background(image)` – Hintergrund per Flood Fill entfernen, gibt `(bild, hinweis)` zurück diff --git a/app.py b/app.py index 53b5cff..8d2cd5f 100755 --- a/app.py +++ b/app.py @@ -10,6 +10,7 @@ zugeschaltet werden, die auf allen Playern abwechselnd eingeblendet wird. import os import json +import copy import hashlib import re import time @@ -380,16 +381,38 @@ def is_item_active(item): # ------------------------------------------------- # Priority-Playlist-Helfer # ------------------------------------------------- -def load_priority_files(): - """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. +PRIORITY_DIRNAME = "priority" + + +def priority_dir(site): + """Verzeichnis der Priority-Medien eines Standorts: media//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//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.""" cfg = load_config() - prio_dir = os.path.join(MEDIA_DIR, "priority") + prio_dir = priority_dir(site) os.makedirs(prio_dir, exist_ok=True) 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 for item in prio_playlist: @@ -1143,11 +1166,10 @@ def api_add_customer(): # ------------------------------------------------- # Medien ausliefern (statische Dateien) # ------------------------------------------------- -@app.route("/media/priority/") -def media_priority(filename): - """Liefert globale Priority-Medien aus media/priority/ aus.""" - path = os.path.join(MEDIA_DIR, "priority") - return send_from_directory(path, filename) +@app.route("/media//priority/") +def media_priority(site, filename): + """Liefert die Priority-Medien eines Standorts aus media//priority/ aus.""" + return send_from_directory(priority_dir(site), filename) @app.route("/media///") @@ -1229,7 +1251,7 @@ def player(site, screen): normal_files.append({"kind": "file", "name": f}) # ─── Priority-Playlist (global) ─── - priority_cfg = config.get("priority", {}) + priority_cfg = get_priority_config(config, site) if priority_cfg.get("enabled", False): 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}) 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): prio_files.append({"kind": "file", "name": item_name, "enabled": True}) else: @@ -1296,7 +1318,7 @@ def playlist_hash(site, screen): screen_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 [] relevant = { @@ -1409,15 +1431,17 @@ def admin(site): config_screens = site_cfg.setdefault("screens", {}) # Ordner im Medienverzeichnis = Screens. - # "logos" ist reserviert: aeltere Versionen legten dort hochgeladene - # Kundenlogos ab, und ein solcher Ordner wuerde sonst dauerhaft als Screen - # in der Konfiguration landen. + # Reservierte Namen: "logos" (hochgeladene Kundenlogos) und "priority" + # (Priority-Medien des Standorts). Solche Ordner wuerden sonst dauerhaft als + # 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): for entry in os.listdir(site_media_base): entry_path = os.path.join(site_media_base, entry) if (os.path.isdir(entry_path) and not entry.startswith("._") - and entry != generate_welcome_page.LOGO_URL_PREFIX): + and entry not in reserviert): known_screens.add(entry) # 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).""" cfg = load_config() site_list = get_accessible_sites(cfg, current_user) - priority_files = load_priority_files() + priority_files = load_priority_files(site) return render_template( "priority.html", @@ -1719,7 +1743,8 @@ def upload(site, screen): filename = secure_filename(file.filename) 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: file.save(os.path.join(MEDIA_DIR, site, screen, filename)) @@ -1790,7 +1815,7 @@ def add_url(site, screen): config = load_config() if screen == "priority": - config.setdefault("priority", {}).setdefault("playlist", []).append(url_data) + get_priority_config(config, site).setdefault("playlist", []).append(url_data) else: cfg = get_screen_config(config, site, screen) cfg.setdefault("playlist", []).append(url_data) @@ -1809,8 +1834,8 @@ def delete_file(site, screen, filename): cfg = load_config() if screen == "priority": - path = os.path.join(MEDIA_DIR, "priority", filename) - playlist = cfg.get("priority", {}).get("playlist", []) + path = os.path.join(priority_dir(site), filename) + playlist = get_priority_config(cfg, site).get("playlist", []) playlist[:] = [item for item in playlist if not ( (isinstance(item, str) and item == filename) or (isinstance(item, dict) and item.get("url") == filename) @@ -1847,7 +1872,7 @@ def save_playlist(site, screen): enabled_map = data.get("enabled", {}) if screen == "priority": - old_playlist = cfg.get("priority", {}).get("playlist", []) + old_playlist = get_priority_config(cfg, site).get("playlist", []) else: screens_cfg = cfg.get("sites", {}).get(site, {}).get("screens", {}) 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)}) if screen == "priority": - cfg.setdefault("priority", {})["playlist"] = new_playlist + get_priority_config(cfg, site)["playlist"] = new_playlist else: 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() if screen == "priority": - playlist = config.setdefault("priority", {}).setdefault("playlist", []) + playlist = get_priority_config(config, site).setdefault("playlist", []) else: playlist = config["sites"][site]["screens"][screen].setdefault("playlist", []) diff --git a/templates/help.html b/templates/help.html index f261472..7d32b8e 100644 --- a/templates/help.html +++ b/templates/help.html @@ -170,12 +170,13 @@

Priority-Playlist

-

Die Priority-Playlist ist global und wird auf allen Playern eines Standorts eingeblendet – zwischen den normalen Playlist-Einträgen.

+

Die Priority-Playlist gilt pro Standort. Ihre Inhalte werden auf allen Screens dieses Standorts eingeblendet – zwischen den normalen Playlist-Einträgen.

  • Aufruf über /admin/<standort>/priority
  • -
  • Globale Medien und URLs verwalten, ebenfalls per Drag & Drop
  • -
  • Priority muss in der Konfiguration aktiviert sein (config.json → priority.enabled)
  • +
  • Medien und URLs dieses Standorts verwalten, ebenfalls per Drag & Drop
  • +
  • Priority muss für den Standort aktiviert sein (config.json → sites.<standort>.priority.enabled)
  • Die Zeitsteuerung steht hier genauso zur Verfügung wie in den Screen-Playlists
  • +
  • Jeder Standort hat seine eigene Priority-Playlist – Inhalte eines Standorts erscheinen nie auf den Screens eines anderen.
diff --git a/templates/player.html b/templates/player.html index efa1360..0edb8a8 100755 --- a/templates/player.html +++ b/templates/player.html @@ -472,7 +472,7 @@ function playNext() { } // 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)) { vid.style.display = "block"; vid.src = src; diff --git a/templates/priority.html b/templates/priority.html index 9b37ee9..b1263b8 100644 --- a/templates/priority.html +++ b/templates/priority.html @@ -147,7 +147,7 @@ {% if file.type == "image" %} data-bs-toggle="tooltip" data-bs-html="true" - title="Vorschau" + title="Vorschau" {% elif file.type == "url" %} data-bs-toggle="tooltip" title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}"