Files
signage/README.md
Erik Thiele a3b9fe59b7 fix: hochgeladene Logos nicht mehr in einem Screen-Ordner ablegen
Hochgeladene Logos landeten unter media/<site>/logos/. admin() behandelt
aber jeden Unterordner von media/<site>/ als Screen und schreibt ihn per
get_screen_config() dauerhaft in die Konfiguration. Der Logo-Ordner
erschien dadurch als Screen in der Oberflaeche, bekam beim Generieren
eine eigene welcome.html und liess sich wie ein Screen bespielen - und
die darin liegenden Logos wie Screen-Medien loeschen. Genau so ist eines
der hochgeladenen Logos verschwunden.

Logos liegen jetzt als media/<site>/logo_<slot>.png direkt im
Standort-Verzeichnis, so wie das Hintergrundbild als
media/<site>/background.* - aus demselben Grund. Die URL bleibt
/media/<site>/logos/<datei>, die Route liest aus dem
Standort-Verzeichnis; bestehende welcome.html funktionieren damit
unveraendert weiter.

Zusaetzlich wird der Ordnername "logos" bei der Screen-Erkennung
uebersprungen, damit Altbestaende nicht erneut als Screen auftauchen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 15:14:20 +02:00

571 lines
27 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**: globale Inhalte wirken auf alle Player
- **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
- 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
- **Aktivitätsverlauf**: Alle Erstell-/Lösch-/Änderungsaktionen sowie Login/Logout werden in `history.json` protokolliert und im Dashboard angezeigt
- 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/
│ │ ├── casino/
│ │ └── videosysteme/
│ └── priority/
├── 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).
---
## 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) |
| `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) |
| `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/priority/<file>` | Priority-Medien (global) |
| `GET /media/<site>/logos/<file>` | Manuell hochgeladenes Kundenlogo |
| `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
### 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
```
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. 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
}
}
},
"priority": {
"enabled": true,
"playlist": [...]
}
}
```
---
## 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` |
<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
"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.
---
## 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()` – Priority-Playlist + Dateien laden
- `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)
- 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.