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 Display, DisplaySession bp = Blueprint("api", __name__, url_prefix="/api") MAX_ACTIVE_SESSIONS_PER_DISPLAY = 2 SESSION_TTL_SECONDS = 90 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. """ playlist = display.assigned_playlist if not playlist: raw = "no-playlist" return None, hashlib.sha1(raw.encode("utf-8")).hexdigest() payload = { "playlist_id": playlist.id, "items": [ { "id": it.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 playlist.items ], } raw = json.dumps(payload, sort_keys=True, separators=(",", ":")) return playlist.id, 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) # Enforce: a display URL/token can be opened by max 2 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 playlist = display.assigned_playlist if not playlist: return jsonify({"display": display.name, "playlist": None, "items": []}) items = [] for item in playlist.items: payload = { "id": item.id, "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, "playlist": {"id": playlist.id, "name": playlist.name}, "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", }, )