Release 1.7

This commit is contained in:
2026-01-27 16:16:23 +01:00
parent 0c2720618a
commit 5221f9f670
3 changed files with 578 additions and 1 deletions

View File

@@ -1114,6 +1114,149 @@ def add_playlist_item(playlist_id: int):
return redirect(url_for("company.playlist_detail", playlist_id=playlist_id))
@bp.post("/playlists/<int:playlist_id>/items/bulk-images")
@login_required
def bulk_upload_playlist_images(playlist_id: int):
"""Bulk upload multiple images to a playlist.
Expects multipart/form-data:
- files: multiple image files
- crop_mode: "16:9" or "9:16" (optional; defaults to 16:9)
- duration_seconds: optional (defaults to 10)
Returns JSON:
{ ok: true, items: [...] }
"""
company_user_required()
playlist = db.session.get(Playlist, playlist_id)
if not playlist or playlist.company_id != current_user.company_id:
abort(404)
# This endpoint is intended for AJAX.
def _json_error(message: str, status: int = 400):
return jsonify({"ok": False, "error": message}), status
files = request.files.getlist("files")
if not files:
return _json_error("No files uploaded")
crop_mode = (request.form.get("crop_mode") or "16:9").strip().lower()
if crop_mode not in {"16:9", "9:16"}:
crop_mode = "16:9"
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
duration = max(1, duration)
# Quota check before processing.
with db.session.no_autoflush:
company = db.session.get(Company, current_user.company_id)
if not company:
abort(404)
upload_root = current_app.config["UPLOAD_FOLDER"]
used_bytes = get_company_upload_bytes(upload_root, company.id)
usage = compute_storage_usage(used_bytes=used_bytes, max_bytes=company.storage_max_bytes)
storage_max_human = _format_bytes(usage["max_bytes"]) if usage.get("max_bytes") else None
if usage.get("is_exceeded"):
return _json_error(_storage_limit_error_message(storage_max_human=storage_max_human), 403)
# Determine starting position.
max_pos = (
db.session.query(db.func.max(PlaylistItem.position)).filter_by(playlist_id=playlist_id).scalar() or 0
)
saved_relpaths: list[str] = []
items: list[PlaylistItem] = []
try:
for idx, f in enumerate(files, start=1):
if not f or not f.filename:
raise ValueError("file_required")
filename = secure_filename(f.filename)
ext = os.path.splitext(filename)[1].lower()
if ext not in ALLOWED_IMAGE_EXTENSIONS:
raise ValueError("unsupported")
relpath = _save_compressed_image(
f,
current_app.config["UPLOAD_FOLDER"],
current_user.company_id,
crop_mode=crop_mode,
)
saved_relpaths.append(relpath)
it = PlaylistItem(
playlist=playlist,
item_type="image",
title=None,
duration_seconds=duration,
position=max_pos + idx,
file_path=relpath,
)
items.append(it)
# Post-save quota check (like single image uploads)
if company.storage_max_bytes is not None and int(company.storage_max_bytes or 0) > 0:
used_after = get_company_upload_bytes(upload_root, company.id)
usage_after = compute_storage_usage(used_bytes=used_after, max_bytes=company.storage_max_bytes)
if usage_after.get("is_exceeded"):
# Remove all newly saved files and reject.
for p in saved_relpaths:
_try_delete_upload(p, upload_root)
return _json_error(_storage_limit_error_message(storage_max_human=storage_max_human), 403)
for it in items:
db.session.add(it)
db.session.commit()
except ValueError as e:
# Clean up any saved files.
upload_root = current_app.config["UPLOAD_FOLDER"]
for p in saved_relpaths:
_try_delete_upload(p, upload_root)
code = str(e)
if code == "unsupported":
return _json_error(
"Unsupported image type. Please upload one of: " + ", ".join(sorted(ALLOWED_IMAGE_EXTENSIONS))
)
if code == "file_required":
return _json_error("File required")
return _json_error("Failed to process image upload", 500)
except Exception:
db.session.rollback()
upload_root = current_app.config["UPLOAD_FOLDER"]
for p in saved_relpaths:
_try_delete_upload(p, upload_root)
return _json_error("Failed to process image upload", 500)
return jsonify(
{
"ok": True,
"items": [
{
"id": it.id,
"playlist_id": it.playlist_id,
"position": it.position,
"item_type": it.item_type,
"title": it.title,
"file_path": it.file_path,
"url": it.url,
"duration_seconds": it.duration_seconds,
}
for it in items
],
}
)
@bp.post("/items/<int:item_id>/delete")
@login_required
def delete_item(item_id: int):