Erik Thiele c56006c18d fix: Trusted Device bei MFA zuverlaessig speichern und pruefen
- Dropdown fuer die Merk-Dauer aktiviert jetzt die Checkbox mit; bisher
  wurde die Auswahl ohne gesetzten Haken verworfen und trotzdem nach
  jedem Login ein Code verlangt.
- Abgelaufene oder defekte Trusted-Device-Eintraege fuehrten beim Login
  zu einem NameError (users_data war nicht geladen); Bereinigung neu
  geschrieben.
- Cookie erhaelt das Secure-Flag, wenn die Verbindung HTTPS ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:46:09 +02:00
2026-06-19 14:17:42 +02:00
2026-06-30 19:01:15 +02:00
2026-09-04 10:46:49 +02:00
2026-06-28 15:15:58 +02:00

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
  • 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/                  # 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

pip install -r requirements.txt

Starten

python app.py

Server läuft auf http://localhost:5005.

Docker

Empfohlenes docker-compose.yml mit Bind-Mounts für persistente Daten:

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:

chown 1000:1000 config.json users.json history.json
chown -R 1000:1000 media/

Start:

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:

docker compose up -d --build

Logs

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)
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/<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.

# 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. 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. 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:

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)
{
  "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)
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/:

"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.


Playlist-Einträge

Ein Eintrag ist entweder ein String (Dateiname oder URL, Altbestand) oder ein Dict:

// 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
// 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(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)
  • 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
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.

Description
No description provided
Readme 4.9 MiB
Languages
HTML 62.1%
Python 37.6%
Dockerfile 0.3%