feat(video): add streamable video events & dashboard controls

Add end-to-end support for video events: server streaming, scheduler
metadata, API fields, and dashboard UI.

- Server: range-capable streaming endpoint with byte-range support.
- Scheduler: emits `video` object; best-effort HEAD probe adds
  `mime_type`, `size`, `accept_ranges`; placeholders for richer
  metadata (duration/resolution/bitrate/qualities/thumbnails).
- API/DB: accept and persist `event_media_id`, `autoplay`, `loop`,
  `volume` for video events.
- Frontend: Event modal supports video selection + playback options;
  FileManager increased upload size and client-side duration check
  (max 10 minutes).
- Docs/UX: bumped program-info, added UX-only changelog and updated
  Copilot instructions for contributors.
- Notes: metadata extraction (ffprobe), checksum persistence, and
  HLS/DASH transcoding are recommended follow-ups (separate changes).
This commit is contained in:
RobbStarkAustria
2025-10-25 16:48:14 +00:00
parent e6c19c189f
commit 38800cec68
14 changed files with 453 additions and 83 deletions

View File

@@ -1,5 +1,5 @@
from re import A
from flask import Blueprint, request, jsonify, send_from_directory
from flask import Blueprint, request, jsonify, send_from_directory, Response, send_file
from server.permissions import editor_or_higher
from server.database import Session
from models.models import EventMedia, MediaType, Conversion, ConversionStatus
@@ -7,6 +7,7 @@ from server.task_queue import get_queue
from server.worker import convert_event_media_to_pdf
import hashlib
import os
import re
eventmedia_bp = Blueprint('eventmedia', __name__, url_prefix='/api/eventmedia')
@@ -304,3 +305,63 @@ def get_media_by_id(media_id):
}
session.close()
return jsonify(result)
# --- Video Streaming with Range Request Support ---
@eventmedia_bp.route('/stream/<int:media_id>/<path:filename>', methods=['GET'])
def stream_video(media_id, filename):
"""Stream video files with range request support for seeking"""
session = Session()
media = session.query(EventMedia).get(media_id)
if not media or not media.file_path:
session.close()
return jsonify({'error': 'Video not found'}), 404
file_path = os.path.join(MEDIA_ROOT, media.file_path)
if not os.path.exists(file_path):
session.close()
return jsonify({'error': 'File not found'}), 404
session.close()
# Determine MIME type based on file extension
ext = os.path.splitext(filename)[1].lower()
mime_types = {
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.ogv': 'video/ogg',
'.avi': 'video/x-msvideo',
'.mkv': 'video/x-matroska',
'.mov': 'video/quicktime',
'.wmv': 'video/x-ms-wmv',
'.flv': 'video/x-flv',
'.mpg': 'video/mpeg',
'.mpeg': 'video/mpeg',
}
mime_type = mime_types.get(ext, 'video/mp4')
# Support range requests for video seeking
range_header = request.headers.get('Range', None)
if not range_header:
return send_file(file_path, mimetype=mime_type)
size = os.path.getsize(file_path)
byte_start, byte_end = 0, size - 1
match = re.search(r'bytes=(\d+)-(\d*)', range_header)
if match:
byte_start = int(match.group(1))
if match.group(2):
byte_end = int(match.group(2))
length = byte_end - byte_start + 1
with open(file_path, 'rb') as f:
f.seek(byte_start)
data = f.read(length)
response = Response(data, 206, mimetype=mime_type, direct_passthrough=True)
response.headers.add('Content-Range', f'bytes {byte_start}-{byte_end}/{size}')
response.headers.add('Accept-Ranges', 'bytes')
response.headers.add('Content-Length', str(length))
return response

View File

@@ -394,6 +394,19 @@ def create_event():
session.commit()
event_media_id = media.id
# Video: event_media_id und Video-Einstellungen übernehmen
autoplay = None
loop = None
volume = None
if event_type == "video":
event_media_id = data.get("event_media_id")
if not event_media_id:
return jsonify({"error": "event_media_id required for video"}), 400
# Get video-specific settings with defaults
autoplay = data.get("autoplay", True)
loop = data.get("loop", False)
volume = data.get("volume", 0.8)
# created_by aus den Daten holen, Default: None
created_by = data.get("created_by")
@@ -419,6 +432,9 @@ def create_event():
is_active=True,
event_media_id=event_media_id,
slideshow_interval=slideshow_interval,
autoplay=autoplay,
loop=loop,
volume=volume,
created_by=created_by,
# Recurrence
recurrence_rule=data.get("recurrence_rule"),
@@ -491,6 +507,13 @@ def update_event(event_id):
event.event_type = data.get("event_type", event.event_type)
event.event_media_id = data.get("event_media_id", event.event_media_id)
event.slideshow_interval = data.get("slideshow_interval", event.slideshow_interval)
# Video-specific fields
if "autoplay" in data:
event.autoplay = data.get("autoplay")
if "loop" in data:
event.loop = data.get("loop")
if "volume" in data:
event.volume = data.get("volume")
event.created_by = data.get("created_by", event.created_by)
# Track previous values to decide on exception regeneration
prev_rule = event.recurrence_rule

View File

@@ -3,6 +3,8 @@ from server.database import Session
from models.models import EventMedia
import os
from flask import Response, abort, session as flask_session
# Blueprint for direct file downloads by media ID
files_bp = Blueprint("files", __name__, url_prefix="/api/files")
@@ -66,3 +68,29 @@ def download_converted(relpath: str):
if not os.path.isfile(abs_path):
return jsonify({"error": "File not found"}), 404
return send_from_directory(os.path.dirname(abs_path), os.path.basename(abs_path), as_attachment=True)
@files_bp.route('/stream/<path:filename>')
def stream_file(filename: str):
"""Stream a media file via nginx X-Accel-Redirect after basic auth checks.
The nginx config must define an internal alias for /internal_media/ that
points to the media folder (for example: /opt/infoscreen/server/media/).
"""
# Basic session-based auth: adapt to your project's auth logic if needed
user_role = flask_session.get('role')
if not user_role:
return abort(403)
# Normalize path to avoid directory traversal
safe_path = os.path.normpath('/' + filename).lstrip('/')
abs_path = os.path.join(MEDIA_ROOT, safe_path)
if not os.path.isfile(abs_path):
return abort(404)
# Return X-Accel-Redirect header to let nginx serve the file efficiently
internal_path = f'/internal_media/{safe_path}'
resp = Response()
resp.headers['X-Accel-Redirect'] = internal_path
# Optional: set content-type if you want (nginx can detect it)
return resp

View File

@@ -19,6 +19,10 @@ sys.path.append('/workspace')
app = Flask(__name__)
# Allow uploads up to 1 GiB at the Flask level (application hard limit)
# See nginx.conf for proxy limit; keep both in sync.
app.config['MAX_CONTENT_LENGTH'] = 1 * 1024 * 1024 * 1024 # 1 GiB
# Configure Flask session
# In production, use a secure random key from environment variable
app.config['SECRET_KEY'] = os.environ.get('FLASK_SECRET_KEY', 'dev-secret-key-change-in-production')