1017 lines
36 KiB
Python
Executable File
1017 lines
36 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
CANCOM Simple Signage – Flask-App für Digital-Signage-Anzeigen.
|
||
──────────────────────────────────────────────────────────────
|
||
Diese App verwaltet mehrere Standorte (sites) mit jeweils mehreren
|
||
Screens. Jeder Screen hat eine eigene Playlist aus Bildern, Videos,
|
||
HTML-Seiten und URLs. Optional kann eine globale Priority-Playlist
|
||
zugeschaltet werden, die auf allen Playern abwechselnd eingeblendet wird.
|
||
"""
|
||
|
||
import os
|
||
import json
|
||
import hashlib
|
||
import generate_welcome_page # Logo-Suche und Willkommensseite-Generierung
|
||
|
||
from datetime import datetime
|
||
|
||
from flask import (
|
||
Flask, jsonify, render_template,
|
||
send_from_directory, redirect,
|
||
request, abort
|
||
)
|
||
from flask_login import (
|
||
LoginManager, login_user,
|
||
login_required, logout_user,
|
||
UserMixin
|
||
)
|
||
from werkzeug.utils import secure_filename
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Grundkonfiguration
|
||
# -------------------------------------------------
|
||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
MEDIA_DIR = os.path.join(BASE_DIR, "media") # Medien-Dateien je Standort/Screen
|
||
CONFIG_FILE = os.path.join(BASE_DIR, "config.json") # Persistente Konfiguration
|
||
|
||
APP_VERSION = "5.1.0"
|
||
UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".mp4"}
|
||
|
||
app = Flask(__name__)
|
||
app.secret_key = "CHANGE_THIS_SECRET!!!"
|
||
|
||
login_manager = LoginManager(app)
|
||
login_manager.login_view = "login" # Nicht eingeloggte User werden zum Login umgeleitet
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Config-Helfer
|
||
# -------------------------------------------------
|
||
def load_config():
|
||
"""Liest die config.json und gibt das Dict zurück. Legt ein leeres an, falls nicht vorhanden."""
|
||
if not os.path.exists(CONFIG_FILE):
|
||
return {"admin": {}, "sites": {}}
|
||
with open(CONFIG_FILE) as f:
|
||
return json.load(f)
|
||
|
||
|
||
def save_config(cfg):
|
||
"""Schreibt das Config-Dict zurück in die config.json."""
|
||
with open(CONFIG_FILE, "w") as f:
|
||
json.dump(cfg, f, indent=2)
|
||
f.flush()
|
||
|
||
|
||
def get_site_list(cfg):
|
||
"""Gibt eine sortierte Liste aller konfigurierten Standort-Namen zurück."""
|
||
return sorted(cfg.get("sites", {}).keys())
|
||
|
||
|
||
def get_screen_config(cfg, site, screen):
|
||
"""Holt die Konfiguration für einen bestimmten Screen.
|
||
Erzeugt automatisch fehlende Dict-Ebenen (sites → site → screens → screen)."""
|
||
return (
|
||
cfg
|
||
.setdefault("sites", {})
|
||
.setdefault(site, {})
|
||
.setdefault("screens", {})
|
||
.setdefault(screen, {})
|
||
)
|
||
|
||
|
||
def is_url(item):
|
||
"""Prüft, ob ein Playlist-Eintrag eine URL ist (String mit http/https) oder ein Dict mit "url"-Key."""
|
||
if isinstance(item, str):
|
||
return item.startswith("http://") or item.startswith("https://")
|
||
return isinstance(item, dict) and "url" in item
|
||
|
||
|
||
def normalize_url(item):
|
||
"""Normalisiert einen URL-Eintrag zu einem Dict mit "url" und "zoom".
|
||
Zoom wird aus Dict übernommen oder defaultmäßig auf 1.0 gesetzt."""
|
||
if isinstance(item, str):
|
||
return {"url": item, "zoom": 1.0}
|
||
if isinstance(item, dict):
|
||
return {"url": item["url"], "zoom": item.get("zoom", 1.0)}
|
||
return None
|
||
|
||
|
||
def playlist_item_name(item):
|
||
"""Ermittelt den Anzeigenamen eines Playlist-Eintrags (String → direkt, Dict → "url" oder "name")."""
|
||
if isinstance(item, str):
|
||
return item
|
||
if isinstance(item, dict):
|
||
return item.get("url") or item.get("name", "")
|
||
return ""
|
||
|
||
|
||
def playlist_item_enabled(item):
|
||
"""Prüft, ob ein Playlist-Eintrag aktiviert ist.
|
||
Strings sind immer aktiv, Dicts haben ein optionales "enabled"-Feld (default: True)."""
|
||
if isinstance(item, (str,)):
|
||
return True
|
||
if isinstance(item, dict):
|
||
return item.get("enabled", True)
|
||
return True
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Priority-Playlist-Helfer
|
||
# -------------------------------------------------
|
||
def load_priority_files():
|
||
"""Lädt die Priority-Playlist aus config.json plus die dazugehörigen Dateien
|
||
aus media/priority/. Gibt eine Liste von Dicts mit name/type/size/enabled zurück.
|
||
Neue Dateien im Ordner, die noch nicht in der Playlist sind, werden angehängt."""
|
||
cfg = load_config()
|
||
prio_dir = os.path.join(MEDIA_DIR, "priority")
|
||
os.makedirs(prio_dir, exist_ok=True)
|
||
|
||
priority_files = []
|
||
prio_playlist = cfg.get("priority", {}).get("playlist", [])
|
||
|
||
# 1. Einträge aus der config.json-Playlist
|
||
for item in prio_playlist:
|
||
item_name = playlist_item_name(item)
|
||
if is_url(item):
|
||
url_data = normalize_url(item)
|
||
if url_data:
|
||
priority_files.append({
|
||
"name": url_data["url"],
|
||
"type": "url",
|
||
"size": "URL",
|
||
"zoom": url_data.get("zoom", 1.0),
|
||
"enabled": playlist_item_enabled(item)
|
||
})
|
||
continue
|
||
|
||
file_path = os.path.join(prio_dir, item_name)
|
||
if not os.path.exists(file_path):
|
||
continue
|
||
|
||
ext = os.path.splitext(item_name)[1].lower()
|
||
ftype = "video" if ext == ".mp4" else "image"
|
||
size = os.path.getsize(file_path) // 1024
|
||
|
||
priority_files.append({
|
||
"name": item_name,
|
||
"type": ftype,
|
||
"size": size,
|
||
"enabled": playlist_item_enabled(item)
|
||
})
|
||
|
||
# 2. Neue Dateien aus dem Ordner, die noch nicht in der Playlist sind
|
||
playlist_names = {playlist_item_name(item) for item in prio_playlist}
|
||
for f in sorted(os.listdir(prio_dir)):
|
||
if f in playlist_names or f.startswith("._"):
|
||
continue
|
||
|
||
file_path = os.path.join(prio_dir, f)
|
||
ext = os.path.splitext(f)[1].lower()
|
||
ftype = "video" if ext == ".mp4" else "image"
|
||
size = os.path.getsize(file_path) // 1024
|
||
|
||
priority_files.append({
|
||
"name": f,
|
||
"type": ftype,
|
||
"size": size
|
||
})
|
||
|
||
return priority_files
|
||
|
||
|
||
def prio_redirect(site):
|
||
"""Gibt den Redirect-Pfad zurück, der nach Priority-Operationen aufgerufen wird."""
|
||
return f"/admin/{site}/priority"
|
||
|
||
|
||
def get_background_url(site):
|
||
"""Prüft, ob für einen Standort ein benutzerdefiniertes Hintergrundbild existiert.
|
||
Gibt die URL zurück oder None (dann wird das Standard-Wallpaper verwendet)."""
|
||
site_dir = os.path.join(MEDIA_DIR, site)
|
||
if not os.path.isdir(site_dir):
|
||
return None
|
||
for f in sorted(os.listdir(site_dir)):
|
||
if f.startswith("background."):
|
||
ext = os.path.splitext(f)[1].lower()
|
||
if ext in (".jpg", ".jpeg", ".png"):
|
||
return f"/media/{site}/background/{f}"
|
||
return None
|
||
|
||
|
||
def _regenerate_welcome_for_site(site):
|
||
"""Regeneriert die Willkommensseite mit aktuellem Hintergrund,
|
||
falls zuvor Kundendaten gespeichert wurden."""
|
||
config = load_config()
|
||
welcome_data = config.get("sites", {}).get(site, {}).get("welcome_data")
|
||
if welcome_data:
|
||
names = welcome_data.get("names", [])
|
||
urls = welcome_data.get("logo_urls", [])
|
||
if names:
|
||
bg_url = get_background_url(site)
|
||
generate_welcome_page.save_welcome_page(names, urls, site=site, background_url=bg_url)
|
||
print(f"♻️ Willkommensseite für {site} mit aktualisiertem Hintergrund neu generiert")
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Medien ausliefern (statische Dateien)
|
||
# -------------------------------------------------
|
||
@app.route("/media/<site>/background/<path:filename>")
|
||
def media_background(site, filename):
|
||
"""Liefert das Hintergrundbild für die Willkommensseite aus."""
|
||
path = os.path.join(MEDIA_DIR, site)
|
||
return send_from_directory(path, filename)
|
||
|
||
|
||
# -------------------------------------------------
|
||
# User & Login (Flask-Login)
|
||
# -------------------------------------------------
|
||
class Admin(UserMixin):
|
||
"""Einfacher Admin-User. Es gibt nur einen Admin (keine DB)."""
|
||
id = 1
|
||
|
||
|
||
@login_manager.user_loader
|
||
def load_user(user_id):
|
||
"""Lädt den Admin-User für Flask-Login. user_id wird ignoriert – es gibt nur einen User."""
|
||
return Admin()
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Login / Logout
|
||
# -------------------------------------------------
|
||
@app.route("/login", methods=["GET", "POST"])
|
||
def login():
|
||
"""Login-Seite. Bei POST werden die Zugangsdaten aus config.json.admin geprüft."""
|
||
config = load_config()
|
||
error = None
|
||
|
||
if request.method == "POST":
|
||
if (
|
||
request.form.get("username") == config["admin"].get("username")
|
||
and request.form.get("password") == config["admin"].get("password")
|
||
):
|
||
login_user(Admin())
|
||
return redirect("/admin")
|
||
error = "Ungültige Zugangsdaten"
|
||
|
||
return render_template("login.html", error=error)
|
||
|
||
|
||
@app.route("/logout")
|
||
def logout():
|
||
"""Loggt den Admin aus und leitet zur Login-Seite weiter."""
|
||
logout_user()
|
||
return redirect("/login")
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Willkommensseite (Kunden-Logos + Generierung)
|
||
# -------------------------------------------------
|
||
@app.route("/willkommen", methods=["GET", "POST"])
|
||
@app.route("/customer", methods=["GET", "POST"])
|
||
def add_customer():
|
||
"""
|
||
Formular für die Willkommensseite:
|
||
- Bis zu 3 Kundennamen eingeben
|
||
- Logos per OpenAI → Brandfetch suchen
|
||
- welcome.html wird generiert und in der Lobby-Playlist vorne platziert
|
||
"""
|
||
error = None
|
||
success = None
|
||
logo_urls = [None, None, None]
|
||
customer_names = ["", "", ""]
|
||
site = request.args.get("site") or request.form.get("site") or ""
|
||
|
||
# Fallback auf ersten Standort, wenn keiner angegeben
|
||
if not site:
|
||
config = load_config()
|
||
sites = get_site_list(config)
|
||
site = sites[0] if sites else ""
|
||
|
||
if request.method == "POST":
|
||
customer_names = [
|
||
request.form.get("customer_name_1", "").strip(),
|
||
request.form.get("customer_name_2", "").strip(),
|
||
request.form.get("customer_name_3", "").strip(),
|
||
]
|
||
filled_names = [n for n in customer_names if n]
|
||
|
||
print(f"🔍 APP.PY - Suche nach Kunden: {filled_names} (site: {site})")
|
||
|
||
if not filled_names:
|
||
error = "Mindestens ein Kundenname erforderlich"
|
||
elif not site:
|
||
error = "Kein Standort ausgewählt"
|
||
else:
|
||
try:
|
||
found_logos = []
|
||
for name in filled_names:
|
||
logo = generate_welcome_page.search_customer_logo(name)
|
||
found_logos.append(logo)
|
||
|
||
valid = [(n, l) for n, l in zip(filled_names, found_logos) if l]
|
||
|
||
if not valid:
|
||
error = "Kein Logo konnte gefunden werden"
|
||
else:
|
||
valid_names = [n for n, l in valid]
|
||
valid_urls = [l for n, l in valid]
|
||
for i, name in enumerate(customer_names):
|
||
for j, (vn, vu) in enumerate(zip(valid_names, valid_urls)):
|
||
if vn == name:
|
||
logo_urls[i] = vu
|
||
break
|
||
|
||
bg_url = get_background_url(site)
|
||
html_filename = generate_welcome_page.save_welcome_page(valid_names, valid_urls, site=site, background_url=bg_url)
|
||
|
||
if html_filename:
|
||
if generate_welcome_page.add_customer_to_lobby_playlist(html_filename, site=site):
|
||
# Letzte Kundendaten für Background-Update speichern
|
||
config = load_config()
|
||
site_cfg = config.setdefault("sites", {}).setdefault(site, {})
|
||
site_cfg["welcome_data"] = {"names": valid_names, "logo_urls": valid_urls}
|
||
save_config(config)
|
||
success = f"✅ Willkommensseite mit {len(valid_names)} Kunden erstellt!"
|
||
else:
|
||
error = "Fehler beim Hinzufügen zur Playliste"
|
||
else:
|
||
error = "Fehler beim Speichern der Willkommensseite"
|
||
except ValueError as e:
|
||
error = f"Konfigurationsfehler: {str(e)}"
|
||
except Exception as e:
|
||
error = f"Fehler: {str(e)}"
|
||
|
||
config = load_config()
|
||
site_list = get_site_list(config)
|
||
background_url = get_background_url(site)
|
||
return render_template(
|
||
"customer.html",
|
||
error=error,
|
||
success=success,
|
||
logo_urls=logo_urls,
|
||
customer_names=customer_names,
|
||
site=site,
|
||
site_list=site_list,
|
||
current_site=site,
|
||
version=APP_VERSION,
|
||
year=datetime.now().year,
|
||
hostname=os.uname().nodename,
|
||
background_url=background_url
|
||
)
|
||
|
||
|
||
@app.route("/api/customer", methods=["POST"])
|
||
def api_add_customer():
|
||
"""
|
||
API-Endpunkt für die Willkommensseite (JSON).
|
||
Erwartet: {"customer_name": "...", "site": "..."}
|
||
Gibt die Logo-URL und den Status zurück.
|
||
"""
|
||
try:
|
||
data = request.get_json()
|
||
customer_name = data.get("customer_name", "").strip()
|
||
site = data.get("site", "").strip()
|
||
|
||
if not customer_name:
|
||
return jsonify({"error": "Customer name required"}), 400
|
||
|
||
# Default auf ersten Standort
|
||
if not site:
|
||
config = load_config()
|
||
sites = get_site_list(config)
|
||
site = sites[0] if sites else ""
|
||
|
||
if not site:
|
||
return jsonify({"error": "No site configured"}), 400
|
||
|
||
logo_url = generate_welcome_page.search_customer_logo(customer_name)
|
||
|
||
if not logo_url:
|
||
return jsonify({"error": "Logo not found"}), 400
|
||
|
||
html_filename = generate_welcome_page.save_welcome_page(customer_name, logo_url, site=site)
|
||
|
||
if not html_filename:
|
||
return jsonify({"error": "Failed to create welcome page"}), 500
|
||
|
||
if generate_welcome_page.add_customer_to_lobby_playlist(html_filename, site=site):
|
||
return jsonify({
|
||
"success": True,
|
||
"message": f"Customer '{customer_name}' added successfully",
|
||
"welcome_page": html_filename,
|
||
"logo_url": logo_url,
|
||
"site": site
|
||
}), 201
|
||
else:
|
||
return jsonify({"error": "Failed to add to playlist"}), 500
|
||
|
||
except ValueError as e:
|
||
return jsonify({"error": str(e)}), 400
|
||
except Exception as e:
|
||
return jsonify({"error": str(e)}), 500
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Medien ausliefern (statische Dateien)
|
||
# -------------------------------------------------
|
||
@app.route("/media/priority/<path:filename>")
|
||
def media_priority(filename):
|
||
"""Liefert globale Priority-Medien aus media/priority/ aus."""
|
||
path = os.path.join(MEDIA_DIR, "priority")
|
||
return send_from_directory(path, filename)
|
||
|
||
|
||
@app.route("/media/<site>/<screen>/<path:filename>")
|
||
def media(site, screen, filename):
|
||
"""Liefert Medien-Dateien für einen bestimmten Screen aus."""
|
||
path = os.path.join(MEDIA_DIR, site, screen)
|
||
return send_from_directory(path, filename)
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Player
|
||
# -------------------------------------------------
|
||
@app.route("/player/<site>/<screen>")
|
||
def player(site, screen):
|
||
"""
|
||
Player-Ansicht für einen Screen.
|
||
Baut die Playlist (normal + optional priority) zusammen und rendert player.html.
|
||
Der Player zeigt abwechselnd Bilder, Videos, HTML-Seiten und URLs an.
|
||
"""
|
||
config = load_config()
|
||
sites_cfg = config.setdefault("sites", {})
|
||
site_cfg = sites_cfg.get(site)
|
||
|
||
if not site_cfg:
|
||
abort(404)
|
||
|
||
screens_cfg = site_cfg.setdefault("screens", {})
|
||
screen_cfg = screens_cfg.get(screen)
|
||
|
||
if not screen_cfg:
|
||
abort(404)
|
||
|
||
folder = os.path.join(MEDIA_DIR, site, screen)
|
||
playlist = screen_cfg.get("playlist", [])
|
||
|
||
show_images = screen_cfg.get("show_images", True)
|
||
show_videos = screen_cfg.get("show_videos", True)
|
||
|
||
def allowed_file(name):
|
||
"""Prüft, ob eine Datei angezeigt werden darf (abhängig von show_images / show_videos)."""
|
||
if name.startswith("._"):
|
||
return False
|
||
ext = name.lower()
|
||
if ext.endswith((".jpg", ".jpeg", ".png")):
|
||
return show_images
|
||
if ext.endswith(".mp4"):
|
||
return show_videos
|
||
if ext.endswith((".html", ".htm")):
|
||
return True
|
||
return False
|
||
|
||
# ─── Normale Screen-Playlist ───
|
||
normal_files = []
|
||
|
||
# 1. Playlist-Reihenfolge aus config.json
|
||
for item in playlist:
|
||
if not playlist_item_enabled(item):
|
||
continue
|
||
item_name = playlist_item_name(item)
|
||
if is_url(item):
|
||
url_data = normalize_url(item)
|
||
if url_data:
|
||
normal_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": True})
|
||
continue
|
||
|
||
if item_name and os.path.exists(os.path.join(folder, item_name)) and allowed_file(item_name):
|
||
normal_files.append({"kind": "file", "name": item_name, "enabled": True})
|
||
|
||
existing_file_names = {
|
||
item["name"] for item in normal_files if item["kind"] == "file"
|
||
}
|
||
|
||
# 2. Neue Dateien aus dem Medien-Ordner (ohne Playlist-Eintrag) hinten anhängen
|
||
if not playlist:
|
||
for f in sorted(os.listdir(folder)):
|
||
if f in existing_file_names or f.startswith("._"):
|
||
continue
|
||
if allowed_file(f):
|
||
normal_files.append({"kind": "file", "name": f})
|
||
|
||
# ─── Priority-Playlist (global) ───
|
||
priority_cfg = config.get("priority", {})
|
||
|
||
if priority_cfg.get("enabled", False):
|
||
prio_files = []
|
||
for item in priority_cfg.get("playlist", []):
|
||
if not playlist_item_enabled(item):
|
||
continue
|
||
item_name = playlist_item_name(item)
|
||
if is_url(item):
|
||
url_data = normalize_url(item)
|
||
if url_data:
|
||
prio_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": True})
|
||
continue
|
||
|
||
file_path = os.path.join(MEDIA_DIR, "priority", item_name)
|
||
if os.path.exists(file_path):
|
||
prio_files.append({"kind": "file", "name": item_name, "enabled": True})
|
||
else:
|
||
prio_files = []
|
||
|
||
return render_template(
|
||
"player.html",
|
||
site=site,
|
||
screen=screen,
|
||
normal_files=[item for item in normal_files if item.get("enabled", True)],
|
||
prio_files=[item for item in prio_files if item.get("enabled", True)],
|
||
interval=screen_cfg.get("interval", 10),
|
||
newsticker_text=screen_cfg.get("newsticker_text", ""),
|
||
newsticker_enabled=screen_cfg.get("newsticker_enabled", False),
|
||
custom_url=screen_cfg.get("custom_url", ""),
|
||
custom_url_label=screen_cfg.get("custom_url_label", ""),
|
||
custom_url_enabled=screen_cfg.get("custom_url_enabled", False),
|
||
custom_url_target=screen_cfg.get("custom_url_target", "overlay")
|
||
)
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Hash für automatisches Player-Reload
|
||
# ─────────────────────────────────────────────────
|
||
# Der Player pollt alle 5s diese Route.
|
||
# Wenn sich der Hash ändert, wird neu geladen.
|
||
# -------------------------------------------------
|
||
@app.route("/playlist/<site>/<screen>/hash")
|
||
def playlist_hash(site, screen):
|
||
"""Berechnet einen MD5-Hash über die aktuelle Konfiguration (Screen + Priority).
|
||
Ändert sich der Hash, lädt der Player automatisch neu."""
|
||
config = load_config()
|
||
|
||
relevant = {
|
||
"sites": {
|
||
site: config.get("sites", {}).get(site, {})
|
||
},
|
||
"priority": config.get("priority", {})
|
||
}
|
||
|
||
blob = json.dumps(relevant, sort_keys=True).encode()
|
||
return hashlib.md5(blob).hexdigest()
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Admin
|
||
# -------------------------------------------------
|
||
|
||
@app.route("/admin")
|
||
@login_required
|
||
def admin_redirect():
|
||
"""Leitet zum ersten konfigurierten Standort weiter."""
|
||
cfg = load_config()
|
||
sites = get_site_list(cfg)
|
||
if sites:
|
||
return redirect(f"/admin/{sites[0]}")
|
||
return "<h1>Kein Standort konfiguriert</h1><p>Bitte lege einen Standort im media-Verzeichnis an.</p>", 200
|
||
|
||
|
||
@app.route("/add-site")
|
||
@login_required
|
||
def add_site():
|
||
"""Legt einen neuen Standort an (Verzeichnis + Config-Eintrag)."""
|
||
name = request.args.get("name", "").strip().lower()
|
||
if not name:
|
||
return redirect("/admin")
|
||
|
||
cfg = load_config()
|
||
cfg.setdefault("sites", {}).setdefault(name, {"screens": {}})
|
||
os.makedirs(os.path.join(MEDIA_DIR, name), exist_ok=True)
|
||
save_config(cfg)
|
||
|
||
return redirect(f"/admin/{name}")
|
||
|
||
|
||
@app.route("/admin/<site>/delete-site", methods=["POST"])
|
||
@login_required
|
||
def delete_site(site):
|
||
"""Löscht einen Standort inkl. aller Screens und Medien (Config + Verzeichnis)."""
|
||
cfg = load_config()
|
||
cfg.get("sites", {}).pop(site, None)
|
||
save_config(cfg)
|
||
|
||
site_dir = os.path.join(MEDIA_DIR, site)
|
||
if os.path.isdir(site_dir):
|
||
import shutil
|
||
shutil.rmtree(site_dir)
|
||
|
||
sites = get_site_list(cfg)
|
||
return redirect(f"/admin/{sites[0]}" if sites else "/admin")
|
||
|
||
|
||
@app.route("/admin/<site>")
|
||
@login_required
|
||
def admin(site):
|
||
"""
|
||
Admin-Dashboard für einen Standort.
|
||
Zeigt alle Screens als Cards mit Tabler-Tabs (Playlist, Einstellungen, Medien).
|
||
"""
|
||
cfg = load_config()
|
||
|
||
site_list = get_site_list(cfg)
|
||
|
||
# Prüfen, ob der Standort existiert
|
||
if site not in site_list:
|
||
if os.path.isdir(os.path.join(MEDIA_DIR, site)):
|
||
cfg.setdefault("sites", {})[site] = {"screens": {}}
|
||
save_config(cfg)
|
||
site_list = get_site_list(cfg)
|
||
else:
|
||
abort(404)
|
||
|
||
current_site = site
|
||
screens = {}
|
||
media_files = {}
|
||
screen_status = {}
|
||
|
||
site_media_base = os.path.join(MEDIA_DIR, current_site)
|
||
|
||
# Screens erkennen: aus Config + Dateisystem
|
||
known_screens = set()
|
||
site_cfg = cfg.setdefault("sites", {}).setdefault(current_site, {"screens": {}})
|
||
config_screens = site_cfg.setdefault("screens", {})
|
||
|
||
# Ordner im Medienverzeichnis = Screens
|
||
if os.path.isdir(site_media_base):
|
||
for entry in os.listdir(site_media_base):
|
||
entry_path = os.path.join(site_media_base, entry)
|
||
if os.path.isdir(entry_path) and not entry.startswith("._"):
|
||
known_screens.add(entry)
|
||
|
||
# Screens aus Config ergänzen (auch ohne Ordner)
|
||
for s in config_screens:
|
||
known_screens.add(s)
|
||
|
||
for screen_name in sorted(known_screens):
|
||
path = os.path.join(site_media_base, screen_name)
|
||
os.makedirs(path, exist_ok=True)
|
||
|
||
files = []
|
||
screen_cfg = get_screen_config(cfg, current_site, screen_name)
|
||
playlist = screen_cfg.get("playlist", [])
|
||
screen_cfg["newsticker_text"] = screen_cfg.get("newsticker_text", "")
|
||
screen_cfg["newsticker_enabled"] = screen_cfg.get("newsticker_enabled", False)
|
||
|
||
# 1. Playlist-Einträge in Config-Reihenfolge
|
||
for item in playlist:
|
||
item_name = playlist_item_name(item)
|
||
|
||
if is_url(item):
|
||
url_data = normalize_url(item)
|
||
if url_data:
|
||
files.append({
|
||
"name": url_data["url"],
|
||
"type": "url",
|
||
"size": "URL",
|
||
"zoom": url_data.get("zoom", 1.0),
|
||
"enabled": playlist_item_enabled(item)
|
||
})
|
||
continue
|
||
|
||
file_path = os.path.join(path, item_name)
|
||
if not os.path.exists(file_path):
|
||
continue
|
||
|
||
ext = os.path.splitext(item_name)[1].lower()
|
||
if ext == ".mp4":
|
||
ftype = "video"
|
||
if ext in (".jpg", ".jpeg", ".png"):
|
||
ftype = "image"
|
||
if ext == ".html":
|
||
ftype = "html"
|
||
|
||
size = os.path.getsize(file_path) // 1024
|
||
|
||
files.append({
|
||
"name": item_name,
|
||
"type": ftype,
|
||
"size": size,
|
||
"enabled": playlist_item_enabled(item)
|
||
})
|
||
|
||
# 2. Neue Dateien (ohne Playlist-Eintrag) anhängen
|
||
playlist_names = {playlist_item_name(item) for item in playlist}
|
||
for f in sorted(os.listdir(path)):
|
||
if f in playlist_names or f.startswith("._"):
|
||
continue
|
||
|
||
file_path = os.path.join(path, f)
|
||
ext = os.path.splitext(f)[1].lower()
|
||
ftype = "video" if ext == ".mp4" else "image"
|
||
size = os.path.getsize(file_path) // 1024
|
||
|
||
files.append({
|
||
"name": f,
|
||
"type": ftype,
|
||
"size": size
|
||
})
|
||
|
||
screens[screen_name] = screen_cfg
|
||
media_files[screen_name] = files
|
||
screen_status[screen_name] = "active" if files else "empty"
|
||
|
||
return render_template(
|
||
"admin.html",
|
||
site_list=site_list,
|
||
current_site=current_site,
|
||
screens=screens,
|
||
media_files=media_files,
|
||
screen_status=screen_status,
|
||
version=APP_VERSION,
|
||
year=datetime.now().year,
|
||
hostname=os.uname().nodename,
|
||
server_url=cfg.get("server_url", "")
|
||
)
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Admin: Priority-Playlist (eigene Seite)
|
||
# -------------------------------------------------
|
||
@app.route("/admin/<site>/priority")
|
||
@login_required
|
||
def admin_priority(site):
|
||
"""Zeigt die Priority-Playlist-Seite mit Tabs (Playlist + Medien)."""
|
||
cfg = load_config()
|
||
site_list = sorted(cfg.get("sites", {}).keys())
|
||
priority_files = load_priority_files()
|
||
|
||
return render_template(
|
||
"priority.html",
|
||
site_list=site_list,
|
||
current_site=site,
|
||
priority_files=priority_files,
|
||
version=APP_VERSION,
|
||
year=datetime.now().year,
|
||
hostname=os.uname().nodename,
|
||
server_url=cfg.get("server_url", "")
|
||
)
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Admin: Hintergrundbild für Willkommensseite hochladen
|
||
# -------------------------------------------------
|
||
@app.route("/admin/<site>/upload-background", methods=["POST"])
|
||
@login_required
|
||
def upload_background(site):
|
||
"""Lädt ein Hintergrundbild für die Willkommensseite hoch (pro Standort).
|
||
Gespeichert als media/<site>/background.<ext>."""
|
||
file = request.files.get("background")
|
||
if not file:
|
||
return redirect(f"/willkommen?site={site}")
|
||
|
||
ext = os.path.splitext(file.filename)[1].lower()
|
||
if ext not in UPLOAD_EXTENSIONS:
|
||
return "Ungültiger Dateityp", 400
|
||
|
||
site_dir = os.path.join(MEDIA_DIR, site)
|
||
os.makedirs(site_dir, exist_ok=True)
|
||
|
||
# Alte Hintergrundbilder löschen
|
||
for f in os.listdir(site_dir):
|
||
if f.startswith("background."):
|
||
os.remove(os.path.join(site_dir, f))
|
||
|
||
filename = f"background{ext}"
|
||
file.save(os.path.join(site_dir, filename))
|
||
|
||
# Willkommensseite mit neuen Hintergrund neu generieren
|
||
_regenerate_welcome_for_site(site)
|
||
|
||
return redirect(f"/willkommen?site={site}")
|
||
|
||
|
||
@app.route("/admin/<site>/delete-background", methods=["POST"])
|
||
@login_required
|
||
def delete_background(site):
|
||
"""Löscht das Hintergrundbild für die Willkommensseite."""
|
||
site_dir = os.path.join(MEDIA_DIR, site)
|
||
if os.path.isdir(site_dir):
|
||
for f in os.listdir(site_dir):
|
||
if f.startswith("background."):
|
||
os.remove(os.path.join(site_dir, f))
|
||
|
||
# Willkommensseite mit Standard-Hintergrund neu generieren
|
||
_regenerate_welcome_for_site(site)
|
||
|
||
return redirect(f"/willkommen?site={site}")
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Admin: Screen-Einstellungen speichern
|
||
# -------------------------------------------------
|
||
@app.route("/admin/<site>/update/<screen>", methods=["POST"])
|
||
@login_required
|
||
def update_screen(site, screen):
|
||
"""Speichert die Einstellungen für einen Screen (Intervall, Newsticker, Show-Bilder/Videos, Custom-URL)."""
|
||
config = load_config()
|
||
cfg = get_screen_config(config, site, screen)
|
||
|
||
cfg["interval"] = int(request.form.get("interval") or 10)
|
||
cfg["show_images"] = "show_images" in request.form
|
||
cfg["show_videos"] = "show_videos" in request.form
|
||
cfg["newsticker_text"] = request.form.get("newsticker_text", "")
|
||
cfg["newsticker_enabled"] = "newsticker_enabled" in request.form
|
||
cfg["custom_url"] = request.form.get("custom_url", "").strip()
|
||
cfg["custom_url_label"] = request.form.get("custom_url_label", "").strip()
|
||
cfg["custom_url_enabled"] = "custom_url_enabled" in request.form
|
||
cfg["custom_url_target"] = request.form.get("custom_url_target", "overlay")
|
||
|
||
save_config(config)
|
||
return redirect(f"/admin/{site}")
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Admin: Screen löschen
|
||
# -------------------------------------------------
|
||
@app.route("/admin/<site>/delete-screen/<screen>", methods=["POST"])
|
||
@login_required
|
||
def delete_screen(site, screen):
|
||
"""Löscht einen Screen (Config-Eintrag + Medien-Verzeichnis)."""
|
||
config = load_config()
|
||
|
||
screens_cfg = config.get("sites", {}).get(site, {}).get("screens", {})
|
||
screens_cfg.pop(screen, None)
|
||
save_config(config)
|
||
|
||
screen_dir = os.path.join(MEDIA_DIR, site, screen)
|
||
if os.path.isdir(screen_dir):
|
||
import shutil
|
||
shutil.rmtree(screen_dir)
|
||
|
||
return redirect(f"/admin/{site}")
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Admin: Neuen Screen anlegen
|
||
# -------------------------------------------------
|
||
@app.route("/admin/<site>/add-screen", methods=["GET"])
|
||
@login_required
|
||
def add_screen(site):
|
||
"""Legt einen neuen Screen an (Verzeichnis + Config-Eintrag)."""
|
||
screen_name = request.args.get("name", "").strip().lower()
|
||
if not screen_name:
|
||
return redirect(f"/admin/{site}")
|
||
|
||
config = load_config()
|
||
|
||
os.makedirs(os.path.join(MEDIA_DIR, site, screen_name), exist_ok=True)
|
||
|
||
screens_cfg = config.setdefault("sites", {}).setdefault(site, {}).setdefault("screens", {})
|
||
if screen_name not in screens_cfg:
|
||
screens_cfg[screen_name] = {"playlist": [], "interval": 10, "show_images": True, "show_videos": True}
|
||
|
||
save_config(config)
|
||
return redirect(f"/admin/{site}#screen-{screen_name}")
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Admin: Medien-Upload
|
||
# -------------------------------------------------
|
||
@app.route("/admin/<site>/upload/<screen>", methods=["POST"])
|
||
@login_required
|
||
def upload(site, screen):
|
||
"""Lädt eine Datei hoch (Bild oder Video). Bei screen="priority" ins globale Verzeichnis."""
|
||
file = request.files.get("file")
|
||
if not file:
|
||
return redirect(prio_redirect(site) if screen == "priority" else f"/admin/{site}")
|
||
|
||
ext = os.path.splitext(file.filename)[1].lower()
|
||
if ext not in UPLOAD_EXTENSIONS:
|
||
return "Ungültiger Dateityp", 400
|
||
|
||
filename = secure_filename(file.filename)
|
||
|
||
if screen == "priority":
|
||
file.save(os.path.join(MEDIA_DIR, "priority", filename))
|
||
else:
|
||
file.save(os.path.join(MEDIA_DIR, site, screen, filename))
|
||
|
||
return redirect(prio_redirect(site) if screen == "priority" else f"/admin/{site}")
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Admin: URL zur Playlist hinzufügen
|
||
# -------------------------------------------------
|
||
@app.route("/admin/<site>/add-url/<screen>", methods=["POST"])
|
||
@login_required
|
||
def add_url(site, screen):
|
||
"""Fügt eine URL (mit Zoom-Faktor) zur Playlist eines Screens oder der Priority-Playlist hinzu."""
|
||
url = request.form.get("url", "").strip()
|
||
zoom = request.form.get("zoom", "1.0").strip()
|
||
|
||
if not is_url(url):
|
||
return redirect(prio_redirect(site) if screen == "priority" else f"/admin/{site}")
|
||
|
||
try:
|
||
zoom_factor = float(zoom)
|
||
if zoom_factor <= 0:
|
||
zoom_factor = 1.0
|
||
except (ValueError, TypeError):
|
||
zoom_factor = 1.0
|
||
|
||
url_data = {"url": url, "zoom": zoom_factor}
|
||
|
||
config = load_config()
|
||
if screen == "priority":
|
||
config.setdefault("priority", {}).setdefault("playlist", []).append(url_data)
|
||
else:
|
||
cfg = get_screen_config(config, site, screen)
|
||
cfg.setdefault("playlist", []).append(url_data)
|
||
|
||
save_config(config)
|
||
return redirect(prio_redirect(site) if screen == "priority" else f"/admin/{site}")
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Admin: Datei aus Playlist löschen
|
||
# -------------------------------------------------
|
||
@app.route("/admin/<site>/delete/<screen>/<path:filename>", methods=["POST"])
|
||
@login_required
|
||
def delete_file(site, screen, filename):
|
||
"""Löscht eine Datei aus der Playlist (Config + Dateisystem)."""
|
||
cfg = load_config()
|
||
|
||
if screen == "priority":
|
||
path = os.path.join(MEDIA_DIR, "priority", filename)
|
||
playlist = cfg.get("priority", {}).get("playlist", [])
|
||
playlist[:] = [item for item in playlist if not (
|
||
(isinstance(item, str) and item == filename) or
|
||
(isinstance(item, dict) and item.get("url") == filename)
|
||
)]
|
||
else:
|
||
path = os.path.join(MEDIA_DIR, site, screen, filename)
|
||
screens_cfg = cfg.get("sites", {}).get(site, {}).get("screens", {})
|
||
playlist = screens_cfg.get(screen, {}).get("playlist", [])
|
||
playlist[:] = [item for item in playlist if not (
|
||
(isinstance(item, str) and item == filename) or
|
||
(isinstance(item, dict) and item.get("url") == filename)
|
||
)]
|
||
|
||
if os.path.exists(path):
|
||
os.remove(path)
|
||
|
||
save_config(cfg)
|
||
return redirect(prio_redirect(site) if screen == "priority" else f"/admin/{site}")
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Admin: Playlist-Reihenfolge speichern (Drag & Drop)
|
||
# -------------------------------------------------
|
||
@app.route("/admin/<site>/playlist/<screen>", methods=["POST"])
|
||
@login_required
|
||
def save_playlist(site, screen):
|
||
"""
|
||
Speichert die über SortableJS per Drag & Drop neu sortierte Playlist.
|
||
Der Client schickt ein JSON mit der neuen Reihenfolge und enabled-Status.
|
||
"""
|
||
cfg = load_config()
|
||
data = request.get_json()
|
||
new_order = data["playlist"]
|
||
enabled_map = data.get("enabled", {})
|
||
|
||
if screen == "priority":
|
||
old_playlist = cfg.get("priority", {}).get("playlist", [])
|
||
else:
|
||
screens_cfg = cfg.get("sites", {}).get(site, {}).get("screens", {})
|
||
old_playlist = screens_cfg.get(screen, {}).get("playlist", [])
|
||
|
||
# Mapping: Name → Original-Item (für Zoom etc.)
|
||
item_map = {}
|
||
for item in old_playlist:
|
||
item_map[playlist_item_name(item)] = item
|
||
|
||
# Neue Playlist in der gewünschten Reihenfolge bauen
|
||
new_playlist = []
|
||
for key in new_order:
|
||
if key in item_map:
|
||
item = dict(item_map[key]) if isinstance(item_map[key], dict) else {"name": item_map[key]}
|
||
item["enabled"] = enabled_map.get(key, True if not isinstance(item_map[key], dict) else item_map[key].get("enabled", True))
|
||
new_playlist.append(item)
|
||
else:
|
||
new_playlist.append({"url": key, "zoom": 1.0, "enabled": enabled_map.get(key, True)} if is_url(key) else {"name": key, "enabled": enabled_map.get(key, True)})
|
||
|
||
if screen == "priority":
|
||
cfg.setdefault("priority", {})["playlist"] = new_playlist
|
||
else:
|
||
cfg.setdefault("sites", {}).setdefault(site, {}).setdefault("screens", {}).setdefault(screen, {})["playlist"] = new_playlist
|
||
|
||
save_config(cfg)
|
||
return "", 204
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Main
|
||
# -------------------------------------------------
|
||
if __name__ == "__main__":
|
||
app.run(debug=True, host="0.0.0.0", port=5005)
|