Compare commits

7 Commits
v1.3 ... main

Author SHA1 Message Date
613f53ba9e Release 1.7.1 2026-01-28 13:33:18 +01:00
5221f9f670 Release 1.7 2026-01-27 16:16:23 +01:00
0c2720618a Release 1.6.2 2026-01-26 15:34:52 +01:00
c5aa8a5156 Release 1.6.1 2026-01-25 18:35:28 +01:00
9fd3f03b87 Release 1.6 2026-01-25 18:00:12 +01:00
860679d119 Release 1.5 2026-01-25 17:14:18 +01:00
78f0f379fc Make image crop target size configurable 2026-01-25 16:54:01 +01:00
12 changed files with 1821 additions and 66 deletions

View File

@@ -140,6 +140,34 @@ Notes:
- `GUNICORN_WORKERS` (default: 2)
- `GUNICORN_BIND` (default: `0.0.0.0:8000`)
## Release helper (git + docker publish)
This repo includes a small helper to:
1) ask for a **commit message** and **version**
2) commit + push to the `openslide` git remote
3) build + push Docker images:
- `git.alphen.cloud/bramval/openslide:<version>`
- `git.alphen.cloud/bramval/openslide:latest`
Run (interactive):
```bash
python scripts/release.py
```
Run (non-interactive):
```bash
python scripts/release.py --version 1.2.3 --message "Release 1.2.3"
```
Dry-run (prints commands only):
```bash
python scripts/release.py --version 1.2.3 --message "Release 1.2.3" --dry-run
```
## Notes
- SQLite DB is stored at `instance/signage.sqlite`.
@@ -159,6 +187,34 @@ The player keeps itself up-to-date automatically:
- It listens to `GET /api/display/<token>/events` (Server-Sent Events) and reloads the playlist immediately when it changes.
- It also does a fallback playlist refresh every 5 minutes for networks/proxies that block SSE.
## Ticker tape (RSS headlines)
Each display can optionally show a **bottom ticker tape** with scrolling news headlines.
Configure RSS + styling as a company user via:
- **My Company → Ticker tape (RSS)**
Company-level options:
- RSS feed URL (public http/https)
- Text color (picker)
- Background color + opacity
- Font (dropdown)
- Font size
- Speed
Per-display option:
- Enable/disable ticker on that display (Dashboard → Displays → Configure display)
Implementation notes:
- Headlines are fetched server-side via `GET /api/display/<token>/ticker` and cached in-memory.
- The player reads the company ticker settings via `GET /api/display/<token>/playlist`.
- The player auto-refreshes headlines without restart on a **long interval** (default: **12 hours**, override via `?ticker_poll=seconds`).
- Server-side cache TTL defaults to **6 hours** (override via env var `TICKER_CACHE_TTL_SECONDS`).
## SMTP / Forgot password
This project includes a simple **forgot password** flow. SMTP configuration is read from environment variables.
@@ -228,6 +284,11 @@ If the reset email is not received:

View File

@@ -20,6 +20,24 @@ def create_app():
app.config.setdefault("SQLALCHEMY_TRACK_MODIFICATIONS", False)
app.config.setdefault("UPLOAD_FOLDER", os.path.join(app.root_path, "static", "uploads"))
# Target output resolution for cropped images.
# This is used by the client-side cropper (to generate an upload) and by the server-side
# image processing (to cap the resulting WEBP size).
#
# Defaults to Full HD landscape (1920x1080). Portrait is derived by swapping.
# Override via env vars, e.g.:
# IMAGE_CROP_TARGET_W=1920
# IMAGE_CROP_TARGET_H=1080
def _env_int(name: str, default: int) -> int:
try:
v = int(os.environ.get(name, "") or default)
except (TypeError, ValueError):
v = default
return max(1, v)
app.config.setdefault("IMAGE_CROP_TARGET_W", _env_int("IMAGE_CROP_TARGET_W", 1920))
app.config.setdefault("IMAGE_CROP_TARGET_H", _env_int("IMAGE_CROP_TARGET_H", 1080))
# NOTE: Videos should be max 250MB.
# Flask's MAX_CONTENT_LENGTH applies to the full request payload (multipart includes overhead).
# We set this slightly above 250MB to allow for multipart/form fields overhead, while still
@@ -69,6 +87,34 @@ def create_app():
)
db.session.commit()
# Displays: optional ticker tape (RSS headlines)
if "ticker_enabled" not in display_cols:
db.session.execute(
db.text("ALTER TABLE display ADD COLUMN ticker_enabled BOOLEAN NOT NULL DEFAULT 0")
)
db.session.commit()
if "ticker_rss_url" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_rss_url VARCHAR(1000)"))
db.session.commit()
if "ticker_color" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_color VARCHAR(32)"))
db.session.commit()
if "ticker_bg_color" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_bg_color VARCHAR(32)"))
db.session.commit()
if "ticker_bg_opacity" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_bg_opacity INTEGER"))
db.session.commit()
if "ticker_font_family" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_font_family VARCHAR(120)"))
db.session.commit()
if "ticker_font_size_px" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_font_size_px INTEGER"))
db.session.commit()
if "ticker_speed" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_speed INTEGER"))
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()
@@ -82,6 +128,29 @@ def create_app():
db.session.execute(db.text("ALTER TABLE company ADD COLUMN overlay_file_path VARCHAR(400)"))
db.session.commit()
# Companies: ticker tape settings (RSS + styling)
if "ticker_rss_url" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_rss_url VARCHAR(1000)"))
db.session.commit()
if "ticker_color" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_color VARCHAR(32)"))
db.session.commit()
if "ticker_bg_color" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_bg_color VARCHAR(32)"))
db.session.commit()
if "ticker_bg_opacity" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_bg_opacity INTEGER"))
db.session.commit()
if "ticker_font_family" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_font_family VARCHAR(120)"))
db.session.commit()
if "ticker_font_size_px" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_font_size_px INTEGER"))
db.session.commit()
if "ticker_speed" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_speed INTEGER"))
db.session.commit()
# AppSettings: create settings table if missing.
# (PRAGMA returns empty if the table doesn't exist.)
settings_cols = [

View File

@@ -30,6 +30,32 @@ def _ensure_schema_and_settings() -> None:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN show_overlay BOOLEAN NOT NULL DEFAULT 0"))
db.session.commit()
# Optional ticker tape (RSS headlines)
if "ticker_enabled" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_enabled BOOLEAN NOT NULL DEFAULT 0"))
db.session.commit()
if "ticker_rss_url" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_rss_url VARCHAR(1000)"))
db.session.commit()
if "ticker_color" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_color VARCHAR(32)"))
db.session.commit()
if "ticker_bg_color" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_bg_color VARCHAR(32)"))
db.session.commit()
if "ticker_bg_opacity" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_bg_opacity INTEGER"))
db.session.commit()
if "ticker_font_family" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_font_family VARCHAR(120)"))
db.session.commit()
if "ticker_font_size_px" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_font_size_px INTEGER"))
db.session.commit()
if "ticker_speed" not in display_cols:
db.session.execute(db.text("ALTER TABLE display ADD COLUMN ticker_speed INTEGER"))
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"))
@@ -39,6 +65,29 @@ def _ensure_schema_and_settings() -> None:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN overlay_file_path VARCHAR(400)"))
db.session.commit()
# Companies: ticker tape settings (RSS + styling)
if "ticker_rss_url" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_rss_url VARCHAR(1000)"))
db.session.commit()
if "ticker_color" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_color VARCHAR(32)"))
db.session.commit()
if "ticker_bg_color" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_bg_color VARCHAR(32)"))
db.session.commit()
if "ticker_bg_opacity" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_bg_opacity INTEGER"))
db.session.commit()
if "ticker_font_family" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_font_family VARCHAR(120)"))
db.session.commit()
if "ticker_font_size_px" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_font_size_px INTEGER"))
db.session.commit()
if "ticker_speed" not in company_cols:
db.session.execute(db.text("ALTER TABLE company ADD COLUMN ticker_speed INTEGER"))
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)"))

View File

@@ -20,6 +20,16 @@ class Company(db.Model):
# Example: uploads/<company_id>/overlay_<uuid>.png
overlay_file_path = db.Column(db.String(400), nullable=True)
# Optional per-company ticker tape (RSS headlines) settings.
# Displays can enable/disable the ticker individually.
ticker_rss_url = db.Column(db.String(1000), nullable=True)
ticker_color = db.Column(db.String(32), nullable=True)
ticker_bg_color = db.Column(db.String(32), nullable=True)
ticker_bg_opacity = db.Column(db.Integer, nullable=True) # 0-100
ticker_font_family = db.Column(db.String(120), nullable=True)
ticker_font_size_px = db.Column(db.Integer, nullable=True)
ticker_speed = db.Column(db.Integer, nullable=True) # 1-100
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")
@@ -107,6 +117,17 @@ class Display(db.Model):
# Transition animation between slides: none|fade|slide
transition = db.Column(db.String(20), nullable=True)
# Optional ticker tape (RSS headlines) rendered on the display.
# Note: for this small project we avoid a JSON config blob; we store a few explicit columns.
ticker_enabled = db.Column(db.Boolean, default=False, nullable=False)
ticker_rss_url = db.Column(db.String(1000), nullable=True)
ticker_color = db.Column(db.String(32), nullable=True) # CSS color, e.g. "#ffffff"
ticker_bg_color = db.Column(db.String(32), nullable=True) # hex (without alpha); opacity in ticker_bg_opacity
ticker_bg_opacity = db.Column(db.Integer, nullable=True) # 0-100
ticker_font_family = db.Column(db.String(120), nullable=True) # CSS font-family
ticker_font_size_px = db.Column(db.Integer, nullable=True) # px
ticker_speed = db.Column(db.Integer, nullable=True) # 1-100 (UI slider); higher = faster
# 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)

