Compare commits
2 Commits
1dbb88538d
...
d2938f87f3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2938f87f3 | ||
|
|
a9cbe42df9 |
16
AGENTS.md
16
AGENTS.md
@@ -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.
|
||||||
|
|||||||
68
README.md
68
README.md
@@ -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
75
app.py
@@ -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", [])
|
||||||
|
|
||||||
|
|||||||
@@ -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/<standort>/priority</code></li>
|
<li>Aufruf über <code>/admin/<standort>/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.<standort>.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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 %}"
|
||||||
|
|||||||
Reference in New Issue
Block a user