225 lines
6.9 KiB
Python
225 lines
6.9 KiB
Python
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/<token>/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/<token>/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",
|
|
},
|
|
)
|