Files
signage/README.md
2026-09-23 19:55:53 +02:00

628 lines
33 KiB
Markdown
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CANCOM Simple Signage
Browserbasiertes Digital-Signage-System für interne Info-Screens.
---
## Features
- 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**: pro Standort; die Inhalte laufen auf allen Screens **dieses** Standorts, nicht standortübergreifend. Sie werden zusätzlich als **reine Vorschau** oberhalb jeder Screen-Playlist angezeigt (grau, mit „Prio"-Badge), damit sie beim Bearbeiten nicht übersehen werden – bedient wird weiterhin nur auf der Priority-Seite
- **Willkommensseite** (ohne Login erreichbar): 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
- Newsticker pro Screen
- **Custom-URL-Button**: pro Screen konfigurierbarer Aktions-Button mit frei wählbarer Position (9 Positionen) im Player (öffnet URL in iframe-Overlay mit Zurück-Button oder per Direkt-Weiterleitung)
- **Digital Voice Agent**: global konfigurierbarer Voice-Agent-Button pro Screen (9 Positionen, iframe-Overlay mit positionsgetreuem Zurück-Button, Typewriter-Tagline mit mehrsprachigen Wechseltexten, optionales Bild über dem Button)
- **Stay-on-First**: Screen kann auf erstem Playlist-Element stehen bleiben (kein automatischer Refresh)
- **Online/Offline-Status**: Player-Status wird via dateibasiertem Heartbeat ermittelt – grün (online) bei aktivem Player, rot (offline) nach 60s ohne Poll
- **Client-Info**: Tooltip beim Hovern über den Online-Button zeigt Browser, Bildschirmauflösung, IP-Adresse und letzten Heartbeat-Zeitpunkt; Info-Tab pro Screen zeigt detaillierte Client-Info-Tabelle
- **User-Verwaltung**: Mehrere User mit Rollen (Admin / Superuser / User), E-Mail als Login, Passwort-Hashing (scrypt), Berechtigungen pro Standort
- **Passwort-Workflow**: First-Login-Änderung, Admin-Reset mit temporärem Passwort, Notfall-Login bei fehlendem Hash
- **MFA (Multi-Faktor)**: Optionale TOTP-Authentifizierung (Google Authenticator, Authy) per User aktivierbar; QR-Code-Scan + Code-Verifikation, 8 Recovery-Codes, Deaktivierung mit Passwort-Bestätigung, **Trusted Device** (Gerät für 1–30 Tage speichern, überspringt MFA beim nächsten Login)
- **Admin Dashboard**: `/admin/dashboard` – Statistiken (Sites, Screens, User, Admins, Superuser) mit Sparkline-Charts + Trendanzeige, Aktivitätsverlauf mit Seitennavigation
- **Aktivitätsverlauf**: Alle Erstell-/Lösch-/Änderungsaktionen sowie Login/Logout werden in `history.json` protokolliert (bis zu 1000 Einträge) und im Dashboard seitenweise angezeigt (50 je Seite)
- Tab-basierte Admin-UI pro Screen: Playlist, Einstellungen, Aktionen, Digital Voice Agent, Medien, Info (Tabler Tabs)
- **Zeitsteuerung**: Pro Playlist-Eintrag (URL *und* Datei, auch in der Priority-Playlist) ein Zeitfenster festlegen – einmalig (Datum + Uhrzeit), täglich oder wöchentlich (Uhrzeit + optionaler Gültigkeitszeitraum, wöchentlich zusätzlich mit Wochentagen). Fenster über Mitternacht werden unterstützt. Ein "Zeit"-Badge in der Playlist-Zeile zeigt die Einstellung im Tooltip
- **Query-Parameter-Passthrough**: Per Player-URL übergebene Query-Parameter (z. B. `/player/stuttgart/lobby?customer=acme`) können pro Playlist-URL im Edit-Modal aktiviert werden – standardmäßig aus. Link-Icon in der Playlist-Zeile zeigt Status (an/aus) an.
- Priority-Seite ebenfalls mit Tabs: Playlist und Medien
- Dark Mode (localStorage-persistiert)
- CI-konformes Admin-UI (CANCOM-Design: `brand-surface`, `nav-surface` rot)
---
## Architektur
```
Browser (Player)
│
▼
Flask App (Server)
├── Admin UI /admin/<site>
├── Player UI /player/<site>/<screen>
├── Priority-Seite /admin/<site>/priority
├── config.json
├── users.json
├── history.json
├── /tmp/signage-heartbeat/ (Heartbeat-JSONs mit Client-Info für Online-Status)
├── media/
│ ├── <site>/
│ │ ├── <screen>/
│ │ │ ├── bild.jpg
│ │ │ ├── video.mp4
│ │ │ └── welcome.html
│ │ └── priority/
└── generate_welcome_page.py
```
- **Server:** Python 3 + Flask
- **Player:** Jeder moderne Browser (Chrome Kiosk, Edge, Firefox)
- **State:** `config.json` + Dateisystem
- **Frontend:** Tabler Core + Tabler Icons + SortableJS (CDN)
---
## Projektstruktur
```
signage/
├── app.py # Flask-App (alle Routen)
├── generate_welcome_page.py # Logo-Suche + Willkommensseite-Generierung
├── config.json # Persistente Konfiguration
├── users.json # User-Datenbank (gehashte Passwörter)
├── history.json # Aktivitätsverlauf (Dashboard)
├── media/
│ ├── <site>/
│ │ ├── lobby/ # Screen-Ordner (jeder Unterordner = ein Screen!)
│ │ ├── 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
├── templates/
│ ├── admin.html # Admin-Dashboard (Übersicht)
│ ├── admin_dashboard.html # Admin Dashboard (Statistiken + Verlauf)
│ ├── priority.html # Priority-Playlist (eigene Seite)
│ ├── customer.html # Willkommensseite-Formular
│ ├── player.html # Player-Ansicht
│ ├── login.html
│ ├── change_password.html # Passwort ändern (First-Login / Reset)
│ ├── user_list.html # User-Liste (Admin)
│ ├── user_create.html # User anlegen (Admin)
│ ├── user_edit.html # User bearbeiten (Admin)
│ ├── mfa_setup.html # MFA einrichten (QR-Code + Deaktivierung)
│ ├── mfa_verify.html # TOTP-Code nach Login eingeben
│ ├── mfa_recovery.html # Einmalige Recovery-Codes-Anzeige
│ ├── _header.html # Gemeinsamer Header (MFA-Link + Aktiv-Badge)
│ ├── _footer.html # Gemeinsamer Footer
│ └── _styles.html # Zentrale CSS (Variablen, Dark Mode)
├── static/
│ ├── cancom.svg
│ ├── dva.png
│ └── wallpaper.png
└── AGENTS.md
```
---
## Installation
### Voraussetzungen
- Python ≥ 3.9
- pip
- ffmpeg (optional, für Videokonvertierung)
### Setup
```bash
pip install -r requirements.txt
```
### Starten
```bash
python app.py
```
Server läuft auf `http://localhost:5005`.
### Docker
Empfohlenes `docker-compose.yml` mit Bind-Mounts für persistente Daten:
```yaml
services:
signage:
container_name: signage
build:
context: .
dockerfile: Dockerfile
platforms:
- linux/amd64
image: your-registry/signage:latest
ports:
- "5005:5005"
restart: unless-stopped
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
volumes:
- ./media:/app/media
- ./config.json:/app/config.json
- ./users.json:/app/users.json
- ./history.json:/app/history.json
environment:
- TZ=Europe/Berlin
```
Der Container läuft als User mit **UID 1000** ohne Shell, auf **read-only Root-FS** ohne Capabilities. Daher vor dem ersten Start die Berechtigungen setzen:
```bash
chown 1000:1000 config.json users.json history.json
chown -R 1000:1000 media/
```
Start:
```bash
docker compose up -d
```
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
| Route | Beschreibung |
|-------|-------------|
| `GET /` | Weiterleitung zum Login |
| `GET /login` | Admin-Login |
| `GET /logout` | Ausloggen |
| `GET /admin` | Redirect zum ersten konfigurierten Standort |
| `GET /admin/dashboard` | Admin Dashboard (Statistiken + Verlauf, `?page=<n>`) |
| `GET /admin/<site>` | Admin-Dashboard für einen Standort |
| `GET /admin/<site>/priority` | Priority-Playlist (separate Seite) |
| `POST /admin/<site>/update/<screen>` | Allgemeine Screen-Einstellungen speichern (Intervall, Newsticker, Bilder/Videos, stay_on_first) |
| `POST /admin/<site>/update-actions/<screen>` | Aktionen-Einstellungen speichern (Custom-URL) |
| `POST /admin/<site>/update-voice/<screen>` | Voice-Agent-Einstellungen speichern (Enabled, Label, Target, Position, Bild) |
| `POST /admin/<site>/upload/<screen>` | Medien hochladen |
| `POST /admin/<site>/add-url/<screen>` | URL zur Playlist hinzufügen |
| `POST /admin/<site>/delete/<screen>/<filename>` | Datei löschen |
| `POST /admin/<site>/playlist/<screen>` | Playlist-Reihenfolge speichern (JSON) |
| `POST /admin/<site>/update-playlist-item/<screen>` | Einzelnen Playlist-Eintrag ändern: URL, Zoom, Passthrough, Zeitsteuerung (JSON) |
| `POST /admin/<site>/delete-screen/<screen>` | Screen + Medien löschen |
| `GET /admin/<site>/add-screen?name=<name>` | Neuen Screen anlegen |
| `GET /add-site?name=<name>` | Neuen Standort anlegen |
| `POST /admin/<site>/delete-site` | Standort + alle Screens/Medien löschen |
| `POST /admin/<site>/upload-background` | Hintergrundbild für Willkommensseite hochladen |
| `POST /admin/<site>/delete-background` | Hintergrundbild zurücksetzen |
| `GET /player/<site>/<screen>` | Player-Ansicht |
| `GET /playlist/<site>/<screen>/hash` | Playlist-Checksumme (für Auto-Reload) + Heartbeat (Online-Status) |
| `GET /willkommen?site=<site>` | Willkommensseite-Formular (GET + POST) – **ohne Login** |
| `POST /api/customer` | API-Endpunkt für Willkommensseite (JSON) |
| `GET /change-password` | Passwort ändern (First-Login / nach Admin-Reset) |
| `GET /admin/users` | User-Liste (Admin) |
| `GET /admin/users/create` | User anlegen (Admin) |
| `GET /admin/users/edit/<email>` | User bearbeiten (Admin) |
| `GET /mfa/setup` | MFA einrichten / deaktivieren (QR-Code + Verifikation) |
| `POST /mfa/setup` | TOTP-Code verifizieren und MFA aktivieren |
| `GET /mfa/verify` | TOTP-Code nach Login eingeben |
| `POST /mfa/verify` | TOTP-Code prüfen oder Recovery-Code verwenden |
| `POST /mfa/disable` | MFA deaktivieren (mit Passwort-Bestätigung) |
| `POST /admin/users/create` | User anlegen (Admin) |
| `POST /admin/users/delete/<email>` | User löschen (Admin, letzter Admin geschützt) |
| `POST /admin/users/reset-password/<email>` | Passwort-Reset mit temporärem Passwort (Admin) |
| `POST /admin/users/edit/<email>` | User bearbeiten speichern (Admin) |
| `GET /media/<site>/<screen>/<file>` | Medien-Datei ausliefern |
| `GET /media/<site>/priority/<file>` | Priority-Medien des Standorts |
| `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 |
### Heartbeat / Online-Status
Der Player ruft alle 5s `/playlist/<site>/<screen>/hash?w=<breite>&h=<höhe>` auf. Der Server speichert dabei ein JSON mit Client-Info (IP, User-Agent, Auflösung) nach `/tmp/signage-heartbeat/<site>/<screen>.json`. Im Admin-Dashboard wird geprüft, ob der Timestamp < 60s alt ist → grüner "Online"-Button mit Tooltip (Browser, Auflösung, IP, letztes Update) oder roter "Offline"-Button pro Screen.
```bash
# Heartbeat-Dateien anzeigen
ls -la /tmp/signage-heartbeat/*/
cat /tmp/signage-heartbeat/stuttgart/lobby.json
```
Nach einem Server-Neustart werden alle Screens kurz rot, bis die Player wieder pollen (max. 60s).
### Admin Portal
```
http://localhost:5005/admin/<standort>
```
- Screens konfigurieren (Tab-basiert: Playlist, Einstellungen, Aktionen, Digital Voice Agent, Medien, Info)
- Info-Tab pro Screen zeigt Client-Info-Tabelle: IP-Adresse, Browser (User-Agent), Bildschirmauflösung und letzter Heartbeat-Zeitpunkt
- Medien hochladen / löschen
- Playlist per Drag & Drop sortieren
- Priority-Playlist verwalten (ebenfalls mit Tabs)
- Willkommensseite generieren (bis zu 3 Kundenlogos, Hintergrundbild pro Standort)
- Custom-URL-Aktionsbutton pro Screen konfigurieren (Position, iframe-Overlay oder Weiterleitung)
- Digital Voice Agent pro Screen konfigurieren (Position, Bild ein/aus, Typewriter-Tagline)
- Stay-on-First-Modus pro Screen (kein automatischer Refresh)
- Standorte anlegen & löschen
- **Userverwaltung**: User anlegen, bearbeiten, löschen, Passwort-Reset (temporäres Passwort wird angezeigt), MFA-Status pro User in der Liste (grünes "Aktiv"-Badge oder "–")
### Admin Dashboard
```
http://localhost:5005/admin/dashboard
```
- 5 Statistik-Cards (Standorte, Screens, User, Admins, Superuser) mit ApexCharts-Sparkline + Trendanzeige
- Aktivitätsverlauf (History) als Liste mit Zeitstempel, Aktion und User
- Alle Erstell-/Lösch-/Änderungsaktionen sowie Login/Logout werden protokolliert
- **Seitennavigation**: 50 Einträge je Seite (`HISTORY_PER_PAGE`), Aufruf über `?page=<n>`; aufbewahrt werden 1000 Einträge (`HISTORY_MAX`)
- Kennzahlen und Sparklines beziehen sich immer auf die **jüngsten 30 Aktionen** und ändern sich beim Blättern bewusst nicht – sonst würden die Trendpfeile springen, obwohl sich nichts geändert hat
### Player-URL
```
http://localhost:5005/player/<standort>/<screen>
```
Beispiel:
```
http://localhost:5005/player/stuttgart/lobby
```
### Query-Parameter-Passthrough
Die Player-URL akzeptiert beliebige Query-Parameter, z.&thinsp;B. `?customer=acme&token=xyz`. Ob diese Parameter an eine Playlist-URL weitergegeben werden, wird pro URL im Admin-Playlist-Edit-Modal gesteuert (Checkbox "Query-Parameter an diese URL übergeben", standardmäßig aus). In der Playlist-Zeile zeigt ein Link-Icon (`ti-link`, grau) den Status an: sichtbar = an, durchgestrichen (`ti-link-off`, hellgrau) = aus.
Beispiel:
```
/player/stuttgart/lobby?customer=acme&token=xyz
```
- URL in Playlist mit **aktiviertem** Passthrough: `https://dashboard.acme.com/status?customer=acme&token=xyz`
- URL in Playlist mit **deaktiviertem** Passthrough: `https://dashboard.acme.com/status`
### Willkommensseite
```
http://localhost:5005/willkommen?site=stuttgart
```
**Ohne Login erreichbar:** Die Seite ist bewusst nicht durch `@login_required` geschützt, damit auch Mitarbeitende ohne Benutzerkonto eine Begrüßung erstellen können. Ein angemeldeter User sieht dabei weiterhin nur seine freigegebenen Standorte; ohne Anmeldung stehen alle Standorte zur Auswahl, ein unbekannter Standort führt zu 403. Im Aktivitätsverlauf erscheinen solche Einträge als „nicht angemeldet". Die programmatische Variante `POST /api/customer` bleibt angemeldet.
Maximal 3 Kunden eingeben → Logos werden via OpenAI + Brandfetch gesucht → `welcome.html` wird in den Playlists aller Zielscreens vorne eingefügt. Pro Standort kann ein eigenes Hintergrundbild hochgeladen werden (Fallback auf `static/wallpaper.png`).
**Zielscreens:** Jeder Screen mit `welcome_enabled` (Schalter „Willkommensseite hier anzeigen" im Tab Einstellungen). Besitzt kein Screen des Standorts den Schlüssel, greift das alte Verhalten und die Seite geht nur in `lobby`.
**Der Schalter wirkt sofort:** Beim Einschalten wird eine bereits vorhandene Willkommensseite unmittelbar in die Playlist des Screens gesetzt, beim Ausschalten wieder entfernt (samt Datei) – ohne erneutes Generieren. Die Seite ist je Standort identisch, ein zweiter Handgriff wäre also nur Aufwand ohne anderes Ergebnis. Beim Generieren wird zusätzlich eine Standort-Kopie unter `media/<site>/welcome.html` abgelegt, die nie gelöscht wird; dadurch findet der Schalter auch dann noch eine Quelle, wenn die Seite zwischenzeitlich auf allen Screens abgeschaltet war.
**Logo-Upload:** Findet die automatische Suche kein Logo (oder passt es nicht), kann pro Kunde eine Bilddatei hochgeladen werden. Ein Upload hat Vorrang vor der Suche. Die Datei wird als `media/<site>/logo_<slot>.png` abgelegt und über `/media/<site>/logos/<datei>` ausgeliefert. **Bewusst als Datei im Standort-Verzeichnis, nicht in einem Unterordner:** `admin()` behandelt jeden Unterordner von `media/<site>/` als Screen und schreibt ihn dauerhaft in die Konfiguration – ein Ordner `logos` würde also als Phantom-Screen in der Oberfläche auftauchen. Das Hintergrundbild liegt aus demselben Grund als `media/<site>/background.*`. Der Ordnername `logos` ist zusätzlich reserviert und wird bei der Screen-Erkennung übersprungen.
**Freistellen und Zuschneiden:** Beim Upload wird der Hintergrund automatisch entfernt, damit das Logo nicht in einem weissen Kasten auf dem Hintergrundbild steht. Anschliessend werden die durchsichtigen Ränder weggeschnitten (`trim_transparent_border`). Ohne diesen Schnitt erscheint ein hochgeladenes Logo kleiner als ein automatisch gefundenes: die Willkommensseite skaliert mit `object-fit: contain` auf eine feste Höhe von 120px, und ein breiter Rand zählt dabei mit – Brandfetch-Logos sind bereits eng beschnitten. Verfahren: Flood Fill von den Bildrändern her (bewusst kein globaler Farbvergleich – so bleibt Weiss *innerhalb* des Logos erhalten), weiche Kante gegen Treppeneffekte, Speicherung immer als PNG. Bereits transparente Bilder werden unverändert übernommen; ist der Bildrand nicht einfarbig, bleibt das Bild unangetastet und die Oberfläche weist darauf hin. Dasselbe gilt, wenn beim Freistellen fast nichts übrig bliebe – ein helles Logo auf hellem Grund liegt innerhalb der Farbtoleranz und würde sonst vollständig verschwinden, was auf der Willkommensseite als „kein Logo" ankäme. Mit der Checkbox „Hintergrund behalten" lässt sich das Freistellen pro Kunde abschalten. Umgesetzt mit Pillow – bewusst ohne ML-Modell, da der Container mit read-only Root-FS läuft und kein Modell-Cache zur Verfügung steht.
**Anzeigen bis:** Optionales Datum im Formular. Ist es gesetzt, bekommt der Eintrag eine Zeitsteuerung (`daily 00:00–23:59` mit `valid_until`) und verschwindet danach auf allen Screens von selbst.
**Ansprechpartner:** Pro Kunde können Kontaktdaten eingegeben werden (Markdown-Format).
- **Fett**: `**Text**`
- *Kursiv*: `*Text*`
- `Code`: `` `Code` ``
- Aufzählungen: `- Punkt` oder `* Punkt`
- Zeilenumbrüche werden als `<br>` übernommen
**Layout:**
- Landscape: Logos nebeneinander (280px breit, 120px hoch), Texte linksbündig darunter
- Portrait: Texte links vom Logo, per `order`-CSS gesteuert
---
### OpenAI API-Key
Die Logo-Suche via OpenAI benötigt einen API-Key. Diesen als Umgebungsvariable setzen:
```bash
export OPENAI_API_KEY=sk-...
```
Oder via `.env`-Datei (wird von Docker Compose automatisch geladen):
```
OPENAI_API_KEY=sk-...
```
---
### Globale Konfiguration (config.json)
| Feld | Typ | Beschreibung |
|------|-----|-------------|
| `server_url` | string | Öffentliche Server-URL für Player-Links |
| `voice_agent_url` | string | URL des Digital Voice Agents (global, read-only im Admin) |
### User-Rollen (users.json)
| Rolle | Zugriff | Userverwaltung |
|-------|---------|----------------|
| **Admin** | Alle Standorte | Ja (anlegen/bearbeiten/löschen/Reset) |
| **Superuser** | Alle Standorte | Nein |
| **User** | Nur freigegebene Standorte | Nein |
- Usernamen sind E-Mail-Adressen
- Passwörter werden als scrypt-Hash gespeichert
- `users.json` wird beim ersten Start aus `config.json.admin` befüllt
- **Notfall**: `users.json` löschen → Server-Neustart erzeugt neuen Admin aus `config.json`
### User-Felder (`users.json`)
| Feld | Typ | Beschreibung |
|------|-----|-------------|
| `password_hash` | string | scrypt-Hash des Passworts |
| `role` | string | `admin`, `superuser` oder `user` |
| `sites` | array | Freigegebene Standorte (nur für User-Rolle) |
| `must_change_password` | bool | Erzwingt Passwort-Änderung beim nächsten Login |
| `first_name` / `last_name` | string | Vor- und Nachname |
| `department` | string | Abteilung |
| `notes` | string | Interne Notizen |
| `mfa_enabled` | bool | MFA aktiv (TOTP) |
| `mfa_secret` | string | TOTP-Secret (sofort beim ersten GET `/mfa/setup` persistiert) |
| `mfa_recovery_codes` | array | 8 Recovery-Codes (hex, 7 Stellen, einmalig verwendbar) |
```json
{
"server_url": "http://signage.ccmake.de",
"voice_agent_url": "https://voice-agent.example.com",
"admin": { "username": "...", "password": "..." },
"sites": {
"stuttgart": {
"screens": {
"lobby": {
"playlist": [...],
"interval": 10,
"show_images": true,
"show_videos": true,
"newsticker_text": "...",
"newsticker_enabled": false,
"custom_url_enabled": false,
"custom_url": "https://...",
"custom_url_label": "Infos",
"custom_url_target": "overlay",
"custom_url_position": "top-left",
"stay_on_first": false,
"voice_agent_enabled": true,
"voice_agent_show_image": false,
"voice_agent_label": "Digitaler Assistent",
"voice_agent_target": "overlay",
"voice_agent_position": "top-left"
},
"casino": { "playlist": [...], "interval": 15 }
},
"welcome_data": {
"names": ["Firma A"],
"logo_urls": ["https://..."],
"background_url": null
}
}
}
}
```
---
## Screen-Konfiguration (pro Screen in `config.json`)
| Feld | Typ | Beschreibung |
|------|-----|-------------|
| `playlist` | Array | Playlist-Einträge (Strings oder Dicts, siehe [Playlist-Einträge](#playlist-eintraege)) |
| `interval` | int | Anzeige-Intervall in Sekunden |
| `show_images` | bool | Bilder anzeigen |
| `show_videos` | bool | Videos anzeigen |
| `newsticker_text` | string | Text für Newsticker (max. 200 Zeichen) |
| `newsticker_enabled` | bool | Newsticker anzeigen |
| `custom_url_enabled` | bool | Custom-URL-Button im Player anzeigen |
| `custom_url` | string | URL des Aktions-Buttons |
| `custom_url_label` | string | Button-Beschriftung |
| `custom_url_target` | string | `"overlay"` (iframe) oder `"redirect"` |
| `custom_url_position` | string | Position des Buttons: `top-left`, `top-center`, `top-right`, `middle-left`, `middle-center`, `middle-right`, `bottom-left`, `bottom-center`, `bottom-right` |
| `stay_on_first` | bool | Player bleibt auf erstem Element stehen (kein Refresh) |
| `welcome_enabled` | bool | Willkommensseite wird auf diesem Screen vorne in die Playlist gesetzt |
| `voice_agent_enabled` | bool | Voice-Agent-Button im Player anzeigen |
| `voice_agent_show_image` | bool | Bild (dva.png) über dem Button anzeigen |
| `voice_agent_label` | string | Button-Beschriftung (Default: "Digitaler Assistent") |
| `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.<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.
**Vorschau in den Screen-Playlisten:** Ist Priority für den Standort aktiviert, erscheinen die Einträge zusätzlich über jeder Screen-Playlist – ausgegraut, mit rotem „Prio"-Badge und ohne Bedienelemente. Sie liegen bewusst in einer **eigenen `<ul>`**, damit das Speichern der Reihenfolge (`savePlaylist()`) sie nicht mit einsammelt und in die Screen-Playlist schreibt.
> 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>
## Playlist-Einträge
Ein Eintrag ist entweder ein String (Dateiname oder URL, Altbestand) oder ein Dict:
```jsonc
// URL-Eintrag
{
"url": "https://example.com",
"zoom": 1.0, // Zoom-Faktor im Player
"enabled": true, // Checkbox in der Playlist (nur der Schalter, nicht das Zeitfenster)
"passthrough_params": false, // Query-Parameter der Player-URL durchreichen
"schedule": { ... } // optional, siehe unten
}
// Datei-Eintrag
{
"name": "bild.jpg",
"enabled": true,
"schedule": { ... } // optional
}
```
### Zeitsteuerung (`schedule`)
Optional pro Eintrag. Ohne `schedule` läuft der Eintrag immer (sofern `enabled`).
| Feld | Typ | Beschreibung |
|------|-----|-------------|
| `mode` | string | `"once"`, `"daily"` oder `"weekly"` |
| `start` / `end` | string | Bei `once` voller Zeitstempel (`"2026-09-10T08:00"`), bei `daily`/`weekly` nur die Uhrzeit (`"08:00"`) |
| `days` | Array | Nur bei `weekly`: Wochentage als Strings, `"0"` = Montag … `"6"` = Sonntag |
| `valid_from` | string | Optional, nur bei `daily`/`weekly`: Datum (`"2026-09-10"`), ab dem das Fenster gilt – inklusiv |
| `valid_until` | string | Optional, nur bei `daily`/`weekly`: Datum, bis zu dem das Fenster gilt – inklusiv |
```jsonc
// Täglich 08:00–12:00, nur vom 10.09. bis 20.09.2026
{ "mode": "daily", "start": "08:00", "end": "12:00",
"valid_from": "2026-09-10", "valid_until": "2026-09-20" }
// Mo/Mi/Fr von 09:00 bis 17:00, unbefristet
{ "mode": "weekly", "start": "09:00", "end": "17:00", "days": ["0", "2", "4"] }
// Einmalig, durchgehend über mehrere Tage
{ "mode": "once", "start": "2026-09-10T08:00", "end": "2026-09-20T12:00" }
```
**Verhaltensregeln:**
- Bei `daily` und `weekly` zählt **nur die Uhrzeit** von `start`/`end`. Ein Gültigkeitszeitraum wird über `valid_from`/`valid_until` gesetzt, nicht über den Datumsanteil.
- Bei `daily` sind **alle Wochentage** gemeint; `days` wird in diesem Modus nicht ausgewertet.
- Ist `start` > `end`, läuft das Fenster **über Mitternacht** (z. B. `22:00`–`02:00`). Ein solches Fenster gehört zum Wochentag bzw. Gültigkeitstag seines **Starts**: „Mi 22:00–02:00" läuft Mittwochabend bis Donnerstagfrüh.
- Unlesbare oder unvollständige Zeitangaben blenden den Eintrag **nicht** aus – ein Konfigurationsfehler soll nicht dazu führen, dass stillschweigend nichts mehr läuft.
- `_schedule_time()` liest neben `"08:00"` auch volle ISO-Zeitstempel, damit Einträge aus älteren Versionen unverändert weiterlaufen.
- Der Aktiv-Zustand fliesst in `/playlist/<site>/<screen>/hash` ein, damit der Player beim Öffnen und Schliessen eines Zeitfensters von selbst neu lädt.
- **Checkbox und Zeitfenster sind getrennt:** Die Checkbox in der Playlist-Zeile zeigt ausschliesslich den gespeicherten Schalter `enabled`, nicht den momentanen Zeitfenster-Zustand. Sie bleibt also gesetzt, auch wenn ein zeitgesteuerter Eintrag gerade nicht läuft. Ob er gerade läuft, sagt das „Zeit"-Badge: blau = läuft, grau = ausserhalb des Fensters (Tooltip nennt den Grund). Abgespielt wird nur, was **beides** erfüllt – Haken gesetzt *und* im Zeitfenster.
> Bis Version 6.2.4 zeigte die Checkbox den ausgewerteten Zustand. Wurde die Playlist ausserhalb des Zeitfensters gespeichert, schrieb die Oberfläche `enabled: false` in die Konfiguration und der Eintrag war dauerhaft abgeschaltet. Der Fehler ist behoben, wirkt aber nicht rückwirkend: bereits betroffene Einträge müssen einmal von Hand wieder aktiviert werden.
---
## Helper-Funktionen (`app.py`)
- `load_config()` / `save_config()` – JSON lesen/schreiben
- `get_site_list(cfg)` – alle Standorte sortiert
- `get_screen_config(cfg, site, screen)` – Screen-Konfiguration mit Defaults
- `is_url(item)` – prüft ob Item eine URL ist
- `normalize_url(item)` – normalisiert URL-Item zu `{"url", "zoom"}`
- `playlist_item_name(item)` – Name aus String oder Dict extrahieren
- `is_item_active(item)` – prüft `enabled` **und** die Zeitsteuerung
- `_schedule_time(value)` – Uhrzeit aus `"HH:MM"` oder vollem ISO-Zeitstempel lesen
- `_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(site)` – Priority-Playlist eines Standorts + Dateien laden
- `_load_history()` / `get_history(limit, offset)` / `get_history_count()` – Aktivitätsverlauf lesen, seitenweise ausschneiden, zählen
- `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`)
- `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
- `trim_transparent_border(img)` – durchsichtige Ränder wegschneiden, damit das Logo den Anzeigebereich ausfüllt
- `sync_welcome_for_screen(config, site, screen, enabled)` – Willkommensseite beim Umlegen des Schalters platzieren bzw. entfernen
- `find_welcome_source(site, config)` – vorhandene `welcome.html` als Kopiervorlage finden
- `prio_redirect(site)` – Redirect-Pfad zur Priority-Seite
- `get_background_url(site)` – URL zum benutzerdefinierten Hintergrundbild oder `None`
- `load_users()` / `save_users()` / `get_user()` – User aus `users.json`
- `init_user_db()` – initialen Admin aus `config.json` anlegen
- `get_accessible_sites(cfg, user)` – Standorte nach User-Berechtigung filtern
- `record_heartbeat(site, screen, ip, ua, resolution)` – Heartbeat-JSON mit Client-Info schreiben
- `screen_is_active(site, screen)` – Prüft ob Heartbeat < 60s alt ist
- `get_client_info(site, screen)` – Gibt gespeicherte Client-Info zurück (Dict mit ip, ua, resolution, last_seen)
- `admin_required` / `site_access_required` – Zugriffs-Dekoratoren
---
## Dark Mode
Wird über `localStorage("signage-theme")` persistiert. Umschalt-Button im Header. CSS-Variablen `--ccm-*` in `_styles.html`.
---
## Sicherheit
- Admin-Bereich per Flask-Login geschützt (E-Mail + Passwort)
- **Ohne Login erreichbar** sind: Player und Medien (die Abspielgeräte melden sich nicht an) sowie das Formular der Willkommensseite. Letzteres legt Dateien an, schreibt in die Konfiguration und löst kostenpflichtige Logo-Abfragen aus – bei einer aus dem Internet erreichbaren Instanz empfiehlt sich eine Einschränkung auf das interne Netz im Reverse Proxy
- Optionale MFA (TOTP) per User aktivierbar (Google Authenticator, Authy)
- 8 Recovery-Codes pro User, einmalig bei Aktivierung angezeigt
- Passwörter gehasht (scrypt) in `users.json`
- Drei Rollen: Admin (alle Standorte + Userverwaltung), Superuser (alle Standorte), User (nur zugewiesene Standorte)
- Zugriffskontrolle per Decorator (`@admin_required`, `@site_access_required`)
- Letzter Admin kann nicht gelöscht werden
- Player-Seiten Read-Only
- Externe Nutzung via Reverse Proxy + TLS empfohlen
---
## Git & Medien
> Das Repository ist für Code gedacht, nicht für Medien.
`media/` und Medien-Dateiendungen sind in `.gitignore` ausgeschlossen.
---
## Video-Empfehlungen
- Format: MP4 (H.264)
- Auflösung: max. 1920×1080
```bash
ffmpeg -i input.mov -c:v libx264 -pix_fmt yuv420p -movflags +faststart output.mp4
```
---
## Maintainer
**CANCOM Simple Signage** – Interne Lösung, nicht für externe Weitergabe bestimmt.