Add company dashboard improvements and upload/auth features
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
from datetime import datetime, timedelta
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
|
||||
from flask import Blueprint, abort, jsonify, request, url_for
|
||||
from flask import Blueprint, Response, abort, jsonify, request, stream_with_context, url_for
|
||||
|
||||
from ..extensions import db
|
||||
from ..models import Display, DisplaySession
|
||||
@@ -12,6 +15,95 @@ 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)
|
||||
DisplaySession.query.filter(
|
||||
DisplaySession.display_id == display.id,
|
||||
DisplaySession.last_seen_at < cutoff,
|
||||
).delete(synchronize_session=False)
|
||||
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()
|
||||
@@ -20,46 +112,10 @@ def display_playlist(token: str):
|
||||
|
||||
# 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") or "").strip()
|
||||
if sid:
|
||||
cutoff = datetime.utcnow() - timedelta(seconds=SESSION_TTL_SECONDS)
|
||||
DisplaySession.query.filter(
|
||||
DisplaySession.display_id == display.id,
|
||||
DisplaySession.last_seen_at < cutoff,
|
||||
).delete(synchronize_session=False)
|
||||
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()
|
||||
else:
|
||||
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 (
|
||||
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()
|
||||
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:
|
||||
@@ -86,3 +142,79 @@ def display_playlist(token: str):
|
||||
"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",
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user