View File

@@ -1,7 +1,12 @@
from datetime import datetime, timedelta
import hashlib
import json
import os
import time
import re
from urllib.parse import urlparse
from urllib.request import Request, urlopen
from xml.etree import ElementTree as ET
from flask import Blueprint, Response, abort, jsonify, request, stream_with_context, url_for
@@ -15,6 +20,21 @@ bp = Blueprint("api", __name__, url_prefix="/api")
MAX_ACTIVE_SESSIONS_PER_DISPLAY = 3
SESSION_TTL_SECONDS = 90
# RSS ticker cache (in-memory; OK for this small app; avoids hammering feeds)
#
# Default is intentionally long because displays can refresh headlines on a long interval
# (e.g., twice per day) and we don't want many displays to re-fetch the same feed.
# Override via env var `TICKER_CACHE_TTL_SECONDS`.
def _env_int(name: str, default: int) -> int:
try:
return int(os.environ.get(name, "") or default)
except (TypeError, ValueError):
return default
TICKER_CACHE_TTL_SECONDS = max(10, _env_int("TICKER_CACHE_TTL_SECONDS", 6 * 60 * 60))
_TICKER_CACHE: dict[str, dict] = {}
def _is_playlist_active_now(p: Playlist, now_utc: datetime) -> bool:
"""Return True if playlist is active based on its optional schedule window."""
@@ -168,16 +188,138 @@ def _playlist_signature(display: Display) -> tuple[int | None, str]:
return None, hashlib.sha1(raw.encode("utf-8")).hexdigest()
def _is_http_url_allowed(url: str) -> bool:
"""Basic SSRF hardening: only allow http(s) and disallow obvious local targets."""
try:
u = urlparse(url)
except Exception:
return False
if u.scheme not in {"http", "https"}:
return False
host = (u.hostname or "").strip().lower()
if not host:
return False
# Block localhost and common local domains.
if host in {"localhost", "127.0.0.1", "::1"}:
return False
# Block RFC1918-ish and link-local targets when host is an IP.
# Note: this is best-effort; proper SSRF protection would require DNS resolution too.
if re.match(r"^\d+\.\d+\.\d+\.\d+$", host):
parts = [int(x) for x in host.split(".")]
if parts[0] == 10:
return False
if parts[0] == 127:
return False
if parts[0] == 169 and parts[1] == 254:
return False
if parts[0] == 192 and parts[1] == 168:
return False
if parts[0] == 172 and 16 <= parts[1] <= 31:
return False
return True
def _strip_text(s: str) -> str:
s = (s or "").strip()
s = re.sub(r"\s+", " ", s)
return s
def _fetch_rss_titles(url: str, *, limit: int = 20) -> list[str]:
"""Fetch RSS/Atom titles from a feed URL.
We intentionally avoid adding dependencies (feedparser) for this project.
This implementation is tolerant enough for typical RSS2/Atom feeds.
"""
req = Request(
url,
headers={
"User-Agent": "SignageTicker/1.0 (+https://example.invalid)",
"Accept": "application/rss+xml, application/atom+xml, application/xml, text/xml, */*",
},
method="GET",
)
with urlopen(req, timeout=8) as resp:
# Basic size cap (avoid reading huge responses into memory)
raw = resp.read(2_000_000) # 2MB
try:
root = ET.fromstring(raw)
except Exception:
return []
titles: list[str] = []
# RSS2: <rss><channel><item><title>
for el in root.findall(".//item/title"):
t = _strip_text("".join(el.itertext()))
if t:
titles.append(t)
# Atom: <feed><entry><title>
if not titles:
for el in root.findall(".//{*}entry/{*}title"):
t = _strip_text("".join(el.itertext()))
if t:
titles.append(t)
# Some feeds may have <channel><title> etc; we only want entry titles.
# Deduplicate while preserving order.
deduped: list[str] = []
seen = set()
for t in titles:
if t in seen:
continue
seen.add(t)
deduped.append(t)
if len(deduped) >= limit:
break
return deduped
def _get_ticker_titles_cached(url: str) -> tuple[list[str], bool]:
"""Return (titles, from_cache)."""
now = time.time()
key = (url or "").strip()
if not key:
return [], True
entry = _TICKER_CACHE.get(key)
if entry and (now - float(entry.get("ts") or 0)) < TICKER_CACHE_TTL_SECONDS:
return (entry.get("titles") or []), True
titles: list[str] = []
try:
if _is_http_url_allowed(key):
titles = _fetch_rss_titles(key)
except Exception:
titles = []
_TICKER_CACHE[key] = {"ts": now, "titles": titles}
return titles, False
@bp.get("/display/<token>/playlist")
def display_playlist(token: str):
display = Display.query.filter_by(token=token).first()
if not display:
abort(404)
company = Company.query.filter_by(id=display.company_id).first()
# 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)
@@ -188,6 +330,18 @@ def display_playlist(token: str):
if not ok:
return resp
# Ticker settings are configured per-company; displays can enable/disable individually.
ticker_cfg = {
"enabled": bool(display.ticker_enabled),
"rss_url": (company.ticker_rss_url if company else None),
"color": (company.ticker_color if company else None),
"bg_color": (company.ticker_bg_color if company else None),
"bg_opacity": (company.ticker_bg_opacity if company else None),
"font_family": (company.ticker_font_family if company else None),
"font_size_px": (company.ticker_font_size_px if company else None),
"speed": (company.ticker_speed if company else None),
}
# Determine active playlists. If display_playlist has any rows, use those.
# Otherwise fall back to the legacy assigned_playlist_id.
mapped_ids = [
@@ -209,6 +363,7 @@ def display_playlist(token: str):
"display": display.name,
"transition": display.transition or "none",
"overlay_src": overlay_src,
"ticker": ticker_cfg,
"playlists": [],
"items": [],
}
@@ -273,12 +428,50 @@ def display_playlist(token: str):
"display": display.name,
"transition": display.transition or "none",
"overlay_src": overlay_src,
"ticker": ticker_cfg,
"playlists": [{"id": p.id, "name": p.name} for p in ordered_playlists],
"items": items,
}
)
@bp.get("/display/<token>/ticker")
def display_ticker(token: str):
"""Return ticker headlines for a display.
We keep it separate from /playlist so the player can refresh headlines on its own interval.
"""
display = Display.query.filter_by(token=token).first()
if not display:
abort(404)
company = Company.query.filter_by(id=display.company_id).first()
# Enforce concurrent session limit the same way as /playlist.
sid = request.args.get("sid")
ok, resp = _enforce_and_touch_display_session(display, sid)
if not ok:
return resp
if not display.ticker_enabled:
return jsonify({"enabled": False, "headlines": []})
rss_url = ((company.ticker_rss_url if company else None) or "").strip()
if not rss_url:
return jsonify({"enabled": True, "headlines": []})
titles, from_cache = _get_ticker_titles_cached(rss_url)
return jsonify(
{
"enabled": True,
"rss_url": rss_url,
"headlines": titles,
"cached": bool(from_cache),
}
)
@bp.get("/display/<token>/events")
def display_events(token: str):
"""Server-Sent Events stream to notify the player when its playlist changes."""

View File

