533 lines
18 KiB
Python
533 lines
18 KiB
Python
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/<VIDEO_ID>
|
|
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/<id>".
|
|
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=<id>
|
|
if path == "watch":
|
|
v = (parse_qs(u.query).get("v") or [None])[0]
|
|
video_id = v
|
|
# /embed/<id>
|
|
elif path.startswith("embed/"):
|
|
video_id = path.split("/", 1)[1]
|
|
# /shorts/<id>
|
|
elif path.startswith("shorts/"):
|
|
video_id = path.split("/", 1)[1]
|
|
elif host == "youtu.be":
|
|
# /<id>
|
|
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/<uuid>.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/<int:playlist_id>")
|
|
@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/<int:playlist_id>/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/<int:playlist_id>/items/reorder")
|
|
@login_required
|
|
def reorder_playlist_items(playlist_id: int):
|
|
"""Persist new ordering for playlist items.
|
|
|
|
Expects form data: order=<comma-separated item ids>.
|
|
"""
|
|
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/<int:playlist_id>/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/<int:item_id>/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/<int:item_id>/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/<int:display_id>/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/<int:display_id>")
|
|
@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"))
|