Initial import

This commit is contained in:
2026-01-25 13:26:45 +01:00
parent a5fe0f73a0
commit f4b7fb62f5
8 changed files with 834 additions and 149 deletions

View File

@@ -15,6 +15,16 @@ MAX_ACTIVE_SESSIONS_PER_DISPLAY = 3
SESSION_TTL_SECONDS = 90
def _is_playlist_active_now(p: Playlist, now_utc: datetime) -> bool:
"""Return True if playlist is active based on its optional schedule window."""
if p.schedule_start and now_utc < p.schedule_start:
return False
if p.schedule_end and now_utc > p.schedule_end:
return False
return True
def _enforce_and_touch_display_session(display: Display, sid: str | None):
"""Enforce concurrent display viewer limit and touch last_seen.
@@ -103,6 +113,18 @@ def _playlist_signature(display: Display) -> tuple[int | None, str]:
raw = "no-playlist"
return None, hashlib.sha1(raw.encode("utf-8")).hexdigest()
# Apply scheduling + priority rule so a schedule change triggers a player refresh.
playlists = Playlist.query.filter(Playlist.id.in_(active_ids)).all()
now_utc = datetime.utcnow()
scheduled = [p for p in playlists if _is_playlist_active_now(p, now_utc)]
if any(p.is_priority for p in scheduled):
scheduled = [p for p in scheduled if p.is_priority]
active_ids = [x for x in active_ids if any(p.id == x for p in scheduled)]
if not active_ids:
raw = "no-active-playlist"
return None, hashlib.sha1(raw.encode("utf-8")).hexdigest()
# Pull items in a stable order so reordering affects signature.
if use_mapping:
items = (
@@ -174,11 +196,30 @@ def display_playlist(token: str):
use_mapping = False
if not active_ids:
return jsonify({"display": display.name, "playlists": [], "items": []})
return jsonify(
{
"display": display.name,
"transition": display.transition or "none",
"playlists": [],
"items": [],
}
)
playlists = Playlist.query.filter(Playlist.id.in_(active_ids)).all()
pl_by_id = {p.id: p for p in playlists}
ordered_playlists = [pl_by_id[x] for x in active_ids if x in pl_by_id]
# Filter playlists by schedule
now_utc = datetime.utcnow()
scheduled = [p for p in playlists if _is_playlist_active_now(p, now_utc)]
# Priority rule:
# If any active (scheduled) playlist is marked priority, only play priority playlists.
any_priority = any(p.is_priority for p in scheduled)
if any_priority:
scheduled = [p for p in scheduled if p.is_priority]
pl_by_id = {p.id: p for p in scheduled}
scheduled_ids = [x for x in active_ids if x in pl_by_id]
ordered_playlists = [pl_by_id[x] for x in scheduled_ids]
# Merge items across active playlists.
if use_mapping:
@@ -186,17 +227,21 @@ def display_playlist(token: str):
PlaylistItem.query.join(DisplayPlaylist, DisplayPlaylist.playlist_id == PlaylistItem.playlist_id)
.filter(
DisplayPlaylist.display_id == display.id,
PlaylistItem.playlist_id.in_(active_ids),
PlaylistItem.playlist_id.in_(scheduled_ids),
)
.order_by(DisplayPlaylist.position.asc(), PlaylistItem.position.asc())
.all()
)
else:
merged = (
PlaylistItem.query.filter(PlaylistItem.playlist_id == active_ids[0])
.order_by(PlaylistItem.position.asc())
.all()
)
# single-playlist fallback; apply schedule filter too.
if scheduled_ids:
merged = (
PlaylistItem.query.filter(PlaylistItem.playlist_id == scheduled_ids[0])
.order_by(PlaylistItem.position.asc())
.all()
)
else:
merged = []
items = []
for item in merged:
@@ -217,6 +262,7 @@ def display_playlist(token: str):
return jsonify(
{
"display": display.name,
"transition": display.transition or "none",
"playlists": [{"id": p.id, "name": p.name} for p in ordered_playlists],
"items": items,
}

View File

@@ -178,6 +178,39 @@ def _try_delete_upload(file_path: str | None, upload_root: str):
bp = Blueprint("company", __name__, url_prefix="/company")
def _parse_schedule_local_to_utc(*, date_str: str | None, time_str: str | None) -> datetime | None:
"""Parse local date+time form inputs into a naive UTC datetime.
Inputs come from <input type="date"> and <input type="time">.
We interpret them as *local* time of the server.
Note: this project currently does not store per-company timezone; in most deployments
server timezone matches users. If you need per-company timezone later, we can extend
this function.
"""
d = (date_str or "").strip()
t = (time_str or "").strip()
if not d and not t:
return None
if not d or not t:
# Require both parts for clarity
raise ValueError("Both date and time are required")
# Basic parsing: YYYY-MM-DD and HH:MM
try:
year, month, day = [int(x) for x in d.split("-")]
hh, mm = [int(x) for x in t.split(":")[:2]]
except Exception:
raise ValueError("Invalid date/time")
# Interpret as local time, convert to UTC naive
local_dt = datetime(year, month, day, hh, mm)
# local_dt.timestamp() uses local timezone when naive.
utc_ts = local_dt.timestamp()
return datetime.utcfromtimestamp(utc_ts)
def company_user_required():
if not current_user.is_authenticated:
abort(403)
@@ -347,6 +380,7 @@ def dashboard():
return render_template(
"company/dashboard.html",
playlists=playlists,
now_utc=datetime.utcnow(),
playlists_json=playlists_json,
displays=displays,
)
@@ -374,7 +408,7 @@ def playlist_detail(playlist_id: int):
playlist = db.session.get(Playlist, playlist_id)
if not playlist or playlist.company_id != current_user.company_id:
abort(404)
return render_template("company/playlist_detail.html", playlist=playlist)
return render_template("company/playlist_detail.html", playlist=playlist, now_utc=datetime.utcnow())
@bp.post("/playlists/<int:playlist_id>")
@@ -405,6 +439,89 @@ def update_playlist(playlist_id: int):
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
@bp.post("/playlists/<int:playlist_id>/schedule")
@login_required
def update_playlist_schedule(playlist_id: int):
"""Update playlist schedule window + priority flag."""
company_user_required()
playlist = db.session.get(Playlist, playlist_id)
if not playlist or playlist.company_id != current_user.company_id:
abort(404)
try:
start = _parse_schedule_local_to_utc(
date_str=request.form.get("schedule_start_date"),
time_str=request.form.get("schedule_start_time"),
)
end = _parse_schedule_local_to_utc(
date_str=request.form.get("schedule_end_date"),
time_str=request.form.get("schedule_end_time"),
)
except ValueError as e:
flash(str(e), "danger")
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
if start and end and end < start:
flash("End must be after start", "danger")
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
playlist.schedule_start = start
playlist.schedule_end = end
db.session.commit()
flash("Schedule updated", "success")
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
@bp.post("/playlists/<int:playlist_id>/schedule/delete")
@login_required
def clear_playlist_schedule(playlist_id: int):
"""Clear schedule for a playlist (sets start/end to NULL)."""
company_user_required()
playlist = db.session.get(Playlist, playlist_id)
if not playlist or playlist.company_id != current_user.company_id:
abort(404)
playlist.schedule_start = None
playlist.schedule_end = None
db.session.commit()
flash("Schedule removed", "success")
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
@bp.post("/playlists/<int:playlist_id>/priority")
@login_required
def update_playlist_priority(playlist_id: int):
"""Update playlist priority flag."""
company_user_required()
playlist = db.session.get(Playlist, playlist_id)
if not playlist or playlist.company_id != current_user.company_id:
abort(404)
wants_json = (
(request.headers.get("X-Requested-With") == "XMLHttpRequest")
or ("application/json" in (request.headers.get("Accept") or ""))
or request.is_json
)
# Accept both form and JSON payloads.
raw = request.form.get("is_priority")
if raw is None and request.is_json:
raw = (request.get_json(silent=True) or {}).get("is_priority")
playlist.is_priority = bool((raw or "").strip())
db.session.commit()
if wants_json:
return jsonify({"ok": True, "is_priority": bool(playlist.is_priority)})
flash("Priority updated", "success")
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
@bp.post("/playlists/<int:playlist_id>/delete")
@login_required
def delete_playlist(playlist_id: int):
@@ -850,6 +967,14 @@ def update_display(display_id: int):
def _json_error(message: str, status: int = 400):
return jsonify({"ok": False, "error": message}), status
def _normalize_transition(val: str | None) -> str | None:
v = (val or "").strip().lower()
if not v:
return None
if v not in {"none", "fade", "slide"}:
return None
return v
# Inputs from either form or JSON
payload = request.get_json(silent=True) if request.is_json else None
@@ -869,6 +994,16 @@ def update_display(display_id: int):
desc = desc[:200]
display.description = desc
# Slide transition
if request.is_json:
if payload is None:
return _json_error("Invalid JSON")
if "transition" in payload:
display.transition = _normalize_transition(payload.get("transition"))
else:
# Form POST implies full update
display.transition = _normalize_transition(request.form.get("transition"))
# Playlist assignment
if request.is_json:
if "playlist_id" in payload:
@@ -908,6 +1043,7 @@ def update_display(display_id: int):
"id": display.id,
"name": display.name,
"description": display.description,
"transition": display.transition,
"assigned_playlist_id": display.assigned_playlist_id,
},
}