From 56760e380d5877320334787400c9306b611d1119 Mon Sep 17 00:00:00 2001 From: bramval Date: Sun, 25 Jan 2026 15:57:38 +0100 Subject: [PATCH] Release v1.3 --- app/__init__.py | 12 ++ app/cli.py | 12 ++ app/models.py | 7 + app/routes/admin.py | 4 + app/routes/api.py | 12 +- app/routes/company.py | 200 ++++++++++++++++++++++++++ app/routes/display.py | 14 +- app/templates/company/dashboard.html | 21 ++- app/templates/company/my_company.html | 44 ++++++ app/templates/display/player.html | 49 +++++++ 10 files changed, 370 insertions(+), 5 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index 2e6d109..2b2822a 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -62,6 +62,13 @@ def create_app(): db.session.execute(db.text("ALTER TABLE display ADD COLUMN transition VARCHAR(20)")) db.session.commit() + # Displays: per-display overlay toggle + if "show_overlay" not in display_cols: + db.session.execute( + db.text("ALTER TABLE display ADD COLUMN show_overlay BOOLEAN NOT NULL DEFAULT 0") + ) + db.session.commit() + # Companies: optional per-company storage quota company_cols = [ r[1] for r in db.session.execute(db.text("PRAGMA table_info(company)")).fetchall() @@ -70,6 +77,11 @@ def create_app(): db.session.execute(db.text("ALTER TABLE company ADD COLUMN storage_max_bytes BIGINT")) db.session.commit() + # Companies: optional overlay file path + if "overlay_file_path" not in company_cols: + db.session.execute(db.text("ALTER TABLE company ADD COLUMN overlay_file_path VARCHAR(400)")) + db.session.commit() + # AppSettings: create settings table if missing. # (PRAGMA returns empty if the table doesn't exist.) settings_cols = [ diff --git a/app/cli.py b/app/cli.py index f9d4fa2..bcf7ff9 100644 --- a/app/cli.py +++ b/app/cli.py @@ -22,11 +22,23 @@ def _ensure_schema_and_settings() -> None: db.session.execute(db.text("ALTER TABLE display ADD COLUMN description VARCHAR(200)")) db.session.commit() + if "transition" not in display_cols: + db.session.execute(db.text("ALTER TABLE display ADD COLUMN transition VARCHAR(20)")) + db.session.commit() + + if "show_overlay" not in display_cols: + db.session.execute(db.text("ALTER TABLE display ADD COLUMN show_overlay BOOLEAN NOT NULL DEFAULT 0")) + db.session.commit() + company_cols = [r[1] for r in db.session.execute(db.text("PRAGMA table_info(company)")).fetchall()] if "storage_max_bytes" not in company_cols: db.session.execute(db.text("ALTER TABLE company ADD COLUMN storage_max_bytes BIGINT")) db.session.commit() + if "overlay_file_path" not in company_cols: + db.session.execute(db.text("ALTER TABLE company ADD COLUMN overlay_file_path VARCHAR(400)")) + db.session.commit() + settings_cols = [r[1] for r in db.session.execute(db.text("PRAGMA table_info(app_settings)")).fetchall()] if settings_cols and "public_domain" not in settings_cols: db.session.execute(db.text("ALTER TABLE app_settings ADD COLUMN public_domain VARCHAR(255)")) diff --git a/app/models.py b/app/models.py index f6c946a..18cb8fd 100644 --- a/app/models.py +++ b/app/models.py @@ -16,6 +16,10 @@ class Company(db.Model): # If NULL or <=0: unlimited. storage_max_bytes = db.Column(db.BigInteger, nullable=True) + # Optional per-company 16:9 PNG overlay (stored under /static/uploads/...) + # Example: uploads//overlay_.png + overlay_file_path = db.Column(db.String(400), nullable=True) + users = db.relationship("User", back_populates="company", cascade="all, delete-orphan") displays = db.relationship("Display", back_populates="company", cascade="all, delete-orphan") playlists = db.relationship("Playlist", back_populates="company", cascade="all, delete-orphan") @@ -102,6 +106,9 @@ class Display(db.Model): description = db.Column(db.String(200), nullable=True) # Transition animation between slides: none|fade|slide transition = db.Column(db.String(20), nullable=True) + + # If true, show the company's overlay PNG on top of the display content. + show_overlay = db.Column(db.Boolean, default=False, nullable=False) token = db.Column(db.String(64), unique=True, nullable=False, default=lambda: uuid.uuid4().hex) assigned_playlist_id = db.Column(db.Integer, db.ForeignKey("playlist.id"), nullable=True) diff --git a/app/routes/admin.py b/app/routes/admin.py index 4954700..81e495a 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -395,6 +395,10 @@ def delete_company(company_id: int): if it.item_type in ("image", "video"): _try_delete_upload(it.file_path, upload_folder) + # 3b) Clean up uploaded overlay (if any) + if company.overlay_file_path: + _try_delete_upload(company.overlay_file_path, upload_folder) + # 4) Delete the company; cascades will delete users/displays/playlists/items. company_name = company.name db.session.delete(company) diff --git a/app/routes/api.py b/app/routes/api.py index a0a2703..650fbb0 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -6,7 +6,8 @@ import time from flask import Blueprint, Response, abort, jsonify, request, stream_with_context, url_for from ..extensions import db -from ..models import Display, DisplayPlaylist, DisplaySession, Playlist, PlaylistItem +from ..models import Company, Display, DisplayPlaylist, DisplaySession, Playlist, PlaylistItem +from ..uploads import is_valid_upload_relpath bp = Blueprint("api", __name__, url_prefix="/api") @@ -173,6 +174,13 @@ def display_playlist(token: str): if not display: abort(404) + # Optional overlay URL (per-company) when enabled on this display. + overlay_src = None + if display.show_overlay: + company = Company.query.filter_by(id=display.company_id).first() + if company and company.overlay_file_path and is_valid_upload_relpath(company.overlay_file_path): + overlay_src = url_for("static", filename=company.overlay_file_path) + # Enforce: a display URL/token can be opened by max 3 concurrently active sessions. # Player sends a stable `sid` via querystring. sid = request.args.get("sid") @@ -200,6 +208,7 @@ def display_playlist(token: str): { "display": display.name, "transition": display.transition or "none", + "overlay_src": overlay_src, "playlists": [], "items": [], } @@ -263,6 +272,7 @@ def display_playlist(token: str): { "display": display.name, "transition": display.transition or "none", + "overlay_src": overlay_src, "playlists": [{"id": p.id, "name": p.name} for p in ordered_playlists], "items": items, } diff --git a/app/routes/company.py b/app/routes/company.py index c94a3f3..30c6069 100644 --- a/app/routes/company.py +++ b/app/routes/company.py @@ -26,6 +26,13 @@ from ..auth_tokens import make_password_reset_token ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff"} ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".webm", ".ogg", ".mov", ".m4v"} +# Overlay is a transparent PNG that sits on top of a display. +ALLOWED_OVERLAY_EXTENSIONS = {".png"} + +# Keep overlay size reasonable; it will be stretched to fit anyway. +# (PNG overlays are typically small-ish; 10MB is generous.) +MAX_OVERLAY_BYTES = 10 * 1024 * 1024 + # Videos should have a maximum upload size of 250MB MAX_VIDEO_BYTES = 250 * 1024 * 1024 @@ -175,6 +182,68 @@ def _try_delete_upload(file_path: str | None, upload_root: str): # Ignore cleanup failures pass + +def _save_overlay_png( + uploaded_file, + upload_root: str, + company_id: int | None, +) -> str: + """Save a company overlay as PNG under the company's upload dir. + + Returns relative file path under /static (uploads//overlay_.png) + """ + + unique = f"overlay_{uuid.uuid4().hex}.png" + company_dir = ensure_company_upload_dir(upload_root, company_id) + save_path = os.path.join(company_dir, unique) + + # Validate file is a PNG and is 16:9-ish. + # Use magic bytes (signature) instead of relying on Pillow's img.format, + # which can be unreliable if the stream position isn't at 0. + try: + if hasattr(uploaded_file, "stream"): + uploaded_file.stream.seek(0) + except Exception: + pass + + try: + sig = uploaded_file.stream.read(8) if hasattr(uploaded_file, "stream") else uploaded_file.read(8) + except Exception: + sig = b"" + + # PNG file signature: 89 50 4E 47 0D 0A 1A 0A + if sig != b"\x89PNG\r\n\x1a\n": + raise ValueError("not_png") + + # Rewind before Pillow parses. + try: + if hasattr(uploaded_file, "stream"): + uploaded_file.stream.seek(0) + except Exception: + pass + + img = Image.open(uploaded_file) + img = ImageOps.exif_transpose(img) + + w, h = img.size + if not w or not h: + raise ValueError("invalid") + + # Allow some tolerance (overlays may include extra transparent padding). + aspect = w / h + target = 16 / 9 + if abs(aspect - target) > 0.15: # ~15% tolerance + raise ValueError("not_16_9") + + # Ensure we preserve alpha; normalize mode. + if img.mode not in ("RGBA", "LA"): + # Convert to RGBA so transparency is supported consistently. + img = img.convert("RGBA") + + img.save(save_path, format="PNG", optimize=True) + company_seg = str(int(company_id)) if company_id is not None else "0" + return f"uploads/{company_seg}/{unique}" + bp = Blueprint("company", __name__, url_prefix="/company") @@ -276,10 +345,15 @@ def my_company(): users = User.query.filter_by(company_id=company.id, is_admin=False).order_by(User.email.asc()).all() + overlay_url = None + if company.overlay_file_path and is_valid_upload_relpath(company.overlay_file_path): + overlay_url = url_for("static", filename=company.overlay_file_path) + return render_template( "company/my_company.html", company=company, users=users, + overlay_url=overlay_url, stats={ "users": user_count, "displays": display_count, @@ -295,6 +369,111 @@ def my_company(): ) +@bp.post("/my-company/overlay") +@login_required +def upload_company_overlay(): + """Upload/replace the per-company 16:9 PNG overlay.""" + + company_user_required() + + company = db.session.get(Company, current_user.company_id) + if not company: + abort(404) + + f = request.files.get("overlay") + if not f or not f.filename: + flash("Overlay file is required", "danger") + return redirect(url_for("company.my_company")) + + filename = secure_filename(f.filename) + ext = os.path.splitext(filename)[1].lower() + if ext not in ALLOWED_OVERLAY_EXTENSIONS: + flash("Unsupported overlay type. Please upload a PNG file.", "danger") + return redirect(url_for("company.my_company")) + + # Enforce size limit best-effort. + size = None + try: + size = getattr(f, "content_length", None) + if (size is None or size <= 0) and hasattr(f, "stream"): + pos = f.stream.tell() + f.stream.seek(0, os.SEEK_END) + size = f.stream.tell() + f.stream.seek(pos, os.SEEK_SET) + except Exception: + size = None + if size is not None and size > MAX_OVERLAY_BYTES: + flash("Overlay file too large. Maximum allowed size is 10MB.", "danger") + return redirect(url_for("company.my_company")) + + # Enforce storage quota too (overlay is stored in the same uploads folder). + upload_root = current_app.config["UPLOAD_FOLDER"] + used_bytes = get_company_upload_bytes(upload_root, company.id) + usage = compute_storage_usage(used_bytes=used_bytes, max_bytes=company.storage_max_bytes) + storage_max_human = _format_bytes(usage["max_bytes"]) if usage.get("max_bytes") else None + if usage.get("is_exceeded"): + flash(_storage_limit_error_message(storage_max_human=storage_max_human), "danger") + return redirect(url_for("company.my_company")) + + old_path = company.overlay_file_path + try: + new_relpath = _save_overlay_png(f, upload_root, company.id) + except ValueError as e: + code = str(e) + if code == "not_png": + flash("Overlay must be a PNG file.", "danger") + elif code == "not_16_9": + flash("Overlay should be 16:9 (landscape).", "danger") + else: + flash("Failed to process overlay upload.", "danger") + return redirect(url_for("company.my_company")) + except Exception: + flash("Failed to process overlay upload.", "danger") + return redirect(url_for("company.my_company")) + + # Post-save quota check (like images) because PNG size is unknown until saved. + if company.storage_max_bytes is not None and int(company.storage_max_bytes or 0) > 0: + try: + used_after = get_company_upload_bytes(upload_root, company.id) + except Exception: + used_after = None + if used_after is not None: + usage_after = compute_storage_usage(used_bytes=used_after, max_bytes=company.storage_max_bytes) + if usage_after.get("is_exceeded"): + _try_delete_upload(new_relpath, upload_root) + flash(_storage_limit_error_message(storage_max_human=storage_max_human), "danger") + return redirect(url_for("company.my_company")) + + company.overlay_file_path = new_relpath + db.session.commit() + + # Clean up the old overlay file. + if old_path and old_path != new_relpath: + _try_delete_upload(old_path, upload_root) + + flash("Overlay updated.", "success") + return redirect(url_for("company.my_company")) + + +@bp.post("/my-company/overlay/delete") +@login_required +def delete_company_overlay(): + company_user_required() + + company = db.session.get(Company, current_user.company_id) + if not company: + abort(404) + + upload_root = current_app.config["UPLOAD_FOLDER"] + old_path = company.overlay_file_path + company.overlay_file_path = None + db.session.commit() + + _try_delete_upload(old_path, upload_root) + flash("Overlay removed.", "success") + return redirect(url_for("company.my_company")) + + @bp.post("/my-company/invite") @login_required def invite_user(): @@ -1004,6 +1183,26 @@ def update_display(display_id: int): # Form POST implies full update display.transition = _normalize_transition(request.form.get("transition")) + # Overlay toggle + if request.is_json: + if payload is None: + return _json_error("Invalid JSON") + if "show_overlay" in payload: + raw = payload.get("show_overlay") + # Accept common truthy representations. + if isinstance(raw, bool): + display.show_overlay = raw + elif raw in (1, 0): + display.show_overlay = bool(raw) + else: + s = ("" if raw is None else str(raw)).strip().lower() + display.show_overlay = s in {"1", "true", "yes", "on"} + else: + # Form POST implies full update + raw = request.form.get("show_overlay") + if raw is not None: + display.show_overlay = (raw or "").strip().lower() in {"1", "true", "yes", "on"} + # Playlist assignment if request.is_json: if "playlist_id" in payload: @@ -1044,6 +1243,7 @@ def update_display(display_id: int): "name": display.name, "description": display.description, "transition": display.transition, + "show_overlay": bool(display.show_overlay), "assigned_playlist_id": display.assigned_playlist_id, }, } diff --git a/app/routes/display.py b/app/routes/display.py index 2900a42..d3c155f 100644 --- a/app/routes/display.py +++ b/app/routes/display.py @@ -1,6 +1,7 @@ -from flask import Blueprint, abort, render_template +from flask import Blueprint, abort, render_template, url_for -from ..models import Display +from ..models import Company, Display +from ..uploads import is_valid_upload_relpath bp = Blueprint("display", __name__, url_prefix="/display") @@ -10,4 +11,11 @@ def display_player(token: str): display = Display.query.filter_by(token=token).first() if not display: abort(404) - return render_template("display/player.html", display=display) + + overlay_url = None + if display.show_overlay: + company = Company.query.filter_by(id=display.company_id).first() + if company and company.overlay_file_path and is_valid_upload_relpath(company.overlay_file_path): + overlay_url = url_for("static", filename=company.overlay_file_path) + + return render_template("display/player.html", display=display, overlay_url=overlay_url) diff --git a/app/templates/company/dashboard.html b/app/templates/company/dashboard.html index ffde3a9..e8b8595 100644 --- a/app/templates/company/dashboard.html +++ b/app/templates/company/dashboard.html @@ -96,6 +96,7 @@ data-display-name="{{ d.name }}" data-current-desc="{{ d.description or '' }}" data-current-transition="{{ d.transition or 'none' }}" + data-current-show-overlay="{{ '1' if d.show_overlay else '0' }}" data-legacy-playlist-id="{{ d.assigned_playlist_id or '' }}" data-active-playlist-ids="{{ d.display_playlists | map(attribute='playlist_id') | list | join(',') }}" > @@ -157,6 +158,12 @@
Applied on the display when switching between playlist items.
+ +
+ + +
If your company has an overlay uploaded, it will be displayed on top of the content.
+