@@ -147,15 +147,22 @@ def _save_compressed_image(
img = img.convert("RGB")
# Optional crop
# NOTE: The front-end may already upload a cropped image (canvas export), but we still
# enforce aspect + maximum output size here for consistency.
target_w = int(current_app.config.get("IMAGE_CROP_TARGET_W", 1920) or 1920)
target_h = int(current_app.config.get("IMAGE_CROP_TARGET_H", 1080) or 1080)
target_w = max(1, target_w)
target_h = max(1, target_h)
if cm == "16:9":
img = _center_crop_to_aspect(img, 16, 9)
max_box = (1920, 1080)
max_box = (target_w, target_h)
elif cm == "9:16":
img = _center_crop_to_aspect(img, 9, 16)
max_box = (1080, 1920)
max_box = (target_h, target_w)
else:
# No crop: allow both portrait and landscape up to 1920px on the longest side.
max_box = (1920, 1920)
# No crop: allow both portrait and landscape up to target_w/target_h on the longest side.
max_box = (max(target_w, target_h),) * 2
# Resize down if very large (keeps aspect ratio)
img.thumbnail(max_box)
@@ -308,6 +315,44 @@ def _storage_limit_error_message(*, storage_max_human: str | None) -> str:
return "Storage limit reached. Please delete items to free space."
def _normalize_css_color(val: str | None) -> str | None:
"""Accept a limited set of CSS color inputs (primarily hex + a few keywords)."""
v = (val or "").strip()
if not v:
return None
low = v.lower()
if low in {"white", "black", "red", "green", "blue", "yellow", "orange", "purple", "gray", "grey"}:
return low
if low.startswith("#"):
h = low[1:]
if len(h) in {3, 6, 8} and all(c in "0123456789abcdef" for c in h):
return "#" + h
return None
def _normalize_font_family(val: str | None) -> str | None:
v = (val or "").strip()
if not v:
return None
v = v.replace("\n", " ").replace("\r", " ").replace('"', "").replace("'", "")
v = " ".join(v.split())
return v[:120] if v else None
def _normalize_int(val, *, min_val: int, max_val: int) -> int | None:
if val in (None, ""):
return None
try:
n = int(val)
except (TypeError, ValueError):
return None
return min(max_val, max(min_val, n))
@bp.get("/my-company")
@login_required
def my_company():
@@ -369,6 +414,37 @@ def my_company():
)
@bp.post("/my-company/ticker")
@login_required
def update_company_ticker_settings():
"""Update per-company ticker tape settings.
Note: per-display enable/disable remains on the Display model.
"""
company_user_required()
company = db.session.get(Company, current_user.company_id)
if not company:
abort(404)
rss_url = (request.form.get("ticker_rss_url") or "").strip() or None
if rss_url is not None:
rss_url = rss_url[:1000]
company.ticker_rss_url = rss_url
company.ticker_color = _normalize_css_color(request.form.get("ticker_color"))
company.ticker_bg_color = _normalize_css_color(request.form.get("ticker_bg_color"))
company.ticker_bg_opacity = _normalize_int(request.form.get("ticker_bg_opacity"), min_val=0, max_val=100)
company.ticker_font_family = _normalize_font_family(request.form.get("ticker_font_family"))
company.ticker_font_size_px = _normalize_int(request.form.get("ticker_font_size_px"), min_val=10, max_val=200)
company.ticker_speed = _normalize_int(request.form.get("ticker_speed"), min_val=1, max_val=100)
db.session.commit()
flash("Ticker settings updated.", "success")
return redirect(url_for("company.my_company"))
@bp.post("/my-company/overlay")
@login_required
def upload_company_overlay():
@@ -1038,6 +1114,149 @@ def add_playlist_item(playlist_id: int):
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
@bp.post("/playlists/<int:playlist_id>/items/bulk-images")
@login_required
def bulk_upload_playlist_images(playlist_id: int):
"""Bulk upload multiple images to a playlist.
Expects multipart/form-data:
- files: multiple image files
- crop_mode: "16:9" or "9:16" (optional; defaults to 16:9)
- duration_seconds: optional (defaults to 10)
Returns JSON:
{ ok: true, items: [...] }
"""
company_user_required()
playlist = db.session.get(Playlist, playlist_id)
if not playlist or playlist.company_id != current_user.company_id:
abort(404)
# This endpoint is intended for AJAX.
def _json_error(message: str, status: int = 400):
return jsonify({"ok": False, "error": message}), status
files = request.files.getlist("files")
if not files:
return _json_error("No files uploaded")
crop_mode = (request.form.get("crop_mode") or "16:9").strip().lower()
if crop_mode not in {"16:9", "9:16"}:
crop_mode = "16:9"
raw_duration = request.form.get("duration_seconds")
try:
duration = int(raw_duration) if raw_duration is not None else 10
except (TypeError, ValueError):
duration = 10
duration = max(1, duration)
# Quota check before processing.
with db.session.no_autoflush:
company = db.session.get(Company, current_user.company_id)
if not company:
abort(404)
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"):
return _json_error(_storage_limit_error_message(storage_max_human=storage_max_human), 403)
# Determine starting position.
max_pos = (
db.session.query(db.func.max(PlaylistItem.position)).filter_by(playlist_id=playlist_id).scalar() or 0
)
saved_relpaths: list[str] = []
items: list[PlaylistItem] = []
try:
for idx, f in enumerate(files, start=1):
if not f or not f.filename:
raise ValueError("file_required")
filename = secure_filename(f.filename)
ext = os.path.splitext(filename)[1].lower()
if ext not in ALLOWED_IMAGE_EXTENSIONS:
raise ValueError("unsupported")
relpath = _save_compressed_image(
f,
current_app.config["UPLOAD_FOLDER"],
current_user.company_id,
crop_mode=crop_mode,
)
saved_relpaths.append(relpath)
it = PlaylistItem(
playlist=playlist,
item_type="image",
title=None,
duration_seconds=duration,
position=max_pos + idx,
file_path=relpath,
)
items.append(it)
# Post-save quota check (like single image uploads)
if company.storage_max_bytes is not None and int(company.storage_max_bytes or 0) > 0:
used_after = get_company_upload_bytes(upload_root, company.id)
usage_after = compute_storage_usage(used_bytes=used_after, max_bytes=company.storage_max_bytes)
if usage_after.get("is_exceeded"):
# Remove all newly saved files and reject.
for p in saved_relpaths:
_try_delete_upload(p, upload_root)
return _json_error(_storage_limit_error_message(storage_max_human=storage_max_human), 403)
for it in items:
db.session.add(it)
db.session.commit()
except ValueError as e:
# Clean up any saved files.
upload_root = current_app.config["UPLOAD_FOLDER"]
for p in saved_relpaths:
_try_delete_upload(p, upload_root)
code = str(e)
if code == "unsupported":
return _json_error(
"Unsupported image type. Please upload one of: " + ", ".join(sorted(ALLOWED_IMAGE_EXTENSIONS))
)
if code == "file_required":
return _json_error("File required")
return _json_error("Failed to process image upload", 500)
except Exception:
db.session.rollback()
upload_root = current_app.config["UPLOAD_FOLDER"]
for p in saved_relpaths:
_try_delete_upload(p, upload_root)
return _json_error("Failed to process image upload", 500)
return jsonify(
{
"ok": True,
"items": [
{
"id": it.id,
"playlist_id": it.playlist_id,
"position": it.position,
"item_type": it.item_type,
"title": it.title,
"file_path": it.file_path,
"url": it.url,
"duration_seconds": it.duration_seconds,
}
for it in items
],
}
)
@bp.post("/items/<int:item_id>/delete")
@login_required
def delete_item(item_id: int):
@@ -1154,6 +1373,64 @@ def update_display(display_id: int):
return None
return v
def _normalize_css_color(val: str | None) -> str | None:
"""Accept a limited set of CSS color inputs (primarily hex + a few keywords).
This is used to avoid storing arbitrary CSS strings while still being user friendly.
"""
v = (val or "").strip()
if not v:
return None
low = v.lower()
if low in {"white", "black", "red", "green", "blue", "yellow", "orange", "purple", "gray", "grey"}:
return low
# Hex colors: #RGB, #RRGGBB, #RRGGBBAA
if low.startswith("#"):
h = low[1:]
if len(h) in {3, 6, 8} and all(c in "0123456789abcdef" for c in h):
return "#" + h
return None
def _normalize_percent(val) -> int | None:
if val in (None, ""):
return None
try:
n = int(val)
except (TypeError, ValueError):
return None
return min(100, max(0, n))
def _normalize_speed(val) -> int | None:
if val in (None, ""):
return None
try:
n = int(val)
except (TypeError, ValueError):
return None
return min(100, max(1, n))
def _normalize_font_family(val: str | None) -> str | None:
v = (val or "").strip()
if not v:
return None
# keep it short and avoid quotes/newlines that could be abused in CSS.
v = v.replace("\n", " ").replace("\r", " ").replace('"', "").replace("'", "")
v = " ".join(v.split())
return v[:120] if v else None
def _normalize_font_size_px(val) -> int | None:
if val in (None, ""):
return None
try:
n = int(val)
except (TypeError, ValueError):
return None
# reasonable bounds for signage displays
return min(200, max(10, n))
# Inputs from either form or JSON
payload = request.get_json(silent=True) if request.is_json else None
@@ -1203,6 +1480,26 @@ def update_display(display_id: int):
if raw is not None:
display.show_overlay = (raw or "").strip().lower() in {"1", "true", "yes", "on"}
# Ticker tape: per-display enable/disable only.
# RSS + styling is configured per-company (see /my-company).
if request.is_json:
if payload is None:
return _json_error("Invalid JSON")
if "ticker_enabled" in payload:
raw = payload.get("ticker_enabled")
if isinstance(raw, bool):
display.ticker_enabled = raw
elif raw in (1, 0):
display.ticker_enabled = bool(raw)
else:
s = ("" if raw is None else str(raw)).strip().lower()
display.ticker_enabled = s in {"1", "true", "yes", "on"}
else:
raw = request.form.get("ticker_enabled")
if raw is not None:
display.ticker_enabled = (raw or "").strip().lower() in {"1", "true", "yes", "on"}
# Playlist assignment
if request.is_json:
if "playlist_id" in payload:
@@ -1244,6 +1541,14 @@ def update_display(display_id: int):
"description": display.description,
"transition": display.transition,
"show_overlay": bool(display.show_overlay),
"ticker_enabled": bool(display.ticker_enabled),
"ticker_rss_url": None,
"ticker_color": None,
"ticker_bg_color": None,
"ticker_bg_opacity": None,
"ticker_font_family": None,
"ticker_font_size_px": None,
"ticker_speed": None,
"assigned_playlist_id": display.assigned_playlist_id,
},
}

