from datetime import datetime, timedelta import hashlib import json import time from flask import Blueprint, Response, abort, jsonify, request, stream_with_context, url_for from ..extensions import db from ..models import Company, Display, DisplayPlaylist, DisplaySession, Playlist, PlaylistItem from ..uploads import is_valid_upload_relpath bp = Blueprint("api", __name__, url_prefix="/api") 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. Returns: (ok, response) - ok=True: caller may proceed - ok=False: response is a Flask response tuple to return """ sid = (sid or "").strip() if not sid: return True, None cutoff = datetime.utcnow() - timedelta(seconds=SESSION_TTL_SECONDS) # Cleanup old sessions. Avoid committing if nothing was deleted (saves write locks on SQLite). deleted = ( DisplaySession.query.filter( DisplaySession.display_id == display.id, DisplaySession.last_seen_at < cutoff, ).delete(synchronize_session=False) ) if deleted: db.session.commit() existing = DisplaySession.query.filter_by(display_id=display.id, sid=sid).first() if existing: existing.last_seen_at = datetime.utcnow() db.session.commit() return True, None active_count = ( DisplaySession.query.filter( DisplaySession.display_id == display.id, DisplaySession.last_seen_at >= cutoff, ).count() ) if active_count >= MAX_ACTIVE_SESSIONS_PER_DISPLAY: return ( False, ( jsonify( { "error": "display_limit_reached", "message": f"This display URL is already open on {MAX_ACTIVE_SESSIONS_PER_DISPLAY} displays.", } ), 429, ), ) s = DisplaySession( display_id=display.id, sid=sid, last_seen_at=datetime.utcnow(), ip=request.headers.get("X-Forwarded-For", request.remote_addr), user_agent=(request.headers.get("User-Agent") or "")[:300], ) db.session.add(s) db.session.commit() return True, None def _playlist_signature(display: Display) -> tuple[int | None, str]: """Compute a stable hash for what the player should be showing. We include enough information so that changing the assigned playlist, reordering, duration changes, and item adds/deletes trigger an update. """ # Determine active playlists. If display_playlist has any rows, use those. # Otherwise fall back to the legacy assigned_playlist_id. mapped_ids = [ r[0] for r in db.session.query(DisplayPlaylist.playlist_id) .filter(DisplayPlaylist.display_id == display.id) .order_by(DisplayPlaylist.position.asc(), DisplayPlaylist.playlist_id.asc()) .all() ] use_mapping = bool(mapped_ids) active_ids = mapped_ids if not active_ids and display.assigned_playlist_id: active_ids = [display.assigned_playlist_id] use_mapping = False if not active_ids: 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 = ( PlaylistItem.query.join(DisplayPlaylist, DisplayPlaylist.playlist_id == PlaylistItem.playlist_id) .filter( DisplayPlaylist.display_id == display.id, PlaylistItem.playlist_id.in_(active_ids), ) .order_by(DisplayPlaylist.position.asc(), PlaylistItem.position.asc()) .all() ) else: items = ( PlaylistItem.query.filter(PlaylistItem.playlist_id == active_ids[0]) .order_by(PlaylistItem.position.asc()) .all() ) payload = { "playlist_ids": list(active_ids), "items": [ { "id": it.id, "playlist_id": it.playlist_id, "pos": it.position, "type": it.item_type, "title": it.title, "duration": it.duration_seconds, "file_path": it.file_path, "url": it.url, } for it in items ], } raw = json.dumps(payload, sort_keys=True, separators=(",", ":")) # signature returns a single playlist_id previously; now return None when multiple. # callers only use it for changed-detection. if len(set(active_ids)) == 1: return active_ids[0], hashlib.sha1(raw.encode("utf-8")).hexdigest() return None, hashlib.sha1(raw.encode("utf-8")).hexdigest() @bp.get("/display//playlist") def display_playlist(token: str): display = Display.query.filter_by(token=token).first() 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") ok, resp = _enforce_and_touch_display_session(display, sid) if not ok: return resp # Determine active playlists. If display_playlist has any rows, use those. # Otherwise fall back to the legacy assigned_playlist_id. mapped_ids = [ r[0] for r in db.session.query(DisplayPlaylist.playlist_id) .filter(DisplayPlaylist.display_id == display.id) .order_by(DisplayPlaylist.position.asc(), DisplayPlaylist.playlist_id.asc()) .all() ] use_mapping = bool(mapped_ids) active_ids = mapped_ids if not active_ids and display.assigned_playlist_id: active_ids = [display.assigned_playlist_id] use_mapping = False if not active_ids: return jsonify( { "display": display.name, "transition": display.transition or "none", "overlay_src": overlay_src, "playlists": [], "items": [], } ) playlists = Playlist.query.filter(Playlist.id.in_(active_ids)).all() # 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: merged = ( PlaylistItem.query.join(DisplayPlaylist, DisplayPlaylist.playlist_id == PlaylistItem.playlist_id) .filter( DisplayPlaylist.display_id == display.id, PlaylistItem.playlist_id.in_(scheduled_ids), ) .order_by(DisplayPlaylist.position.asc(), PlaylistItem.position.asc()) .all() ) else: # 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: payload = { "id": item.id, "playlist_id": item.playlist_id, "playlist_name": (pl_by_id.get(item.playlist_id).name if pl_by_id.get(item.playlist_id) else None), "type": item.item_type, "title": item.title, "duration": item.duration_seconds, } if item.item_type in ("image", "video") and item.file_path: payload["src"] = url_for("static", filename=item.file_path) if item.item_type in ("webpage", "youtube"): payload["url"] = item.url items.append(payload) return jsonify( { "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, } ) @bp.get("/display//events") def display_events(token: str): """Server-Sent Events stream to notify the player when its playlist changes.""" display = Display.query.filter_by(token=token).first() if not display: abort(404) sid = request.args.get("sid") ok, resp = _enforce_and_touch_display_session(display, sid) if not ok: return resp display_id = display.id sid = (sid or "").strip() or None @stream_with_context def _gen(): last_hash = None last_touch = 0.0 keepalive_counter = 0 while True: try: # Refresh from DB each loop so changes become visible. d = Display.query.filter_by(id=display_id).first() if not d: yield "event: closed\ndata: {}\n\n" return playlist_id, h = _playlist_signature(d) if h != last_hash: last_hash = h payload = json.dumps({"playlist_id": playlist_id, "hash": h}) yield f"event: changed\ndata: {payload}\n\n" # Touch session periodically so SSE-only viewers don't time out. now = time.time() if sid and (now - last_touch) >= 30: last_touch = now existing = DisplaySession.query.filter_by(display_id=display_id, sid=sid).first() if existing: existing.last_seen_at = datetime.utcnow() db.session.commit() # Keep-alive comment (prevents some proxies from closing idle streams). keepalive_counter += 1 if keepalive_counter >= 10: # ~20s with the sleep below keepalive_counter = 0 yield ": keep-alive\n\n" # Release DB connections between iterations. db.session.remove() time.sleep(2) except GeneratorExit: return except Exception: # Avoid tight error loops. try: db.session.remove() except Exception: pass time.sleep(2) return Response( _gen(), mimetype="text/event-stream", headers={ "Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive", }, )