Files
openslide/app/routes/api.py
2026-01-25 13:26:45 +01:00

346 lines
12 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, DisplayPlaylist, DisplaySession, Playlist, PlaylistItem
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/<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 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",
"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",
"playlists": [{"id": p.id, "name": p.name} for p in ordered_playlists],
"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",
},
)