import os import uuid from urllib.parse import urlparse, parse_qs from flask import Blueprint, abort, current_app, flash, jsonify, redirect, render_template, request, url_for from flask_login import current_user, login_required from werkzeug.utils import secure_filename from PIL import Image from ..extensions import db from ..models import Display, Playlist, PlaylistItem ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff"} ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".webm", ".ogg", ".mov", ".m4v"} # Videos should have a maximum upload size of 250MB MAX_VIDEO_BYTES = 250 * 1024 * 1024 def _normalize_youtube_embed_url(raw: str) -> str | None: """Normalize a user-provided YouTube URL into a privacy-friendly embed base URL. Returns: https://www.youtube-nocookie.com/embed/ or None if we cannot parse a valid video id. """ val = (raw or "").strip() if not val: return None # Be forgiving for inputs like "youtu.be/". if not val.startswith("http://") and not val.startswith("https://"): val = "https://" + val try: u = urlparse(val) except Exception: return None host = (u.netloc or "").lower() host = host[4:] if host.startswith("www.") else host video_id: str | None = None path = (u.path or "").strip("/") if host in {"youtube.com", "m.youtube.com"}: # /watch?v= if path == "watch": v = (parse_qs(u.query).get("v") or [None])[0] video_id = v # /embed/ elif path.startswith("embed/"): video_id = path.split("/", 1)[1] # /shorts/ elif path.startswith("shorts/"): video_id = path.split("/", 1)[1] elif host == "youtu.be": # / if path: video_id = path.split("/", 1)[0] # Basic validation: YouTube IDs are typically 11 chars (letters/digits/_/-) if not video_id: return None video_id = video_id.strip() if len(video_id) != 11: return None for ch in video_id: if not (ch.isalnum() or ch in {"_", "-"}): return None return f"https://www.youtube-nocookie.com/embed/{video_id}" def _save_compressed_image(uploaded_file, upload_folder: str) -> str: """Save an uploaded image as a compressed WEBP file. Returns relative file path under /static (e.g. uploads/.webp) """ unique = f"{uuid.uuid4().hex}.webp" save_path = os.path.join(upload_folder, unique) img = Image.open(uploaded_file) # Normalize mode for webp if img.mode not in ("RGB", "RGBA"): img = img.convert("RGB") # Resize down if very large (keeps aspect ratio) img.thumbnail((1920, 1080)) img.save(save_path, format="WEBP", quality=80, method=6) return f"uploads/{unique}" def _try_delete_upload(file_path: str | None, upload_folder: str): """Best-effort delete of an uploaded media file.""" if not file_path: return if not file_path.startswith("uploads/"): return filename = file_path.split("/", 1)[1] abs_path = os.path.join(upload_folder, filename) try: if os.path.isfile(abs_path): os.remove(abs_path) except Exception: # Ignore cleanup failures pass bp = Blueprint("company", __name__, url_prefix="/company") def company_user_required(): if not current_user.is_authenticated: abort(403) if current_user.is_admin: abort(403) if not current_user.company_id: abort(403) @bp.get("/") @login_required 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) @bp.post("/playlists") @login_required def create_playlist(): company_user_required() name = request.form.get("name", "").strip() if not name: flash("Playlist name required", "danger") return redirect(url_for("company.dashboard")) p = Playlist(company_id=current_user.company_id, name=name) db.session.add(p) db.session.commit() flash("Playlist created", "success") return redirect(url_for("company.playlist_detail", playlist_id=p.id)) @bp.get("/playlists/") @login_required def playlist_detail(playlist_id: int): company_user_required() playlist = db.session.get(Playlist, playlist_id) if not playlist or playlist.company_id != current_user.company_id: abort(404) return render_template("company/playlist_detail.html", playlist=playlist) @bp.post("/playlists//delete") @login_required def delete_playlist(playlist_id: int): company_user_required() playlist = db.session.get(Playlist, playlist_id) if not playlist or playlist.company_id != current_user.company_id: abort(404) # Unassign from any displays in this company Display.query.filter_by(company_id=current_user.company_id, assigned_playlist_id=playlist.id).update( {"assigned_playlist_id": None} ) # cleanup uploaded files for image/video items for it in list(playlist.items): if it.item_type in ("image", "video"): _try_delete_upload(it.file_path, current_app.config["UPLOAD_FOLDER"]) db.session.delete(playlist) db.session.commit() flash("Playlist deleted", "success") return redirect(url_for("company.dashboard")) @bp.post("/playlists//items/reorder") @login_required def reorder_playlist_items(playlist_id: int): """Persist new ordering for playlist items. Expects form data: order=. """ company_user_required() playlist = db.session.get(Playlist, playlist_id) if not playlist or playlist.company_id != current_user.company_id: abort(404) # Accept both form and JSON payloads. order = (request.form.get("order") or "").strip() if not order and request.is_json: order = ((request.get_json(silent=True) or {}).get("order") or "").strip() if not order: abort(400) try: ids = [int(x) for x in order.split(",") if x.strip()] except ValueError: abort(400) # Ensure ids belong to this playlist existing = PlaylistItem.query.filter(PlaylistItem.playlist_id == playlist_id, PlaylistItem.id.in_(ids)).all() existing_ids = {i.id for i in existing} if len(existing_ids) != len(ids): abort(400) # Re-number positions starting at 1 id_to_item = {i.id: i for i in existing} for pos, item_id in enumerate(ids, start=1): id_to_item[item_id].position = pos db.session.commit() # Client currently doesn't require JSON, but returning JSON if requested # helps debugging and future enhancements. wants_json = ( (request.headers.get("X-Requested-With") == "XMLHttpRequest") or ("application/json" in (request.headers.get("Accept") or "")) or request.is_json ) if wants_json: return jsonify({"ok": True}) return ("", 204) @bp.post("/playlists//items") @login_required def add_playlist_item(playlist_id: int): company_user_required() playlist = db.session.get(Playlist, playlist_id) if not playlist or playlist.company_id != current_user.company_id: abort(404) # Support AJAX/modal usage: return JSON when requested. wants_json = ( (request.headers.get("X-Requested-With") == "XMLHttpRequest") or ("application/json" in (request.headers.get("Accept") or "")) or (request.form.get("response") == "json") ) def _json_error(message: str, status: int = 400): return jsonify({"ok": False, "error": message}), status item_type = (request.form.get("item_type") or "").strip().lower() title = request.form.get("title", "").strip() or None # Duration is only used for image/webpage. Video/YouTube plays until ended. raw_duration = request.form.get("duration_seconds") try: duration = int(raw_duration) if raw_duration is not None else 10 except (TypeError, ValueError): duration = 10 max_pos = ( db.session.query(db.func.max(PlaylistItem.position)).filter_by(playlist_id=playlist_id).scalar() or 0 ) pos = max_pos + 1 item = PlaylistItem( playlist=playlist, item_type=item_type, title=title, duration_seconds=max(1, duration), position=pos, ) if item_type in ("image", "video"): f = request.files.get("file") if not f or not f.filename: if wants_json: return _json_error("File required") flash("File required", "danger") return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) filename = secure_filename(f.filename) ext = os.path.splitext(filename)[1].lower() if item_type == "image": if ext not in ALLOWED_IMAGE_EXTENSIONS: if wants_json: return _json_error( "Unsupported image type. Please upload one of: " + ", ".join(sorted(ALLOWED_IMAGE_EXTENSIONS)) ) flash("Unsupported image type", "danger") return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) try: item.file_path = _save_compressed_image(f, current_app.config["UPLOAD_FOLDER"]) except Exception: if wants_json: return _json_error("Failed to process image upload", 500) flash("Failed to process image upload", "danger") return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) else: if ext not in ALLOWED_VIDEO_EXTENSIONS: if wants_json: return _json_error( "Unsupported video type. Please upload one of: " + ", ".join(sorted(ALLOWED_VIDEO_EXTENSIONS)) ) flash("Unsupported video type", "danger") return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) # Enforce video size limit (250MB) with a clear error message. # This is separate from Flask's MAX_CONTENT_LENGTH, which caps the full request. size = None try: size = getattr(f, "content_length", None) # Werkzeug may report 0 for unknown per-part length. if (size is None or size <= 0) and hasattr(f, "stream"): # Measure by seeking in the file-like stream. pos = f.stream.tell() f.stream.seek(0, os.SEEK_END) size = f.stream.tell() f.stream.seek(pos, os.SEEK_SET) except Exception: size = None if size is not None and size > MAX_VIDEO_BYTES: msg = "Video file too large. Maximum allowed size is 250MB." if wants_json: return _json_error(msg, 413) flash(msg, "danger") return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) # Keep as-is but always rename to a UUID. unique = uuid.uuid4().hex + ext save_path = os.path.join(current_app.config["UPLOAD_FOLDER"], unique) f.save(save_path) # Safety check: validate using the actual saved file size. # (Some clients/framework layers don't reliably report per-part size.) try: saved_size = os.path.getsize(save_path) except OSError: saved_size = None if saved_size is not None and saved_size > MAX_VIDEO_BYTES: try: os.remove(save_path) except OSError: pass msg = "Video file too large. Maximum allowed size is 250MB." if wants_json: return _json_error(msg, 413) flash(msg, "danger") return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) item.file_path = f"uploads/{unique}" elif item_type == "webpage": url = request.form.get("url", "").strip() if not url: if wants_json: return _json_error("URL required") flash("URL required", "danger") return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) item.url = url elif item_type == "youtube": raw = request.form.get("url", "").strip() if not raw: if wants_json: return _json_error("YouTube URL required") flash("YouTube URL required", "danger") return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) embed_url = _normalize_youtube_embed_url(raw) if not embed_url: if wants_json: return _json_error("Invalid YouTube URL") flash("Invalid YouTube URL", "danger") return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) item.url = embed_url else: if wants_json: return _json_error("Invalid item type") flash("Invalid item type", "danger") return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) db.session.add(item) db.session.commit() if wants_json: return jsonify( { "ok": True, "item": { "id": item.id, "playlist_id": item.playlist_id, "position": item.position, "item_type": item.item_type, "title": item.title, "file_path": item.file_path, "url": item.url, "duration_seconds": item.duration_seconds, }, } ) flash("Item added", "success") return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) @bp.post("/items//delete") @login_required def delete_item(item_id: int): company_user_required() item = db.session.get(PlaylistItem, item_id) if not item or item.playlist.company_id != current_user.company_id: abort(404) playlist_id = item.playlist_id if item.item_type in ("image", "video"): _try_delete_upload(item.file_path, current_app.config["UPLOAD_FOLDER"]) db.session.delete(item) db.session.commit() flash("Item deleted", "success") return redirect(url_for("company.playlist_detail", playlist_id=playlist_id)) @bp.post("/items//duration") @login_required def update_item_duration(item_id: int): """Update duration_seconds for a playlist item. Used from the playlist overview (inline edit). """ company_user_required() item = db.session.get(PlaylistItem, item_id) if not item or item.playlist.company_id != current_user.company_id: abort(404) # Duration only applies to images/webpages; videos play until ended. if item.item_type == "video": return jsonify({"ok": False, "error": "Duration cannot be set for video items"}), 400 wants_json = ( (request.headers.get("X-Requested-With") == "XMLHttpRequest") or ("application/json" in (request.headers.get("Accept") or "")) or request.is_json ) def _json_error(message: str, status: int = 400): return jsonify({"ok": False, "error": message}), status raw = request.form.get("duration_seconds") if raw is None and request.is_json: raw = (request.get_json(silent=True) or {}).get("duration_seconds") try: duration = int(raw) except (TypeError, ValueError): if wants_json: return _json_error("Invalid duration") abort(400) item.duration_seconds = max(1, duration) db.session.commit() if wants_json: return jsonify({"ok": True, "duration_seconds": item.duration_seconds}) return ("", 204) @bp.post("/displays//assign") @login_required def assign_playlist(display_id: int): company_user_required() display = db.session.get(Display, display_id) if not display or display.company_id != current_user.company_id: abort(404) playlist_id = request.form.get("playlist_id") if not playlist_id: display.assigned_playlist_id = None else: playlist = db.session.get(Playlist, int(playlist_id)) if not playlist or playlist.company_id != current_user.company_id: abort(400) display.assigned_playlist_id = playlist.id db.session.commit() flash("Display assignment updated", "success") return redirect(url_for("company.dashboard")) @bp.post("/displays/") @login_required def update_display(display_id: int): """Update display metadata (description + assigned playlist). Company users should be able to set a short description per display and assign a playlist. """ company_user_required() display = db.session.get(Display, display_id) if not display or display.company_id != current_user.company_id: abort(404) # Description (short, optional) desc = (request.form.get("description") or "").strip() or None if desc is not None: desc = desc[:200] display.description = desc # Playlist assignment playlist_id = (request.form.get("playlist_id") or "").strip() if not playlist_id: display.assigned_playlist_id = None else: try: playlist_id_int = int(playlist_id) except ValueError: abort(400) playlist = db.session.get(Playlist, playlist_id_int) if not playlist or playlist.company_id != current_user.company_id: abort(400) display.assigned_playlist_id = playlist.id db.session.commit() flash("Display updated", "success") return redirect(url_for("company.dashboard"))