View File

@@ -280,6 +280,13 @@ h1, h2, h3, .display-1, .display-2, .display-3 {
background: #000;
}
/* Mobile: dashboard display previews are heavy (iframes). Hide them on small screens. */
@media (max-width: 768px) {
.display-gallery-card .display-preview {
display: none;
}
}
@media (max-width: 768px) {
.display-gallery-grid {
grid-template-columns: 1fr;
@@ -307,3 +314,9 @@ h1, h2, h3, .display-1, .display-2, .display-3 {
.schedule-status-dot.inactive {
background: #dc3545;
}
/* Dropzone disabled state (used by bulk upload) */
.dropzone.disabled {
opacity: 0.6;
pointer-events: none;
}

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block content %}
<h1 class="page-title">Welcome{% if current_user and current_user.email %}, {{ current_user.email }}{% endif %}!</h1>
<h1 class="page-title">Dashboard</h1>
<div class="row mt-4">
<div class="col-12">
@@ -69,14 +69,21 @@
<div class="col-12 col-md-6 col-xl-4">
<div class="card display-gallery-card h-100">
<div class="display-preview">
<div
class="display-preview-scale"
style="width: 1000%; height: 1000%; transform: scale(0.1); transform-origin: top left;"
>
<iframe
title="Preview — {{ d.name }}"
data-display-id="{{ d.id }}"
src="{{ url_for('display.display_player', token=d.token) }}?preview=1"
class="js-display-preview"
data-preview-src="{{ url_for('display.display_player', token=d.token) }}?preview=1"
loading="lazy"
referrerpolicy="no-referrer"
style="width: 100%; height: 100%; border: 0;"
></iframe>
</div>
</div>
<div class="card-body d-flex flex-column gap-2">
<div>
@@ -97,6 +104,7 @@
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-current-ticker-enabled="{{ '1' if d.ticker_enabled else '0' }}"
data-legacy-playlist-id="{{ d.assigned_playlist_id or '' }}"
data-active-playlist-ids="{{ d.display_playlists | map(attribute='playlist_id') | list | join(',') }}"
>
@@ -164,6 +172,16 @@
<label class="form-check-label" for="editPlaylistsShowOverlayCheck">Show company overlay</label>
<div class="form-text">If your company has an overlay uploaded, it will be displayed on top of the content.</div>
</div>
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" id="editTickerEnabled" />
<label class="form-check-label" for="editTickerEnabled">
Enable ticker tape on this display
</label>
<div class="form-text">
RSS feed + styling is configured in <a href="{{ url_for('company.my_company') }}">My Company</a>.
</div>
</div>
<hr class="my-3" />
<div class="text-muted small mb-2">Tick the playlists that should be active on this display.</div>
<div id="editPlaylistsList" class="d-flex flex-column gap-2"></div>
@@ -224,17 +242,23 @@
function refreshPreviewIframe(displayId) {
const iframe = document.querySelector(`iframe[data-display-id="${displayId}"]`);
if (!iframe || !iframe.src) return;
// Previews are disabled on mobile.
if (window.matchMedia && window.matchMedia('(max-width: 768px)').matches) return;
if (!iframe) return;
try {
const u = new URL(iframe.src, window.location.origin);
const baseSrc = iframe.dataset.previewSrc || iframe.src;
if (!baseSrc) return;
const u = new URL(baseSrc, window.location.origin);
// Ensure preview flag is present (and bust cache).
u.searchParams.set('preview', '1');
u.searchParams.set('_ts', String(Date.now()));
iframe.src = u.toString();
} catch (e) {
// Fallback: naive cache buster
const sep = iframe.src.includes('?') ? '&' : '?';
iframe.src = `${iframe.src}${sep}_ts=${Date.now()}`;
const baseSrc = iframe.dataset.previewSrc || iframe.src;
if (!baseSrc) return;
const sep = baseSrc.includes('?') ? '&' : '?';
iframe.src = `${baseSrc}${sep}_ts=${Date.now()}`;
}
}
@@ -309,6 +333,7 @@
const plDescCountEl = document.getElementById('editPlaylistsDescCount');
const plTransitionEl = document.getElementById('editPlaylistsTransitionSelect');
const plShowOverlayEl = document.getElementById('editPlaylistsShowOverlayCheck');
const tickerEnabledEl = document.getElementById('editTickerEnabled');
let activePlDisplayId = null;
let activePlButton = null;
@@ -378,6 +403,11 @@
plShowOverlayEl.checked = raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
}
if (tickerEnabledEl) {
const raw = (btn.dataset.currentTickerEnabled || '').toLowerCase();
tickerEnabledEl.checked = raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
}
const selected = computeActiveIdsFromDataset(btn);
renderPlaylistCheckboxes(selected);
if (plHintEl) {
@@ -393,11 +423,17 @@
const desc = plDescInputEl ? (plDescInputEl.value || '').trim() : '';
const transition = plTransitionEl ? (plTransitionEl.value || 'none') : 'none';
const showOverlay = plShowOverlayEl ? !!plShowOverlayEl.checked : false;
const tickerEnabled = tickerEnabledEl ? !!tickerEnabledEl.checked : false;
plSaveBtn.disabled = true;
try {
const [updatedPlaylists, updatedDesc] = await Promise.all([
postDisplayPlaylists(activePlDisplayId, ids),
postDisplayUpdate(activePlDisplayId, { description: desc, transition, show_overlay: showOverlay })
postDisplayUpdate(activePlDisplayId, {
description: desc,
transition,
show_overlay: showOverlay,
ticker_enabled: tickerEnabled
})
]);
const newIds = (updatedPlaylists && updatedPlaylists.active_playlist_ids)
@@ -420,6 +456,11 @@
: showOverlay;
activePlButton.dataset.currentShowOverlay = newShowOverlay ? '1' : '0';
const newTickerEnabled = updatedDesc && typeof updatedDesc.ticker_enabled !== 'undefined'
? !!updatedDesc.ticker_enabled
: tickerEnabled;
activePlButton.dataset.currentTickerEnabled = newTickerEnabled ? '1' : '0';
showToast('Display updated', 'text-bg-success');
refreshPreviewIframe(activePlDisplayId);
if (plModal) plModal.hide();
@@ -433,6 +474,31 @@
if (plSaveBtn) {
plSaveBtn.addEventListener('click', savePlaylists);
}
// Disable dashboard previews on small screens (mobile): don't even set iframe src.
function loadDashboardPreviewsIfDesktop() {
const isMobile = window.matchMedia && window.matchMedia('(max-width: 768px)').matches;
if (isMobile) return;
document.querySelectorAll('iframe.js-display-preview[data-preview-src]').forEach((iframe) => {
if (!iframe.src || iframe.src === 'about:blank') {
iframe.src = iframe.dataset.previewSrc;
}
});
}
loadDashboardPreviewsIfDesktop();
// If user rotates/resizes from mobile -> desktop, load previews then.
if (window.matchMedia) {
const mql = window.matchMedia('(max-width: 768px)');
const onChange = () => {
if (!mql.matches) loadDashboardPreviewsIfDesktop();
};
if (typeof mql.addEventListener === 'function') {
mql.addEventListener('change', onChange);
} else if (typeof mql.addListener === 'function') {
mql.addListener(onChange);
}
}
})();
</script>
{% endblock %}

View File

@@ -90,50 +90,6 @@
</div>
</div>
<div class="card card-elevated mt-4">
<div class="card-header">
<h2 class="h5 mb-0">Overlay</h2>
</div>
<div class="card-body">
<div class="text-muted small mb-3">
Upload a <strong>16:9 PNG</strong> overlay. It will be rendered on top of the display content.
Transparent areas will show the content underneath.
</div>
{% if overlay_url %}
<div class="mb-3">
<div class="text-muted small mb-2">Current overlay:</div>
<div style="max-width: 520px; border: 1px solid rgba(0,0,0,0.15); border-radius: 8px; overflow: hidden;">
<img
src="{{ overlay_url }}"
alt="Company overlay"
style="display:block; width:100%; height:auto; background: repeating-linear-gradient(45deg, #eee 0 12px, #fff 12px 24px);"
/>
</div>
</div>
{% else %}
<div class="text-muted mb-3">No overlay uploaded.</div>
{% endif %}
<form method="post" action="{{ url_for('company.upload_company_overlay') }}" enctype="multipart/form-data" class="d-flex gap-2 flex-wrap align-items-end">
<div>
<label class="form-label">Upload overlay (PNG)</label>
<input class="form-control" type="file" name="overlay" accept="image/png" required />
<div class="form-text">Tip: export at 1920×1080 (or any 16:9 size).</div>
</div>
<div>
<button class="btn btn-brand" type="submit">Save overlay</button>
</div>
</form>
{% if overlay_url %}
<form method="post" action="{{ url_for('company.delete_company_overlay') }}" class="mt-3" onsubmit="return confirm('Remove the overlay?');">
<button class="btn btn-outline-danger" type="submit">Remove overlay</button>
</form>
{% endif %}
</div>
</div>
<div class="card card-elevated mt-4">
<div class="card-header">
<h2 class="h5 mb-0">Users</h2>
@@ -181,5 +137,178 @@
</tbody>
</table>
</div>
</div>
<div class="card card-elevated mt-4">
<div class="card-header">
<h2 class="h5 mb-0">Overlay</h2>
</div>
<div class="card-body">
<div class="text-muted small mb-3">
Upload a <strong>16:9 PNG</strong> overlay. It will be rendered on top of the display content.
Transparent areas will show the content underneath.
</div>
{% if overlay_url %}
<div class="mb-3">
<div class="text-muted small mb-2">Current overlay:</div>
<div style="max-width: 520px; border: 1px solid rgba(0,0,0,0.15); border-radius: 8px; overflow: hidden;">
<img
src="{{ overlay_url }}"
alt="Company overlay"
style="display:block; width:100%; height:auto; background: repeating-linear-gradient(45deg, #eee 0 12px, #fff 12px 24px);"
/>
</div>
</div>
{% else %}
<div class="text-muted mb-3">No overlay uploaded.</div>
{% endif %}
<form method="post" action="{{ url_for('company.upload_company_overlay') }}" enctype="multipart/form-data" class="d-flex gap-2 flex-wrap align-items-end">
<div>
<label class="form-label">Upload overlay (PNG)</label>
<input class="form-control" type="file" name="overlay" accept="image/png" required />
<div class="form-text">Tip: export at 1920×1080 (or any 16:9 size).</div>
</div>
<div>
<button class="btn btn-brand" type="submit">Save overlay</button>
</div>
</form>
{% if overlay_url %}
<form method="post" action="{{ url_for('company.delete_company_overlay') }}" class="mt-3" onsubmit="return confirm('Remove the overlay?');">
<button class="btn btn-outline-danger" type="submit">Remove overlay</button>
</form>
{% endif %}
</div>
</div>
<div class="card card-elevated mt-4">
<div class="card-header">
<h2 class="h5 mb-0">Ticker tape (RSS)</h2>
</div>
<div class="card-body">
<div class="text-muted small mb-3">
Configure the RSS feed and styling for the ticker tape. Individual displays can enable/disable the ticker from the Dashboard.
</div>
<form method="post" action="{{ url_for('company.update_company_ticker_settings') }}" class="d-flex flex-column gap-3">
<div>
<label class="form-label" for="companyTickerRssUrl">RSS feed URL</label>
<input
class="form-control"
id="companyTickerRssUrl"
name="ticker_rss_url"
type="url"
value="{{ company.ticker_rss_url or '' }}"
placeholder="https://example.com/feed.xml"
/>
<div class="form-text">Leave empty to disable headlines (even if displays have ticker enabled).</div>
</div>
<div class="row g-2">
<div class="col-12 col-md-4">
<label class="form-label" for="companyTickerColor">Text color</label>
<input
class="form-control form-control-color"
id="companyTickerColor"
name="ticker_color"
type="color"
value="{{ company.ticker_color or '#ffffff' }}"
title="Choose text color"
/>
</div>
<div class="col-12 col-md-4">
<label class="form-label" for="companyTickerBgColor">Background color</label>
<input
class="form-control form-control-color"
id="companyTickerBgColor"
name="ticker_bg_color"
type="color"
value="{{ company.ticker_bg_color or '#000000' }}"
title="Choose background color"
/>
</div>
<div class="col-12 col-md-4">
<label class="form-label" for="companyTickerBgOpacity">Background opacity</label>
<input
class="form-range"
id="companyTickerBgOpacity"
name="ticker_bg_opacity"
type="range"
min="0"
max="100"
step="1"
value="{{ company.ticker_bg_opacity if company.ticker_bg_opacity is not none else 75 }}"
/>
<div class="form-text"><span id="companyTickerBgOpacityLabel">{{ company.ticker_bg_opacity if company.ticker_bg_opacity is not none else 75 }}</span>%</div>
</div>
</div>
<div class="row g-2">
<div class="col-12 col-md-6">
<label class="form-label" for="companyTickerFontFamily">Font</label>
<select class="form-select" id="companyTickerFontFamily" name="ticker_font_family">
{% set ff = company.ticker_font_family or 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif' %}
<option value="system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif" {{ 'selected' if ff == 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif' else '' }}>System (default)</option>
<option value="Arial, Helvetica, sans-serif" {{ 'selected' if ff == 'Arial, Helvetica, sans-serif' else '' }}>Arial</option>
<option value="Segoe UI, Arial, sans-serif" {{ 'selected' if ff == 'Segoe UI, Arial, sans-serif' else '' }}>Segoe UI</option>
<option value="Roboto, Arial, sans-serif" {{ 'selected' if ff == 'Roboto, Arial, sans-serif' else '' }}>Roboto</option>
<option value="Georgia, serif" {{ 'selected' if ff == 'Georgia, serif' else '' }}>Georgia</option>
<option value="Times New Roman, Times, serif" {{ 'selected' if ff == 'Times New Roman, Times, serif' else '' }}>Times New Roman</option>
<option value="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace" {{ 'selected' if ff == 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace' else '' }}>Monospace</option>
</select>
</div>
<div class="col-12 col-md-3">
<label class="form-label" for="companyTickerFontSize">Font size (px)</label>
<input
class="form-control"
id="companyTickerFontSize"
name="ticker_font_size_px"
type="number"
min="10"
max="200"
step="1"
value="{{ company.ticker_font_size_px if company.ticker_font_size_px is not none else 28 }}"
/>
</div>
<div class="col-12 col-md-3">
<label class="form-label" for="companyTickerSpeed">Speed</label>
<input
class="form-range"
id="companyTickerSpeed"
name="ticker_speed"
type="range"
min="1"
max="100"
step="1"
value="{{ company.ticker_speed if company.ticker_speed is not none else 25 }}"
/>
<div class="form-text">Slower ⟷ Faster</div>
</div>
</div>
<div>
<button class="btn btn-brand" type="submit">Save ticker settings</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block page_scripts %}
<script>
(function () {
const opacityEl = document.getElementById('companyTickerBgOpacity');
const opacityLabelEl = document.getElementById('companyTickerBgOpacityLabel');
function syncOpacity() {
if (!opacityEl || !opacityLabelEl) return;
opacityLabelEl.textContent = String(opacityEl.value || '0');
}
if (opacityEl) opacityEl.addEventListener('input', syncOpacity);
syncOpacity();
})();
</script>
{% endblock %}

View File

@@ -1,5 +1,12 @@
{% extends "base.html" %}
{% block content %}
{# Expose server-side crop target sizes to the JS without embedding Jinja inside JS #}
<div
id="page-config"
class="d-none"
data-image-crop-target-w="{{ config.get('IMAGE_CROP_TARGET_W', 1920) }}"
data-image-crop-target-h="{{ config.get('IMAGE_CROP_TARGET_H', 1080) }}"
></div>
{# Cropper.js (used for image cropping) #}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cropperjs@1.6.2/dist/cropper.min.css" />
<style>
@@ -230,8 +237,11 @@
<h2 class="h5 mb-0">Items</h2>
<div class="text-muted small">Tip: drag items to reorder. Changes save automatically.</div>
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary" type="button" id="open-bulk-upload">Bulk upload</button>
<button class="btn btn-brand" type="button" id="open-add-item">Add item</button>
</div>
</div>
<div class="mt-3">
<div class="small text-muted mb-2" id="reorder-status" aria-live="polite"></div>
@@ -506,6 +516,65 @@
</div>
</div>
{# Bulk upload images modal #}
<div class="modal fade" id="bulkUploadModal" tabindex="-1" aria-labelledby="bulkUploadModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="bulkUploadModalLabel">Bulk upload images</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="text-muted small mb-2">Choose an aspect ratio. Images will be center-cropped automatically.</div>
<div class="mb-3">
<div class="btn-group w-100" role="group" aria-label="Bulk crop mode">
<input type="radio" class="btn-check" name="bulk_crop_mode_choice" id="bulk-crop-16-9" autocomplete="off" checked>
<label class="btn btn-outline-primary" for="bulk-crop-16-9">16:9 (landscape)</label>
<input type="radio" class="btn-check" name="bulk_crop_mode_choice" id="bulk-crop-9-16" autocomplete="off">
<label class="btn btn-outline-primary" for="bulk-crop-9-16">9:16 (portrait)</label>
</div>
</div>
<div id="bulk-dropzone" class="dropzone">
<div><strong>Drag & drop</strong> multiple images here</div>
<div class="text-muted small">or click to select files</div>
</div>
<input id="bulk-file-input" class="form-control d-none" type="file" accept="image/*" multiple />
<div class="mt-3">
<div class="small text-muted" id="bulk-status" aria-live="polite"></div>
<div id="bulk-upload-progress" class="d-none mt-2" aria-live="polite">
<div class="progress" style="height: 10px;">
<div
id="bulk-upload-progress-bar"
class="progress-bar"
role="progressbar"
style="width: 0%"
aria-valuenow="0"
aria-valuemin="0"
aria-valuemax="100"
></div>
</div>
<div class="text-muted small mt-1" id="bulk-upload-progress-text">Uploading…</div>
</div>
<ul class="list-group mt-2" id="bulk-file-list"></ul>
</div>
</div>
<div class="modal-footer">
<div class="small text-danger me-auto" id="bulk-error" aria-live="polite"></div>
<button type="button" class="btn btn-outline-ink" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-brand" id="bulk-upload-submit" disabled>Upload</button>
</div>
</div>
</div>
</div>
{# Load Cropper.js BEFORE our inline script so window.Cropper is available #}
<script src="https://cdn.jsdelivr.net/npm/cropperjs@1.6.2/dist/cropper.min.js"></script>
@@ -912,9 +981,15 @@
cropStatus.textContent = 'Preparing cropped image…';
const isPortrait = cm === '9:16';
// Export at Full HD by default (or whatever the server config says).
// We still enforce a server-side max output size in _save_compressed_image.
const cfg = document.getElementById('page-config');
const TARGET_W = parseInt(cfg?.dataset?.imageCropTargetW || '1920', 10) || 1920;
const TARGET_H = parseInt(cfg?.dataset?.imageCropTargetH || '1080', 10) || 1080;
const canvas = cropper.getCroppedCanvas({
width: isPortrait ? 720 : 1280,
height: isPortrait ? 1280 : 720,
width: isPortrait ? TARGET_H : TARGET_W,
height: isPortrait ? TARGET_W : TARGET_H,
imageSmoothingQuality: 'high',
});
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
@@ -1397,6 +1472,372 @@
</div>
`;
}
// Expose for other scripts on this page (bulk upload appends cards).
window.__renderPlaylistCardInnerHtml = renderCardInnerHtml;
})();
(function() {
// -------------------------
// Bulk image upload (auto center-crop)
// -------------------------
const openBtn = document.getElementById('open-bulk-upload');
const modalEl = document.getElementById('bulkUploadModal');
const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
const dropzone = document.getElementById('bulk-dropzone');
const fileInput = document.getElementById('bulk-file-input');
const submitBtn = document.getElementById('bulk-upload-submit');
const statusEl = document.getElementById('bulk-status');
const errorEl = document.getElementById('bulk-error');
const listEl = document.getElementById('bulk-file-list');
const crop169 = document.getElementById('bulk-crop-16-9');
const crop916 = document.getElementById('bulk-crop-9-16');
const cfg = document.getElementById('page-config');
const TARGET_W = parseInt(cfg?.dataset?.imageCropTargetW || '1920', 10) || 1920;
const TARGET_H = parseInt(cfg?.dataset?.imageCropTargetH || '1080', 10) || 1080;
const uploadUrl = `{{ url_for('company.bulk_upload_playlist_images', playlist_id=playlist.id) }}`;
let selectedFiles = [];
let processedFiles = []; // cropped output
function setError(msg) {
if (errorEl) errorEl.textContent = (msg || '').trim();
}
function setStatus(msg) {
if (statusEl) statusEl.textContent = (msg || '').trim();
}
function currentCropMode() {
return crop916?.checked ? '9:16' : '16:9';
}
function clearList() {
if (listEl) listEl.innerHTML = '';
}
function addListItem(name, initialText) {
if (!listEl) return null;
const li = document.createElement('li');
li.className = 'list-group-item d-flex justify-content-between align-items-center';
li.innerHTML = `
<div class="text-truncate" style="max-width: 70%;">${escapeHtml(name)}</div>
<div class="small text-muted">${escapeHtml(initialText || '')}</div>
`;
listEl.appendChild(li);
return li;
}
function setListItemStatus(li, txt, kind) {
if (!li) return;
const right = li.querySelector('div.small');
if (!right) return;
right.textContent = txt || '';
right.classList.toggle('text-danger', kind === 'err');
right.classList.toggle('text-success', kind === 'ok');
right.classList.toggle('text-muted', !kind);
}
function escapeHtml(s) {
return String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
async function loadImageFromFile(file) {
return await new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
resolve(img);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load image'));
};
img.src = url;
});
}
async function centerCropToAspect(file, cropMode) {
// cropMode is "16:9" or "9:16"
const img = await loadImageFromFile(file);
const srcW = img.naturalWidth || img.width;
const srcH = img.naturalHeight || img.height;
if (!srcW || !srcH) throw new Error('Invalid image');
const targetAspect = (cropMode === '9:16') ? (9 / 16) : (16 / 9);
const srcAspect = srcW / srcH;
// Compute center crop rect in source pixels
let cropW = srcW;
let cropH = srcH;
if (srcAspect > targetAspect) {
// too wide -> crop width
cropW = Math.max(1, Math.round(srcH * targetAspect));
cropH = srcH;
} else {
// too tall -> crop height
cropW = srcW;
cropH = Math.max(1, Math.round(srcW / targetAspect));
}
const cropX = Math.max(0, Math.round((srcW - cropW) / 2));
const cropY = Math.max(0, Math.round((srcH - cropH) / 2));
const outW = (cropMode === '9:16') ? TARGET_H : TARGET_W;
const outH = (cropMode === '9:16') ? TARGET_W : TARGET_H;
const canvas = document.createElement('canvas');
canvas.width = outW;
canvas.height = outH;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas not supported');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img, cropX, cropY, cropW, cropH, 0, 0, outW, outH);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
if (!blob) throw new Error('Failed to encode cropped image');
const base = (file.name || 'image').replace(/\.[^/.]+$/, '');
const outName = `${base}_${cropMode.replace(':','x')}.png`;
return new File([blob], outName, { type: 'image/png' });
}
async function processSelection(files) {
setError('');
processedFiles = [];
clearList();
submitBtn.disabled = true;
selectedFiles = (files || []).filter(f => f && f.type && f.type.startsWith('image/'));
if (!selectedFiles.length) {
setStatus('');
setError('Please select one or more image files.');
return;
}
const mode = currentCropMode();
setStatus(`Preparing ${selectedFiles.length} image(s)…`);
// Process sequentially to keep memory use stable.
for (let i = 0; i < selectedFiles.length; i++) {
const f = selectedFiles[i];
const li = addListItem(f.name, 'Cropping…');
try {
const cropped = await centerCropToAspect(f, mode);
processedFiles.push(cropped);
setListItemStatus(li, 'Ready', 'ok');
} catch (e) {
console.warn('Failed to crop', f.name, e);
setListItemStatus(li, 'Failed to crop', 'err');
}
}
if (!processedFiles.length) {
setStatus('');
setError('No images could be processed.');
return;
}
setStatus(`Ready to upload ${processedFiles.length} image(s).`);
submitBtn.disabled = false;
}
async function upload() {
setError('');
if (!processedFiles.length) {
setError('Please add some images first.');
return;
}
submitBtn.disabled = true;
dropzone?.classList.add('disabled');
const progressWrap = document.getElementById('bulk-upload-progress');
const progressBar = document.getElementById('bulk-upload-progress-bar');
const progressText = document.getElementById('bulk-upload-progress-text');
function setProgressVisible(visible) {
progressWrap?.classList.toggle('d-none', !visible);
}
function setProgress(pct, text) {
const p = Math.max(0, Math.min(100, Math.round(Number(pct) || 0)));
if (progressBar) {
progressBar.style.width = `${p}%`;
progressBar.setAttribute('aria-valuenow', String(p));
}
if (progressText) progressText.textContent = text || `${p}%`;
}
setProgressVisible(true);
setProgress(0, 'Uploading…');
const mode = currentCropMode();
setStatus(`Uploading ${processedFiles.length} image(s)…`);
const fd = new FormData();
fd.set('crop_mode', mode);
fd.set('duration_seconds', '10');
for (const f of processedFiles) fd.append('files', f);
// Use XHR so we can track upload progress.
const xhr = new XMLHttpRequest();
const xhrPromise = new Promise((resolve) => {
xhr.onreadystatechange = () => {
if (xhr.readyState !== 4) return;
resolve();
};
});
xhr.open('POST', uploadUrl, true);
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.setRequestHeader('Accept', 'application/json');
xhr.upload.onprogress = (e) => {
if (!e || !e.lengthComputable) {
setProgress(0, 'Uploading…');
return;
}
const pct = (e.total > 0) ? ((e.loaded / e.total) * 100) : 0;
setProgress(pct, `Uploading… ${Math.round(pct)}%`);
};
xhr.onerror = () => {
// Network error
setError('Upload failed (network error).');
};
xhr.send(fd);
await xhrPromise;
const status = xhr.status;
const text = xhr.responseText || '';
let data = null;
try { data = JSON.parse(text); } catch (e) {}
const resOk = (status >= 200 && status < 300);
if (!resOk) {
let msg = (data && data.error) ? data.error : `Upload failed (HTTP ${status}).`;
setError(msg);
submitBtn.disabled = false;
dropzone?.classList.remove('disabled');
setStatus('');
setProgressVisible(false);
return;
}
if (!data || !data.ok) {
setError((data && data.error) ? data.error : 'Upload failed.');
submitBtn.disabled = false;
dropzone?.classList.remove('disabled');
setStatus('');
setProgressVisible(false);
return;
}
setProgress(100, 'Processing…');
// Append cards in returned order.
const list = document.getElementById('playlist-items');
const render = window.__renderPlaylistCardInnerHtml;
if (list && Array.isArray(data.items) && render) {
for (const item of data.items) {
const el = document.createElement('div');
el.className = 'playlist-card';
el.setAttribute('draggable', 'true');
el.setAttribute('data-item-id', item.id);
el.innerHTML = render(item);
list.appendChild(el);
}
}
setStatus(`Uploaded ${data.items?.length || processedFiles.length} image(s).`);
// Reset & close.
selectedFiles = [];
processedFiles = [];
clearList();
submitBtn.disabled = true;
window.setTimeout(() => {
modal?.hide();
setStatus('');
setError('');
}, 500);
dropzone?.classList.remove('disabled');
setProgressVisible(false);
}
function resetModal() {
setError('');
setStatus('');
selectedFiles = [];
processedFiles = [];
clearList();
submitBtn.disabled = true;
try { if (fileInput) fileInput.value = ''; } catch (e) {}
// Reset progress UI
try {
document.getElementById('bulk-upload-progress')?.classList.add('d-none');
const bar = document.getElementById('bulk-upload-progress-bar');
if (bar) {
bar.style.width = '0%';
bar.setAttribute('aria-valuenow', '0');
}
const txt = document.getElementById('bulk-upload-progress-text');
if (txt) txt.textContent = 'Uploading…';
} catch (e) {}
// default to landscape
if (crop169) crop169.checked = true;
if (crop916) crop916.checked = false;
}
openBtn?.addEventListener('click', () => {
resetModal();
modal?.show();
});
dropzone?.addEventListener('click', () => fileInput?.click());
dropzone?.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('dragover'); });
dropzone?.addEventListener('dragleave', () => dropzone.classList.remove('dragover'));
dropzone?.addEventListener('drop', async (e) => {
e.preventDefault();
dropzone.classList.remove('dragover');
const files = Array.from(e.dataTransfer?.files || []);
await processSelection(files);
});
fileInput?.addEventListener('change', async () => {
const files = Array.from(fileInput.files || []);
await processSelection(files);
});
// If user switches aspect ratio after selecting files, re-process automatically.
[crop169, crop916].forEach((el) => {
el?.addEventListener('change', async () => {
if (selectedFiles.length) await processSelection(selectedFiles);
});
});
submitBtn?.addEventListener('click', () => {
upload().catch((err) => {
console.warn('Bulk upload failed', err);
setError('Bulk upload failed.');
submitBtn.disabled = false;
setStatus('');
});
});
})();
(function() {

View File

@@ -8,6 +8,12 @@
html, body { height: 100%; width: 100%; margin: 0; background: #000; overflow: hidden; }
#stage { position: fixed; inset: 0; width: 100vw; height: 100vh; background: #000; }
/* When ticker is shown, keep content from being visually covered.
(We pad the stage; video/img/iframe inside will keep aspect.) */
body.has-ticker #stage {
bottom: var(--ticker-height, 54px);
}
/* Optional company overlay (transparent PNG) */
#overlay {
position: fixed;
@@ -98,6 +104,41 @@
margin: 0;
opacity: 0.95;
}
/* Ticker tape */
#ticker {
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: var(--ticker-height, 54px);
background: rgba(0, 0, 0, 0.75); /* overridden by JS via style */
display: none;
align-items: center;
overflow: hidden;
z-index: 6; /* above stage, below notice */
pointer-events: none;
}
#ticker .track {
display: inline-flex;
align-items: center;
white-space: nowrap;
will-change: transform;
animation: ticker-scroll linear infinite;
animation-duration: var(--ticker-duration, 60s);
transform: translateX(0);
}
#ticker .item {
padding: 0 26px;
}
#ticker .sep {
opacity: 0.65;
}
@keyframes ticker-scroll {
/* We duplicate the content twice, so shifting -50% effectively loops. */
0% { transform: translateX(0); }
100% { transform: translateX(calc(-1 * var(--ticker-shift, 50%))); }
}
img, video, iframe { width: 100%; height: 100%; object-fit: contain; border: 0; }
/* removed bottom-left status text */
</style>
@@ -110,6 +151,9 @@
</div>
</div>
<div id="stage"></div>
<div id="ticker" aria-hidden="true">
<div class="track" id="tickerTrack"></div>
</div>
{% if overlay_url %}
<img id="overlay" src="{{ overlay_url }}" alt="Overlay" />
{% endif %}
@@ -152,6 +196,21 @@
let idx = 0;
let timer = null;
// Ticker DOM
const tickerEl = document.getElementById('ticker');
const tickerTrackEl = document.getElementById('tickerTrack');
let tickerConfig = null;
let tickerInterval = null;
let tickerLastHeadlines = [];
function getTickerPollSeconds() {
// Refresh headlines on a long interval.
// Default: 12 hours (twice per day).
// Override via ?ticker_poll=seconds.
const tp = parseInt(new URLSearchParams(window.location.search).get('ticker_poll') || '', 10);
return Number.isFinite(tp) && tp > 0 ? tp : (12 * 60 * 60);
}
const ANIM_MS = 420;
function getTransitionMode(pl) {
@@ -174,6 +233,179 @@
return await res.json();
}
async function fetchTickerHeadlines() {
const qs = sid ? `?sid=${encodeURIComponent(sid)}` : '';
const res = await fetch(`/api/display/${token}/ticker${qs}`, { cache: 'no-store' });
if (res.status === 429) {
const data = await res.json().catch(() => null);
throw Object.assign(new Error(data?.message || 'Display limit reached'), { code: 'LIMIT', data });
}
return await res.json();
}
function safeCss(val) {
return (val || '').toString().replace(/[\n\r"']/g, ' ').trim();
}
function applyTickerStyle(cfg) {
if (!tickerEl) return;
const color = safeCss(cfg && cfg.color);
const bgColor = safeCss(cfg && cfg.bg_color);
const bgOpacityRaw = parseInt((cfg && cfg.bg_opacity) || '', 10);
const bgOpacity = Number.isFinite(bgOpacityRaw) ? Math.max(0, Math.min(100, bgOpacityRaw)) : 75;
const fontFamily = safeCss(cfg && cfg.font_family);
const sizePx = parseInt((cfg && cfg.font_size_px) || '', 10);
const fontSize = Number.isFinite(sizePx) ? Math.max(10, Math.min(200, sizePx)) : 28;
// Height is slightly larger than font size.
const height = Math.max(36, Math.min(120, fontSize + 26));
tickerEl.style.color = color || '#ffffff';
tickerEl.style.fontFamily = fontFamily || 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif';
tickerEl.style.fontSize = `${fontSize}px`;
tickerEl.style.setProperty('--ticker-height', `${height}px`);
// Background color + opacity
tickerEl.style.backgroundColor = toRgba(bgColor || '#000000', bgOpacity);
}
function toRgba(hexColor, opacityPercent) {
const s = (hexColor || '').toString().trim().toLowerCase();
const a = Math.max(0, Math.min(100, parseInt(opacityPercent || '0', 10))) / 100;
// Accept #rgb or #rrggbb. Fallback to black.
let r = 0, g = 0, b = 0;
if (s.startsWith('#')) {
const h = s.slice(1);
if (h.length === 3) {
r = parseInt(h[0] + h[0], 16);
g = parseInt(h[1] + h[1], 16);
b = parseInt(h[2] + h[2], 16);
} else if (h.length === 6) {
r = parseInt(h.slice(0,2), 16);
g = parseInt(h.slice(2,4), 16);
b = parseInt(h.slice(4,6), 16);
}
}
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
function computeTickerDurationPx(copyWidthPx) {
const w = Math.max(1, parseInt(copyWidthPx || '0', 10) || 0);
// Speed slider (1..100): higher => faster.
const rawSpeed = parseInt((tickerConfig && tickerConfig.speed) || '', 10);
const speed = Number.isFinite(rawSpeed) ? Math.max(1, Math.min(100, rawSpeed)) : 25;
// Map speed to pixels/second. (tuned to be readable on signage)
// speed=25 => ~38 px/s, speed=100 => ~128 px/s
const pxPerSecond = Math.max(8, Math.min(180, 8 + (speed * 1.2)));
const seconds = w / pxPerSecond;
return Math.max(12, Math.min(600, seconds));
}
function buildTickerCopyHtml(list) {
// No trailing separator at the end.
return list.map((t, i) => {
const sep = (i === list.length - 1) ? '' : '<span class="sep">•</span>';
return `<span class="item">${escapeHtml(t)}</span>${sep}`;
}).join('');
}
function setTickerHeadlines(headlines) {
if (!tickerEl || !tickerTrackEl) return;
const list = Array.isArray(headlines) ? headlines.map(x => (x || '').toString().trim()).filter(Boolean) : [];
if (!list.length) {
tickerEl.style.display = 'none';
tickerTrackEl.innerHTML = '';
document.body.classList.remove('has-ticker');
return;
}
tickerLastHeadlines = list.slice();
// Show first so measurements work.
tickerEl.style.display = 'flex';
document.body.classList.add('has-ticker');
// Build one copy.
const oneCopyHtml = buildTickerCopyHtml(list);
tickerTrackEl.innerHTML = oneCopyHtml;
// Ensure we repeat enough so there is never an empty gap, even when the
// total headline width is smaller than the viewport.
requestAnimationFrame(() => {
try {
const viewportW = tickerEl.clientWidth || 1;
const copyW = tickerTrackEl.scrollWidth || 1;
// Want at least 2x viewport width in total track content.
const repeats = Math.max(2, Math.ceil((viewportW * 2) / copyW) + 1);
tickerTrackEl.innerHTML = oneCopyHtml.repeat(repeats);
// Shift by exactly one copy width. In % of total track width that is 100/repeats.
const shiftPercent = 100 / repeats;
tickerEl.style.setProperty('--ticker-shift', `${shiftPercent}%`);
tickerEl.style.setProperty('--ticker-duration', `${computeTickerDurationPx(copyW)}s`);
} catch (e) {
// fallback: 2 copies
tickerTrackEl.innerHTML = oneCopyHtml + oneCopyHtml;
tickerEl.style.setProperty('--ticker-shift', '50%');
tickerEl.style.setProperty('--ticker-duration', `${computeTickerDurationPx(2000)}s`);
}
});
}
function escapeHtml(s) {
return (s || '').toString()
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
async function refreshTickerOnce() {
// Disabled or missing URL: hide and clear immediately.
if (!tickerConfig || !tickerConfig.enabled) {
setTickerHeadlines([]);
return;
}
if (!tickerConfig.rss_url || !String(tickerConfig.rss_url).trim()) {
setTickerHeadlines([]);
return;
}
try {
const data = await fetchTickerHeadlines();
if (!data || !data.enabled) {
setTickerHeadlines([]);
return;
}
setTickerHeadlines(data.headlines || []);
} catch (e) {
// Soft-fail: keep old headlines if any.
}
}
function rerenderTickerFromCache() {
if (!tickerLastHeadlines || !tickerLastHeadlines.length) return;
setTickerHeadlines(tickerLastHeadlines);
}
function startTickerPolling() {
if (tickerInterval) {
clearInterval(tickerInterval);
tickerInterval = null;
}
tickerInterval = setInterval(refreshTickerOnce, getTickerPollSeconds() * 1000);
}
function stopTickerPolling() {
if (tickerInterval) {
clearInterval(tickerInterval);
tickerInterval = null;
}
}
function clearStage() {
if (timer) { clearTimeout(timer); timer = null; }
stage.innerHTML = '';
@@ -320,6 +552,14 @@
idx = 0;
applyTransitionClass(getTransitionMode(playlist));
setOverlaySrc(playlist && playlist.overlay_src);
tickerConfig = (playlist && playlist.ticker) ? playlist.ticker : null;
applyTickerStyle(tickerConfig);
await refreshTickerOnce();
if (tickerConfig && tickerConfig.enabled) {
startTickerPolling();
} else {
stopTickerPolling();
}
next();
} catch (e) {
clearStage();
@@ -349,6 +589,38 @@
const newStr = JSON.stringify(newPlaylist);
playlist = newPlaylist;
setOverlaySrc(playlist && playlist.overlay_src);
// Apply ticker settings (and refresh if settings changed)
const newTickerCfg = (playlist && playlist.ticker) ? playlist.ticker : null;
const oldTickerStr = JSON.stringify(tickerConfig);
const newTickerStr = JSON.stringify(newTickerCfg);
const oldEnabled = !!(tickerConfig && tickerConfig.enabled);
const newEnabled = !!(newTickerCfg && newTickerCfg.enabled);
const oldRssUrl = (tickerConfig && tickerConfig.rss_url) ? String(tickerConfig.rss_url) : '';
const newRssUrl = (newTickerCfg && newTickerCfg.rss_url) ? String(newTickerCfg.rss_url) : '';
tickerConfig = newTickerCfg;
applyTickerStyle(tickerConfig);
if (oldTickerStr !== newTickerStr) {
// Ensure enable/disable toggles are applied immediately (no reload required).
if (oldEnabled !== newEnabled) {
if (!newEnabled) {
// Hide and stop polling.
tickerLastHeadlines = [];
stopTickerPolling();
setTickerHeadlines([]);
} else {
// Re-enable: fetch now and restart headline polling.
await refreshTickerOnce();
startTickerPolling();
}
} else if (oldRssUrl !== newRssUrl) {
// RSS URL changed: refetch now.
await refreshTickerOnce();
} else {
// Style/speed change only: rerender from cache to apply instantly.
if (newEnabled) rerenderTickerFromCache();
}
}
if (oldStr !== newStr) {
idx = 0;
applyTransitionClass(getTransitionMode(playlist));

136
scripts/release.py Normal file
View File

@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Release helper.
What it does (in order):
1) Ask/provide a commit message and version.
2) Commit & push to the `openslide` git remote.
3) Build + push Docker image tags:
- git.alphen.cloud/bramval/openslide:<version>
- git.alphen.cloud/bramval/openslide:latest
Usage examples:
python scripts/release.py --version 1.2.3 --message "Release 1.2.3"
python scripts/release.py # interactive prompts
Notes:
- Assumes you are already authenticated for git + the Docker registry (docker login).
"""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
from dataclasses import dataclass
from typing import Iterable, Sequence
DEFAULT_GIT_REMOTE = "openslide"
DEFAULT_IMAGE = "git.alphen.cloud/bramval/openslide"
@dataclass(frozen=True)
class ReleaseInfo:
version: str
message: str
_DOCKER_TAG_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
def _run(cmd: Sequence[str], *, dry_run: bool = False) -> None:
printable = " ".join(cmd)
print(f"> {printable}")
if dry_run:
return
subprocess.run(cmd, check=True)
def _capture(cmd: Sequence[str]) -> str:
return subprocess.check_output(cmd, text=True).strip()
def _require_tool(name: str) -> None:
try:
subprocess.run([name, "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
except FileNotFoundError as e:
raise SystemExit(f"Required tool not found in PATH: {name}") from e
def _validate_version(tag: str) -> str:
tag = tag.strip()
if not tag:
raise ValueError("Version may not be empty")
if not _DOCKER_TAG_RE.match(tag):
raise ValueError(
"Invalid docker tag for version. Use only letters, digits, '.', '_' or '-'. "
"(1..128 chars, must start with [A-Za-z0-9])"
)
return tag
def get_release_info(*, version: str | None, message: str | None) -> ReleaseInfo:
"""Collect commit message + version before doing the rest."""
if version is None:
version = input("Version tag (e.g. 1.2.3): ").strip()
version = _validate_version(version)
if message is None:
message = input(f"Commit message [Release {version}]: ").strip() or f"Release {version}"
return ReleaseInfo(version=version, message=message)
def git_commit_and_push(*, remote: str, message: str, dry_run: bool = False) -> None:
# Stage all changes
_run(["git", "add", "-A"], dry_run=dry_run)
# Only commit if there is something to commit
porcelain = _capture(["git", "status", "--porcelain"]) # empty => clean
if porcelain:
_run(["git", "commit", "-m", message], dry_run=dry_run)
else:
print("No working tree changes detected; skipping git commit.")
branch = _capture(["git", "rev-parse", "--abbrev-ref", "HEAD"])
_run(["git", "push", remote, branch], dry_run=dry_run)
def docker_build_and_push(*, image: str, version: str, dry_run: bool = False) -> None:
version_tag = f"{image}:{version}"
latest_tag = f"{image}:latest"
_run(["docker", "build", "-t", version_tag, "-t", latest_tag, "."], dry_run=dry_run)
_run(["docker", "push", version_tag], dry_run=dry_run)
_run(["docker", "push", latest_tag], dry_run=dry_run)
def main(argv: Iterable[str]) -> int:
parser = argparse.ArgumentParser(description="Commit + push + docker publish helper.")
parser.add_argument("--version", "-v", help="Docker version tag (e.g. 1.2.3)")
parser.add_argument("--message", "-m", help="Git commit message")
parser.add_argument("--remote", default=DEFAULT_GIT_REMOTE, help=f"Git remote to push to (default: {DEFAULT_GIT_REMOTE})")
parser.add_argument("--image", default=DEFAULT_IMAGE, help=f"Docker image name (default: {DEFAULT_IMAGE})")
parser.add_argument("--dry-run", action="store_true", help="Print commands without executing")
args = parser.parse_args(list(argv))
_require_tool("git")
_require_tool("docker")
info = get_release_info(version=args.version, message=args.message)
print(f"\nReleasing version: {info.version}")
print(f"Commit message: {info.message}")
print(f"Git remote: {args.remote}")
print(f"Docker image: {args.image}\n")
git_commit_and_push(remote=args.remote, message=info.message, dry_run=args.dry_run)
docker_build_and_push(image=args.image, version=info.version, dry_run=args.dry_run)
print("\nDone.")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))