diff --git a/app/__init__.py b/app/__init__.py index d155248..7d507cb 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -77,6 +77,57 @@ def create_app(): if settings_cols and "public_domain" not in settings_cols: db.session.execute(db.text("ALTER TABLE app_settings ADD COLUMN public_domain VARCHAR(255)")) db.session.commit() + + # DisplayPlaylist: create association table for multi-playlist displays. + dp_cols = [ + r[1] for r in db.session.execute(db.text("PRAGMA table_info(display_playlist)")).fetchall() + ] + if not dp_cols: + # Create association table for multi-playlist displays. + # Keep schema compatible with older DBs that include an autoincrement id and position. + db.session.execute( + db.text( + """ + CREATE TABLE IF NOT EXISTS display_playlist ( + id INTEGER PRIMARY KEY, + display_id INTEGER NOT NULL, + playlist_id INTEGER NOT NULL, + position INTEGER NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL, + UNIQUE(display_id, playlist_id), + FOREIGN KEY(display_id) REFERENCES display (id), + FOREIGN KEY(playlist_id) REFERENCES playlist (id) + ) + """ + ) + ) + db.session.commit() + else: + # Best-effort column additions for older/newer variants. + if "position" not in dp_cols: + db.session.execute( + db.text("ALTER TABLE display_playlist ADD COLUMN position INTEGER NOT NULL DEFAULT 1") + ) + db.session.commit() + if "created_at" not in dp_cols: + # Use CURRENT_TIMESTAMP as a reasonable default for existing rows. + db.session.execute( + db.text( + "ALTER TABLE display_playlist ADD COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" + ) + ) + db.session.commit() + if "id" not in dp_cols: + # Cannot add PRIMARY KEY via ALTER TABLE; keep nullable for compatibility. + db.session.execute(db.text("ALTER TABLE display_playlist ADD COLUMN id INTEGER")) + db.session.commit() + # Ensure uniqueness index exists (no-op if already present) + db.session.execute( + db.text( + "CREATE UNIQUE INDEX IF NOT EXISTS uq_display_playlist_display_playlist ON display_playlist (display_id, playlist_id)" + ) + ) + db.session.commit() except Exception: db.session.rollback() diff --git a/app/models.py b/app/models.py index 851d46c..caf7fe1 100644 --- a/app/models.py +++ b/app/models.py @@ -93,6 +93,21 @@ class Display(db.Model): assigned_playlist_id = db.Column(db.Integer, db.ForeignKey("playlist.id"), nullable=True) assigned_playlist = db.relationship("Playlist") + # Multi-playlist support (active playlists per display). + # If a display has any rows in display_playlist, those are used by the player. + # If not, we fall back to assigned_playlist_id for backwards compatibility. + display_playlists = db.relationship( + "DisplayPlaylist", + back_populates="display", + cascade="all, delete-orphan", + ) + playlists = db.relationship( + "Playlist", + secondary="display_playlist", + viewonly=True, + order_by="Playlist.name.asc()", + ) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) company = db.relationship("Company", back_populates="displays") @@ -117,6 +132,32 @@ class DisplaySession(db.Model): __table_args__ = (db.UniqueConstraint("display_id", "sid", name="uq_display_session_display_sid"),) +class DisplayPlaylist(db.Model): + """Association table: which playlists are active on a display.""" + + # NOTE: Some existing databases include an `id` INTEGER PRIMARY KEY column and a + # NOT NULL `position` column on display_playlist. We keep the mapper primary key as + # (display_id, playlist_id) for portability, while allowing an optional `id` column + # to exist in the underlying table. + id = db.Column(db.Integer, nullable=True) + + # Composite mapper PK ensures uniqueness per display. + display_id = db.Column(db.Integer, db.ForeignKey("display.id"), primary_key=True) + playlist_id = db.Column(db.Integer, db.ForeignKey("playlist.id"), primary_key=True) + + # Ordering of playlists within a display. + position = db.Column(db.Integer, default=1, nullable=False) + + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + display = db.relationship("Display", back_populates="display_playlists") + playlist = db.relationship("Playlist") + + __table_args__ = ( + db.UniqueConstraint("display_id", "playlist_id", name="uq_display_playlist_display_playlist"), + ) + + class AppSettings(db.Model): """Singleton-ish app-wide settings. diff --git a/app/routes/admin.py b/app/routes/admin.py index 409dc12..4954700 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -7,7 +7,7 @@ from flask_login import current_user, login_required, login_user from ..extensions import db from ..uploads import abs_upload_path, ensure_company_upload_dir, get_company_upload_bytes, is_valid_upload_relpath -from ..models import AppSettings, Company, Display, DisplaySession, Playlist, PlaylistItem, User +from ..models import AppSettings, Company, Display, DisplayPlaylist, DisplaySession, Playlist, PlaylistItem, User from ..email_utils import send_email bp = Blueprint("admin", __name__, url_prefix="/admin") @@ -375,8 +375,12 @@ def delete_company(company_id: int): for d in list(company.displays): d.assigned_playlist_id = None - # 2) Delete display sessions referencing displays of this company + # 1b) Clear multi-playlist mappings display_ids = [d.id for d in company.displays] + if display_ids: + DisplayPlaylist.query.filter(DisplayPlaylist.display_id.in_(display_ids)).delete(synchronize_session=False) + + # 2) Delete display sessions referencing displays of this company if display_ids: DisplaySession.query.filter(DisplaySession.display_id.in_(display_ids)).delete(synchronize_session=False) @@ -518,10 +522,13 @@ def delete_display(display_id: int): # 1) Unassign playlist display.assigned_playlist_id = None - # 2) Delete active sessions for this display + # 2) Clear multi-playlist mappings + DisplayPlaylist.query.filter_by(display_id=display.id).delete(synchronize_session=False) + + # 3) Delete active sessions for this display DisplaySession.query.filter_by(display_id=display.id).delete(synchronize_session=False) - # 3) Delete display + # 4) Delete display db.session.delete(display) db.session.commit() diff --git a/app/routes/api.py b/app/routes/api.py index 97ff44c..2394cc7 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -6,12 +6,12 @@ import time from flask import Blueprint, Response, abort, jsonify, request, stream_with_context, url_for from ..extensions import db -from ..models import Display, DisplaySession +from ..models import Display, DisplayPlaylist, DisplaySession, Playlist, PlaylistItem bp = Blueprint("api", __name__, url_prefix="/api") -MAX_ACTIVE_SESSIONS_PER_DISPLAY = 2 +MAX_ACTIVE_SESSIONS_PER_DISPLAY = 3 SESSION_TTL_SECONDS = 90 @@ -84,16 +84,49 @@ def _playlist_signature(display: Display) -> tuple[int | None, str]: duration changes, and item adds/deletes trigger an update. """ - playlist = display.assigned_playlist - if not playlist: + # 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() + # 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_id": playlist.id, + "playlist_ids": list(active_ids), "items": [ { "id": it.id, + "playlist_id": it.playlist_id, "pos": it.position, "type": it.item_type, "title": it.title, @@ -101,11 +134,15 @@ def _playlist_signature(display: Display) -> tuple[int | None, str]: "file_path": it.file_path, "url": it.url, } - for it in playlist.items + for it in items ], } raw = json.dumps(payload, sort_keys=True, separators=(",", ":")) - return playlist.id, hashlib.sha1(raw.encode("utf-8")).hexdigest() + # 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") @@ -114,21 +151,59 @@ def display_playlist(token: str): if not display: abort(404) - # Enforce: a display URL/token can be opened by max 2 concurrently active sessions. + # 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 - playlist = display.assigned_playlist - if not playlist: - return jsonify({"display": display.name, "playlist": None, "items": []}) + # 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, "playlists": [], "items": []}) + + playlists = Playlist.query.filter(Playlist.id.in_(active_ids)).all() + pl_by_id = {p.id: p for p in playlists} + ordered_playlists = [pl_by_id[x] for x in active_ids if x in pl_by_id] + + # 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_(active_ids), + ) + .order_by(DisplayPlaylist.position.asc(), PlaylistItem.position.asc()) + .all() + ) + else: + merged = ( + PlaylistItem.query.filter(PlaylistItem.playlist_id == active_ids[0]) + .order_by(PlaylistItem.position.asc()) + .all() + ) items = [] - for item in playlist.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, @@ -142,7 +217,7 @@ def display_playlist(token: str): return jsonify( { "display": display.name, - "playlist": {"id": playlist.id, "name": playlist.name}, + "playlists": [{"id": p.id, "name": p.name} for p in ordered_playlists], "items": items, } ) diff --git a/app/routes/company.py b/app/routes/company.py index 220e849..cc50e99 100644 --- a/app/routes/company.py +++ b/app/routes/company.py @@ -18,7 +18,7 @@ from ..uploads import ( get_company_upload_bytes, is_valid_upload_relpath, ) -from ..models import AppSettings, Company, Display, DisplaySession, Playlist, PlaylistItem, User +from ..models import AppSettings, Company, Display, DisplayPlaylist, DisplaySession, Playlist, PlaylistItem, User from ..email_utils import send_email from ..auth_tokens import make_password_reset_token @@ -343,7 +343,13 @@ def dashboard(): company_user_required() playlists = Playlist.query.filter_by(company_id=current_user.company_id).order_by(Playlist.name.asc()).all() displays = Display.query.filter_by(company_id=current_user.company_id).order_by(Display.name.asc()).all() - return render_template("company/dashboard.html", playlists=playlists, displays=displays) + playlists_json = [{"id": p.id, "name": p.name} for p in playlists] + return render_template( + "company/dashboard.html", + playlists=playlists, + playlists_json=playlists_json, + displays=displays, + ) @bp.post("/playlists") @@ -412,6 +418,15 @@ def delete_playlist(playlist_id: int): {"assigned_playlist_id": None} ) + # Remove from any display multi-playlist mappings in this company. + # Use a subquery to avoid a JOIN-based DELETE which is not supported on SQLite. + display_ids = [d.id for d in Display.query.filter_by(company_id=current_user.company_id).all()] + if display_ids: + DisplayPlaylist.query.filter( + DisplayPlaylist.display_id.in_(display_ids), + DisplayPlaylist.playlist_id == playlist.id, + ).delete(synchronize_session=False) + # cleanup uploaded files for image/video items for it in list(playlist.items): if it.item_type in ("image", "video"): @@ -900,3 +915,87 @@ def update_display(display_id: int): flash("Display updated", "success") return redirect(url_for("company.dashboard")) + + +@bp.post("/displays//playlists") +@login_required +def update_display_playlists(display_id: int): + """Set active playlists for a display. + + Expects JSON: { playlist_ids: [1,2,3] } + Returns JSON with the updated assigned playlist ids. + + Note: if playlist_ids is empty, the display will have no active playlists. + For backwards compatibility, this does NOT modify Display.assigned_playlist_id. + """ + + company_user_required() + + display = db.session.get(Display, display_id) + if not display or display.company_id != current_user.company_id: + abort(404) + + if not request.is_json: + abort(400) + + payload = request.get_json(silent=True) or {} + raw_ids = payload.get("playlist_ids") + if raw_ids is None: + return jsonify({"ok": False, "error": "playlist_ids is required"}), 400 + + if not isinstance(raw_ids, list): + return jsonify({"ok": False, "error": "playlist_ids must be a list"}), 400 + + playlist_ids: list[int] = [] + try: + for x in raw_ids: + if x in (None, ""): + continue + playlist_ids.append(int(x)) + except (TypeError, ValueError): + return jsonify({"ok": False, "error": "Invalid playlist id"}), 400 + + # Ensure playlists belong to this company. + if playlist_ids: + allowed = { + p.id + for p in Playlist.query.filter( + Playlist.company_id == current_user.company_id, + Playlist.id.in_(playlist_ids), + ).all() + } + if len(allowed) != len(set(playlist_ids)): + return jsonify({"ok": False, "error": "One or more playlists are invalid"}), 400 + + # Replace mapping rows. + DisplayPlaylist.query.filter_by(display_id=display.id).delete(synchronize_session=False) + now = datetime.utcnow() + for pos, pid in enumerate(dict.fromkeys(playlist_ids), start=1): + db.session.add( + DisplayPlaylist( + display_id=display.id, + playlist_id=pid, + position=pos, + created_at=now, + ) + ) + + db.session.commit() + + active_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() + ] + + return jsonify( + { + "ok": True, + "display": { + "id": display.id, + "active_playlist_ids": active_ids, + }, + } + ) diff --git a/app/templates/company/dashboard.html b/app/templates/company/dashboard.html index bfeec10..51213c3 100644 --- a/app/templates/company/dashboard.html +++ b/app/templates/company/dashboard.html @@ -72,16 +72,22 @@
- + {# Multi-playlist selector: button opens modal with playlist checkboxes #} +
+ +
+ +
+
+ + + + + {# Embed playlists list as JSON to avoid templating inside JS (keeps JS linters happy). #} + {% endblock %} {% block page_scripts %} {% endblock %} diff --git a/app/templates/company/playlist_detail.html b/app/templates/company/playlist_detail.html index 408ae68..a9d623f 100644 --- a/app/templates/company/playlist_detail.html +++ b/app/templates/company/playlist_detail.html @@ -369,6 +369,7 @@