Tick the playlists that should be active on this display.
@@ -301,6 +308,7 @@ const plDescInputEl = document.getElementById('editPlaylistsDescInput'); const plDescCountEl = document.getElementById('editPlaylistsDescCount'); const plTransitionEl = document.getElementById('editPlaylistsTransitionSelect'); + const plShowOverlayEl = document.getElementById('editPlaylistsShowOverlayCheck'); let activePlDisplayId = null; let activePlButton = null; @@ -365,6 +373,11 @@ const currentTransition = (btn.dataset.currentTransition || 'none').toLowerCase(); if (plTransitionEl) plTransitionEl.value = ['none','fade','slide'].includes(currentTransition) ? currentTransition : 'none'; + if (plShowOverlayEl) { + const raw = (btn.dataset.currentShowOverlay || '').toLowerCase(); + plShowOverlayEl.checked = raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on'; + } + const selected = computeActiveIdsFromDataset(btn); renderPlaylistCheckboxes(selected); if (plHintEl) { @@ -379,11 +392,12 @@ const ids = getSelectedPlaylistIdsFromModal(); const desc = plDescInputEl ? (plDescInputEl.value || '').trim() : ''; const transition = plTransitionEl ? (plTransitionEl.value || 'none') : 'none'; + const showOverlay = plShowOverlayEl ? !!plShowOverlayEl.checked : false; plSaveBtn.disabled = true; try { const [updatedPlaylists, updatedDesc] = await Promise.all([ postDisplayPlaylists(activePlDisplayId, ids), - postDisplayUpdate(activePlDisplayId, { description: desc, transition }) + postDisplayUpdate(activePlDisplayId, { description: desc, transition, show_overlay: showOverlay }) ]); const newIds = (updatedPlaylists && updatedPlaylists.active_playlist_ids) @@ -401,6 +415,11 @@ const newTransition = updatedDesc && typeof updatedDesc.transition === 'string' ? updatedDesc.transition : transition; activePlButton.dataset.currentTransition = newTransition || 'none'; + const newShowOverlay = updatedDesc && typeof updatedDesc.show_overlay !== 'undefined' + ? !!updatedDesc.show_overlay + : showOverlay; + activePlButton.dataset.currentShowOverlay = newShowOverlay ? '1' : '0'; + showToast('Display updated', 'text-bg-success'); refreshPreviewIframe(activePlDisplayId); if (plModal) plModal.hide(); diff --git a/app/templates/company/my_company.html b/app/templates/company/my_company.html index 1b94858..e33373d 100644 --- a/app/templates/company/my_company.html +++ b/app/templates/company/my_company.html @@ -90,6 +90,50 @@ +
+
+

Overlay

+
+
+
+ Upload a 16:9 PNG overlay. It will be rendered on top of the display content. + Transparent areas will show the content underneath. +
+ + {% if overlay_url %} +
+
Current overlay:
+
+ Company overlay +
+
+ {% else %} +
No overlay uploaded.
+ {% endif %} + +
+
+ + +
Tip: export at 1920×1080 (or any 16:9 size).
+
+
+ +
+
+ + {% if overlay_url %} +
+ +
+ {% endif %} +
+
+

Users

diff --git a/app/templates/display/player.html b/app/templates/display/player.html index 167153a..902af31 100644 --- a/app/templates/display/player.html +++ b/app/templates/display/player.html @@ -8,6 +8,17 @@ html, body { height: 100%; width: 100%; margin: 0; background: #000; overflow: hidden; } #stage { position: fixed; inset: 0; width: 100vw; height: 100vh; background: #000; } + /* Optional company overlay (transparent PNG) */ + #overlay { + position: fixed; + inset: 0; + width: 100vw; + height: 100vh; + pointer-events: none; + z-index: 5; + object-fit: contain; + } + /* Slide transitions (applied by JS via classes) */ #stage .slide { position: absolute; @@ -99,9 +110,13 @@
+ {% if overlay_url %} + Overlay + {% endif %}