Files
enterprise_digital-platform/digiserver-v2/app/blueprints/content.py
T
ske087 4f4e017ad2 fix: player connectivity and media download pipeline
- nginx: add /api/ shortcut block (no portal auth, X-Script-Name /digiserver,
  Host $http_host) so players can reach DigiServer API without /digiserver prefix
- nginx: use $http_host in /api/ block so Flask host_url includes port — fixes
  media download URLs missing :8080 (was http://ip/digiserver/... not http://ip:8080/...)
- player main.py: fix double-port bug when server_ip already contains a port
  (e.g. 192.168.0.230:8080 was producing http://192.168.0.230:8080:80)
- get_playlists_v2.py: force re-sync when server version differs OR local media
  files are missing on disk — fixes stale playlist after server reset
- digiserver api.py: playlist endpoint builds full media URLs using script_root
  from X-Script-Name header set by nginx
- weblink support, player build/deploy improvements, manage-playlist AJAX prefix fix
2026-06-29 20:04:11 +03:00

1434 lines
60 KiB
Python

"""Content blueprint - New playlist-centric workflow."""
from flask import (Blueprint, render_template, request, redirect, url_for,
flash, jsonify, current_app)
from flask_login import login_required
from werkzeug.utils import secure_filename
from typing import Optional
import os
import threading
import uuid
from datetime import datetime
from urllib.parse import urlparse
from app.extensions import db, cache
from app.models import Content, Playlist, Player
from app.models.playlist import playlist_content
from app.utils.logger import log_action
from app.utils.uploads import process_video_file, set_upload_progress
# Store for background processing status
_background_tasks = {}
content_bp = Blueprint('content', __name__, url_prefix='/content')
@content_bp.route('/')
@login_required
def content_list():
"""Main playlist management page."""
playlists = Playlist.query.order_by(Playlist.created_at.desc()).all()
media_files = Content.query.order_by(Content.uploaded_at.desc()).limit(3).all() # Only last 3
total_media_count = Content.query.count() # Total count for display
players = Player.query.order_by(Player.name).all()
return render_template('content/content_list_new.html',
playlists=playlists,
media_files=media_files,
total_media_count=total_media_count,
players=players)
@content_bp.route('/media-library')
@login_required
def media_library():
"""View all media files in the library."""
from app.models.player_edit import PlayerEdit
media_files = Content.query.order_by(Content.uploaded_at.desc()).all()
# Add edit count to each media item
for media in media_files:
media.edit_count = PlayerEdit.query.filter_by(content_id=media.id).count()
# Group by content type
images = [m for m in media_files if m.content_type == 'image']
videos = [m for m in media_files if m.content_type == 'video']
pdfs = [m for m in media_files if m.content_type == 'pdf']
presentations = [m for m in media_files if m.content_type == 'pptx']
others = [m for m in media_files if m.content_type not in ['image', 'video', 'pdf', 'pptx']]
return render_template('content/media_library.html',
media_files=media_files,
images=images,
videos=videos,
pdfs=pdfs,
presentations=presentations,
others=others)
@content_bp.route('/media/<int:media_id>/delete', methods=['POST'])
@login_required
def delete_media(media_id: int):
"""Delete a media file and remove it from all playlists."""
try:
media = Content.query.get_or_404(media_id)
filename = media.filename
# Get all playlists containing this media
affected_playlists = list(media.playlists.all())
# Delete physical file
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], media.filename)
if os.path.exists(file_path):
os.remove(file_path)
log_action('info', f'Deleted physical file: {filename}')
# Delete edited media archive folder if it exists
import shutil
edited_media_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media', str(media.id))
if os.path.exists(edited_media_dir):
shutil.rmtree(edited_media_dir)
log_action('info', f'Deleted edited media archive for content ID {media.id}')
# Delete associated player edit records first
from app.models.player_edit import PlayerEdit
edit_records = PlayerEdit.query.filter_by(content_id=media.id).all()
if edit_records:
for edit in edit_records:
db.session.delete(edit)
log_action('info', f'Deleted {len(edit_records)} edit record(s) for content: {filename}')
# Remove from all playlists (this will cascade properly)
db.session.delete(media)
# Increment version for all affected playlists
for playlist in affected_playlists:
playlist.version += 1
log_action('info', f'Playlist "{playlist.name}" version updated to {playlist.version} (media removed)')
db.session.commit()
# Clear cache for affected playlists
from app.blueprints.players import get_player_playlist
from app.extensions import cache
for playlist in affected_playlists:
for player in playlist.players:
cache.delete_memoized(get_player_playlist, player.id)
if affected_playlists:
flash(f'Deleted "{filename}" and removed from {len(affected_playlists)} playlist(s). Playlist versions updated.', 'success')
else:
flash(f'Deleted "{filename}" successfully.', 'success')
log_action('info', f'Media deleted: {filename} (affected {len(affected_playlists)} playlists)')
except Exception as e:
db.session.rollback()
log_action('error', f'Error deleting media: {str(e)}')
flash(f'Error deleting media: {str(e)}', 'danger')
return redirect(url_for('content.media_library'))
@content_bp.route('/playlist/create', methods=['POST'])
@login_required
def create_playlist():
"""Create a new playlist."""
try:
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
orientation = request.form.get('orientation', 'Landscape')
if not name:
flash('Playlist name is required.', 'warning')
return redirect(url_for('content.content_list'))
# Check if playlist name exists
existing = Playlist.query.filter_by(name=name).first()
if existing:
flash(f'Playlist "{name}" already exists.', 'warning')
return redirect(url_for('content.content_list'))
playlist = Playlist(
name=name,
description=description or None,
orientation=orientation
)
db.session.add(playlist)
db.session.commit()
log_action('info', f'Created playlist: {name}')
flash(f'Playlist "{name}" created successfully!', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error creating playlist: {str(e)}')
flash('Error creating playlist.', 'danger')
return redirect(url_for('content.content_list'))
@content_bp.route('/playlist/<int:playlist_id>/delete', methods=['POST'])
@login_required
def delete_playlist(playlist_id: int):
"""Delete a playlist."""
playlist = Playlist.query.get_or_404(playlist_id)
try:
name = playlist.name
# Unassign all players from this playlist
Player.query.filter_by(playlist_id=playlist_id).update({'playlist_id': None})
db.session.delete(playlist)
db.session.commit()
cache.clear()
log_action('info', f'Deleted playlist: {name}')
flash(f'Playlist "{name}" deleted successfully.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error deleting playlist: {str(e)}')
flash('Error deleting playlist.', 'danger')
return redirect(url_for('content.content_list'))
@content_bp.route('/playlist/<int:playlist_id>/manage')
@login_required
def manage_playlist_content(playlist_id: int):
"""Manage content in a specific playlist."""
playlist = Playlist.query.get_or_404(playlist_id)
# Get content in playlist (ordered)
playlist_content = playlist.get_content_ordered()
# Get all available content not in this playlist.
# Web links are created on demand per playlist, so they are not offered
# as reusable library items here.
all_content = Content.query.filter(Content.content_type != 'weblink').all()
playlist_content_ids = {c.id for c in playlist_content}
available_content = [c for c in all_content if c.id not in playlist_content_ids]
return render_template('content/manage_playlist_content.html',
playlist=playlist,
playlist_content=playlist_content,
available_content=available_content)
@content_bp.route('/playlist/<int:playlist_id>/add-content', methods=['POST'])
@login_required
def add_content_to_playlist(playlist_id: int):
"""Add content to playlist."""
playlist = Playlist.query.get_or_404(playlist_id)
try:
content_id = request.form.get('content_id', type=int)
duration = request.form.get('duration', type=int, default=10)
if not content_id:
flash('Please select content to add.', 'warning')
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
content = Content.query.get_or_404(content_id)
# Get max position
from sqlalchemy import select, func
from app.models.playlist import playlist_content
max_pos = db.session.execute(
select(func.max(playlist_content.c.position)).where(
playlist_content.c.playlist_id == playlist_id
)
).scalar() or 0
# Add to playlist
stmt = playlist_content.insert().values(
playlist_id=playlist_id,
content_id=content_id,
position=max_pos + 1,
duration=duration
)
db.session.execute(stmt)
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Added "{content.filename}" to playlist "{playlist.name}"')
flash(f'Added "{content.filename}" to playlist.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error adding content to playlist: {str(e)}')
flash('Error adding content to playlist.', 'danger')
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
@content_bp.route('/playlist/<int:playlist_id>/add-weblink', methods=['POST'])
@login_required
def add_weblink_to_playlist(playlist_id: int):
"""Create a web link content item and add it to the playlist."""
playlist = Playlist.query.get_or_404(playlist_id)
try:
web_url = (request.form.get('url') or '').strip()
duration = request.form.get('duration', type=int, default=30)
description = (request.form.get('description') or '').strip() or None
# Validate the URL: only http/https schemes are allowed (avoid file://, etc.)
parsed = urlparse(web_url)
if parsed.scheme.lower() not in ('http', 'https') or not parsed.netloc:
flash('Please enter a valid http:// or https:// web address.', 'warning')
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
if duration is None or duration < 1:
duration = 30
# Create a weblink Content row. filename is a synthetic unique label
# (no file on disk); the real target lives in the url column.
content = Content(
filename=f'weblink-{uuid.uuid4().hex[:12]}',
content_type='weblink',
url=web_url,
duration=duration,
description=description or web_url,
uploaded_at=datetime.utcnow(),
)
db.session.add(content)
db.session.flush() # assign content.id
# Append to the end of the playlist
from sqlalchemy import select, func
max_pos = db.session.execute(
select(func.max(playlist_content.c.position)).where(
playlist_content.c.playlist_id == playlist_id
)
).scalar() or 0
db.session.execute(
playlist_content.insert().values(
playlist_id=playlist_id,
content_id=content.id,
position=max_pos + 1,
duration=duration,
)
)
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Added web link "{web_url}" to playlist "{playlist.name}"')
flash('Web link added to playlist.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error adding web link to playlist: {str(e)}')
flash('Error adding web link to playlist.', 'danger')
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
@content_bp.route('/playlist/<int:playlist_id>/remove-content/<int:content_id>', methods=['POST'])
@login_required
def remove_content_from_playlist(playlist_id: int, content_id: int):
"""Remove content from playlist."""
playlist = Playlist.query.get_or_404(playlist_id)
try:
from app.models.playlist import playlist_content
# Remove from playlist
stmt = playlist_content.delete().where(
(playlist_content.c.playlist_id == playlist_id) &
(playlist_content.c.content_id == content_id)
)
db.session.execute(stmt)
# Web link items are playlist-specific and have no media-library
# presence, so delete the orphan Content row when it is removed.
content = db.session.get(Content, content_id)
if content is not None and content.content_type == 'weblink':
db.session.delete(content)
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Removed content from playlist "{playlist.name}"')
flash('Content removed from playlist.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error removing content from playlist: {str(e)}')
flash('Error removing content from playlist.', 'danger')
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
@content_bp.route('/playlist/<int:playlist_id>/bulk-remove', methods=['POST'])
@login_required
def bulk_remove_from_playlist(playlist_id: int):
"""Remove multiple content items from playlist."""
playlist = Playlist.query.get_or_404(playlist_id)
try:
data = request.get_json()
content_ids = data.get('content_ids', [])
if not content_ids:
return jsonify({'success': False, 'message': 'No content IDs provided'}), 400
from app.models.playlist import playlist_content
# Remove all selected items
stmt = playlist_content.delete().where(
(playlist_content.c.playlist_id == playlist_id) &
(playlist_content.c.content_id.in_(content_ids))
)
result = db.session.execute(stmt)
# Increment version
playlist.increment_version()
db.session.commit()
cache.clear()
removed_count = result.rowcount if hasattr(result, 'rowcount') else len(content_ids)
log_action('info', f'Bulk removed {removed_count} items from playlist "{playlist.name}"')
return jsonify({
'success': True,
'message': f'Removed {removed_count} item(s) from playlist',
'removed_count': removed_count
})
except Exception as e:
db.session.rollback()
log_action('error', f'Error bulk removing from playlist: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@content_bp.route('/playlist/<int:playlist_id>/reorder', methods=['POST'])
@login_required
def reorder_playlist_content(playlist_id: int):
"""Reorder content in playlist."""
playlist = Playlist.query.get_or_404(playlist_id)
try:
data = request.get_json()
content_ids = data.get('content_ids', [])
if not content_ids:
return jsonify({'success': False, 'message': 'No content IDs provided'}), 400
from app.models.playlist import playlist_content
# Update positions
for idx, content_id in enumerate(content_ids, start=1):
stmt = playlist_content.update().where(
(playlist_content.c.playlist_id == playlist_id) &
(playlist_content.c.content_id == content_id)
).values(position=idx)
db.session.execute(stmt)
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Reordered playlist "{playlist.name}"')
return jsonify({
'success': True,
'message': 'Playlist reordered successfully',
'version': playlist.version
})
except Exception as e:
db.session.rollback()
log_action('error', f'Error reordering playlist: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@content_bp.route('/playlist/<int:playlist_id>/update-muted/<int:content_id>', methods=['POST'])
@login_required
def update_playlist_content_muted(playlist_id: int, content_id: int):
"""Update content muted setting in playlist."""
playlist = Playlist.query.get_or_404(playlist_id)
try:
content = Content.query.get_or_404(content_id)
muted = request.form.get('muted', 'true').lower() == 'true'
from app.models.playlist import playlist_content
from sqlalchemy import update
# Update muted in association table
stmt = update(playlist_content).where(
(playlist_content.c.playlist_id == playlist_id) &
(playlist_content.c.content_id == content_id)
).values(muted=muted)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Updated muted={muted} for "{content.filename}" in playlist "{playlist.name}"')
return jsonify({
'success': True,
'message': 'Audio setting updated',
'muted': muted,
'version': playlist.version
})
except Exception as e:
db.session.rollback()
log_action('error', f'Error updating muted setting: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@content_bp.route('/playlist/<int:playlist_id>/update-edit-enabled/<int:content_id>', methods=['POST'])
@login_required
def update_playlist_content_edit_enabled(playlist_id: int, content_id: int):
"""Update content edit_on_player_enabled setting in playlist."""
playlist = Playlist.query.get_or_404(playlist_id)
try:
content = Content.query.get_or_404(content_id)
edit_enabled = request.form.get('edit_enabled', 'false').lower() == 'true'
from app.models.playlist import playlist_content
from sqlalchemy import update
# Update edit_on_player_enabled in association table
stmt = update(playlist_content).where(
(playlist_content.c.playlist_id == playlist_id) &
(playlist_content.c.content_id == content_id)
).values(edit_on_player_enabled=edit_enabled)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Updated edit_on_player_enabled={edit_enabled} for "{content.filename}" in playlist "{playlist.name}"')
return jsonify({
'success': True,
'message': 'Edit setting updated',
'edit_enabled': edit_enabled,
'version': playlist.version
})
except Exception as e:
db.session.rollback()
log_action('error', f'Error updating edit setting: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@content_bp.route('/playlist/<int:playlist_id>/update-duration/<int:content_id>', methods=['POST'])
@login_required
def update_playlist_content_duration(playlist_id: int, content_id: int):
"""Update content duration in playlist."""
playlist = Playlist.query.get_or_404(playlist_id)
try:
content = Content.query.get_or_404(content_id)
# Get duration from request
try:
duration = int(request.form.get('duration', 10))
except (ValueError, TypeError):
return jsonify({'success': False, 'message': 'Invalid duration value'}), 400
# Validate duration (minimum 1 second)
if duration < 1:
return jsonify({'success': False, 'message': 'Duration must be at least 1 second'}), 400
from app.models.playlist import playlist_content
from sqlalchemy import update
# Update duration in association table
stmt = update(playlist_content).where(
(playlist_content.c.playlist_id == playlist_id) &
(playlist_content.c.content_id == content_id)
).values(duration=duration)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Updated duration={duration}s for "{content.filename}" in playlist "{playlist.name}"')
return jsonify({
'success': True,
'message': 'Duration updated',
'duration': duration,
'version': playlist.version
})
except Exception as e:
db.session.rollback()
log_action('error', f'Error updating duration: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@content_bp.route('/upload-media-page')
@login_required
def upload_media_page():
"""Display upload media page."""
playlists = Playlist.query.order_by(Playlist.name).all()
return render_template('content/upload_media.html', playlists=playlists)
def process_image_file(filepath: str, filename: str) -> tuple[bool, str]:
"""Process and optimize image files."""
try:
from PIL import Image
# Open and optimize image
img = Image.open(filepath)
# Convert RGBA to RGB for JPEGs
if img.mode == 'RGBA' and filename.lower().endswith(('.jpg', '.jpeg')):
rgb_img = Image.new('RGB', img.size, (255, 255, 255))
rgb_img.paste(img, mask=img.split()[3])
img = rgb_img
# Resize if too large (max 1920x1080 for display efficiency)
max_size = (1920, 1080)
if img.width > max_size[0] or img.height > max_size[1]:
img.thumbnail(max_size, Image.Resampling.LANCZOS)
img.save(filepath, optimize=True, quality=85)
log_action('info', f'Optimized image: {filename}')
return True, "Image processed successfully"
except Exception as e:
return False, f"Image processing error: {str(e)}"
def process_video_file_extended(filepath: str, filename: str) -> tuple[bool, str]:
"""Process and optimize video files for Raspberry Pi playback."""
try:
# Basic video validation
import subprocess
# Check if video is playable
result = subprocess.run(
['ffprobe', '-v', 'error', '-select_streams', 'v:0',
'-show_entries', 'stream=codec_name,width,height',
'-of', 'default=noprint_wrappers=1', filepath],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
log_action('info', f'Video validated: {filename}')
return True, "Video validated successfully"
else:
return False, "Video validation failed"
except Exception as e:
# If ffprobe not available, just accept the video
log_action('warning', f'Video validation skipped (ffprobe unavailable): {filename}')
return True, "Video accepted without validation"
def process_pdf_file(filepath: str, filename: str) -> tuple[bool, str]:
"""Process PDF files by converting each page to PNG images."""
try:
from pdf2image import convert_from_path
from pathlib import Path
# Basic PDF validation - check if it's a valid PDF
with open(filepath, 'rb') as f:
header = f.read(5)
if header != b'%PDF-':
return False, "Invalid PDF file"
log_action('info', f'Converting PDF to images: {filename}')
# Convert PDF pages to images at high DPI for quality
images = convert_from_path(
filepath,
dpi=300, # 300 DPI for sharp rendering
fmt='png'
)
if not images:
return False, "No pages found in PDF"
# Generate base filename without extension
base_filename = Path(filename).stem
upload_folder = os.path.dirname(filepath)
# Save each page with proper aspect ratio preservation
converted_files = []
for idx, image in enumerate(images, start=1):
# Create filename for this page
page_filename = f"{base_filename}_page{idx:03d}.png"
page_filepath = os.path.join(upload_folder, page_filename)
# Determine orientation and resize maintaining aspect ratio
width, height = image.size
is_portrait = height > width
# Define Full HD dimensions based on orientation
if is_portrait:
# Portrait: max height 1920, max width 1080 (rotated Full HD)
max_size = (1080, 1920)
else:
# Landscape: max width 1920, max height 1080 (standard Full HD)
max_size = (1920, 1080)
# Resize maintaining aspect ratio (thumbnail maintains ratio)
from PIL import Image as PILImage
image.thumbnail(max_size, PILImage.Resampling.LANCZOS)
# Save the optimized image
image.save(page_filepath, 'PNG', optimize=True, quality=95)
converted_files.append((page_filepath, page_filename))
log_action('info', f'Converted PDF page {idx}/{len(images)} ({width}x{height} -> {image.size[0]}x{image.size[1]}): {page_filename}')
log_action('info', f'PDF converted successfully: {len(images)} pages from {filename}')
# Return success with file info for later processing
return True, f"PDF converted to {len(images)} images"
except ImportError:
return False, "pdf2image library not installed. Install with: pip install pdf2image"
except Exception as e:
import traceback
error_details = traceback.format_exc()
log_action('error', f'PDF processing error: {str(e)}\n{error_details}')
return False, f"PDF processing error: {str(e)}"
def process_file_in_background(app, filepath: str, filename: str, file_ext: str,
duration: int, playlist_id: Optional[int], task_id: str, edit_on_player_enabled: bool = False):
"""Process large files (PDF, PPTX, Video) in background thread."""
with app.app_context():
try:
_background_tasks[task_id] = {'status': 'processing', 'message': f'Processing {filename}...'}
upload_folder = app.config['UPLOAD_FOLDER']
processing_success = True
processing_message = ""
detected_type = file_ext
# Process based on file type
if file_ext == 'pdf':
processing_success, processing_message = process_pdf_file(filepath, filename)
if processing_success and "converted to" in processing_message.lower():
base_name = os.path.splitext(filename)[0]
page_pattern = f"{base_name}_page*.png"
import glob
page_files = sorted(glob.glob(os.path.join(upload_folder, page_pattern)))
if page_files:
max_position = 0
if playlist_id:
playlist = Playlist.query.get(playlist_id)
max_position = db.session.query(db.func.max(playlist_content.c.position))\
.filter(playlist_content.c.playlist_id == playlist_id)\
.scalar() or 0
for page_file in page_files:
page_filename = os.path.basename(page_file)
page_content = Content(
filename=page_filename,
content_type='image',
duration=duration,
file_size=os.path.getsize(page_file)
)
db.session.add(page_content)
db.session.flush()
if playlist_id:
max_position += 1
stmt = playlist_content.insert().values(
playlist_id=playlist_id,
content_id=page_content.id,
position=max_position,
duration=duration,
edit_on_player_enabled=edit_on_player_enabled
)
db.session.execute(stmt)
if playlist_id and page_files:
playlist = Playlist.query.get(playlist_id)
if playlist:
playlist.version += 1
db.session.commit()
cache.clear()
if os.path.exists(filepath):
os.remove(filepath)
_background_tasks[task_id] = {
'status': 'complete',
'message': f'PDF converted to {len(page_files)} images successfully!'
}
log_action('info', f'Background: PDF {filename} converted to {len(page_files)} pages')
return
elif file_ext in ['ppt', 'pptx']:
processing_success, processing_message = process_presentation_file(filepath, filename)
if processing_success and "converted to" in processing_message.lower():
base_name = os.path.splitext(filename)[0]
slide_pattern = f"{base_name}_slide_*.png"
import glob
slide_files = sorted(glob.glob(os.path.join(upload_folder, slide_pattern)))
if slide_files:
max_position = 0
if playlist_id:
playlist = Playlist.query.get(playlist_id)
max_position = db.session.query(db.func.max(playlist_content.c.position))\
.filter(playlist_content.c.playlist_id == playlist_id)\
.scalar() or 0
for slide_file in slide_files:
slide_filename = os.path.basename(slide_file)
slide_content = Content(
filename=slide_filename,
content_type='image',
duration=duration,
file_size=os.path.getsize(slide_file)
)
db.session.add(slide_content)
db.session.flush()
if playlist_id:
max_position += 1
stmt = playlist_content.insert().values(
playlist_id=playlist_id,
content_id=slide_content.id,
position=max_position,
duration=duration,
edit_on_player_enabled=edit_on_player_enabled
)
db.session.execute(stmt)
if playlist_id and slide_files:
playlist = Playlist.query.get(playlist_id)
if playlist:
playlist.version += 1
db.session.commit()
cache.clear()
if os.path.exists(filepath):
os.remove(filepath)
_background_tasks[task_id] = {
'status': 'complete',
'message': f'Presentation converted to {len(slide_files)} slides successfully!'
}
log_action('info', f'Background: PPTX {filename} converted to {len(slide_files)} slides')
return
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv']:
processing_success, processing_message = process_video_file_extended(filepath, filename)
detected_type = 'video'
# If file still exists, add as regular content
if processing_success and os.path.exists(filepath):
content = Content(
filename=filename,
content_type=detected_type,
duration=duration,
file_size=os.path.getsize(filepath)
)
db.session.add(content)
db.session.flush()
if playlist_id:
playlist = Playlist.query.get(playlist_id)
if playlist:
max_position = db.session.query(db.func.max(playlist_content.c.position))\
.filter(playlist_content.c.playlist_id == playlist_id)\
.scalar() or 0
stmt = playlist_content.insert().values(
playlist_id=playlist_id,
content_id=content.id,
position=max_position + 1,
duration=duration,
edit_on_player_enabled=edit_on_player_enabled
)
db.session.execute(stmt)
playlist.version += 1
db.session.commit()
cache.clear()
_background_tasks[task_id] = {'status': 'complete', 'message': f'{filename} processed successfully!'}
log_action('info', f'Background: {filename} processed successfully')
else:
_background_tasks[task_id] = {
'status': 'error',
'message': f'Failed to process {filename}: {processing_message}'
}
log_action('error', f'Background: Failed to process {filename}')
except Exception as e:
db.session.rollback()
_background_tasks[task_id] = {'status': 'error', 'message': f'Error: {str(e)}'}
log_action('error', f'Background processing error for {filename}: {str(e)}')
def process_presentation_file(filepath: str, filename: str) -> tuple[bool, str]:
"""Process PowerPoint presentation files by converting slides to images."""
try:
import subprocess
import tempfile
import shutil
from pathlib import Path
# Basic validation - check file exists and has content
file_size = os.path.getsize(filepath)
if file_size < 1024: # Less than 1KB is suspicious
return False, "File too small to be a valid presentation"
# Check if LibreOffice is available
libreoffice_paths = [
'/usr/bin/libreoffice',
'/usr/bin/soffice',
'/snap/bin/libreoffice',
'libreoffice', # Try in PATH
'soffice'
]
libreoffice_cmd = None
for cmd in libreoffice_paths:
try:
result = subprocess.run([cmd, '--version'],
capture_output=True,
timeout=5)
if result.returncode == 0:
libreoffice_cmd = cmd
log_action('info', f'Found LibreOffice at: {cmd}')
break
except (FileNotFoundError, subprocess.TimeoutExpired):
continue
if not libreoffice_cmd:
log_action('warning', f'LibreOffice not found, cannot convert: {filename}')
return False, "LibreOffice is not installed. Please install it from the Admin Panel → System Dependencies to upload PowerPoint files."
# Create temporary directory for conversion
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# Copy presentation to temp directory
temp_ppt = temp_path / filename
shutil.copy2(filepath, temp_ppt)
# Convert presentation to PDF first (for better quality)
convert_cmd = [
libreoffice_cmd,
'--headless',
'--convert-to', 'pdf',
'--outdir', str(temp_path),
str(temp_ppt)
]
log_action('info', f'Converting presentation to PDF: {filename}')
try:
result = subprocess.run(
convert_cmd,
capture_output=True,
text=True,
timeout=120 # 2 minutes timeout
)
if result.returncode != 0:
log_action('error', f'LibreOffice conversion failed: {result.stderr}')
return True, "Presentation accepted without conversion (conversion failed)"
# Find generated PDF file
pdf_files = list(temp_path.glob('*.pdf'))
if not pdf_files:
log_action('warning', f'No PDF generated from presentation: {filename}')
return True, "Presentation accepted without conversion"
pdf_file = pdf_files[0]
log_action('info', f'Converting PDF to images at Full HD resolution: {pdf_file.name}')
# Convert PDF to images using pdftoppm at Full HD resolution (1920x1080)
# Calculate DPI for Full HD output (assuming standard presentation is 10x7.5 inches)
# 1920/10 = 192 DPI for width, use 192 DPI for best quality
pdftoppm_cmd = [
'pdftoppm',
'-png',
'-r', '300', # High DPI for quality
'-scale-to', '1920', # Scale width to 1920px
str(pdf_file),
str(temp_path / 'slide')
]
result = subprocess.run(
pdftoppm_cmd,
capture_output=True,
text=True,
timeout=120
)
if result.returncode != 0:
log_action('error', f'pdftoppm conversion failed: {result.stderr}')
return True, "Presentation accepted without conversion (image conversion failed)"
# Find generated PNG files
png_files = sorted(temp_path.glob('slide-*.png'))
if not png_files:
log_action('warning', f'No images generated from presentation: {filename}')
return True, "Presentation accepted without images"
# Get upload folder from app config
upload_folder = current_app.config['UPLOAD_FOLDER']
base_name = os.path.splitext(filename)[0]
# Move converted images to upload folder and resize to exact Full HD
slide_count = 0
for idx, png_file in enumerate(png_files, start=1):
# Create descriptive filename
slide_filename = f"{base_name}_slide_{idx:03d}.png"
destination = os.path.join(upload_folder, slide_filename)
shutil.move(str(png_file), destination)
# Resize to exact Full HD dimensions (1920x1080) maintaining aspect ratio
resize_image_to_fullhd(destination)
slide_count += 1
log_action('info', f'Converted {slide_count} slides from {filename} to images')
# Remove original PPTX file as we now have the images
os.remove(filepath)
return True, f"Presentation converted to {slide_count} Full HD images"
except subprocess.TimeoutExpired:
log_action('error', f'LibreOffice conversion timeout for: {filename}')
return True, "Presentation accepted without conversion (timeout)"
except Exception as e:
log_action('error', f'Presentation processing error: {str(e)}')
return False, f"Presentation processing error: {str(e)}"
def create_fullhd_image(img):
"""Create a Full HD (1920x1080) image from PIL Image object, centered on white background."""
from PIL import Image as PILImage
target_size = (1920, 1080)
# Resize maintaining aspect ratio
img_copy = img.copy()
img_copy.thumbnail(target_size, PILImage.Resampling.LANCZOS)
# Create canvas with white background
fullhd_img = PILImage.new('RGB', target_size, (255, 255, 255))
# Center the image
x = (target_size[0] - img_copy.width) // 2
y = (target_size[1] - img_copy.height) // 2
if img_copy.mode == 'RGBA':
fullhd_img.paste(img_copy, (x, y), img_copy)
else:
fullhd_img.paste(img_copy, (x, y))
return fullhd_img
def resize_image_to_fullhd(filepath: str) -> bool:
"""Resize image to exactly Full HD (1920x1080) maintaining aspect ratio with centered crop or padding."""
try:
from PIL import Image
img = Image.open(filepath)
target_width = 1920
target_height = 1080
# Calculate aspect ratios
img_aspect = img.width / img.height
target_aspect = target_width / target_height
if abs(img_aspect - target_aspect) < 0.01:
# Aspect ratio is very close, just resize
img_resized = img.resize((target_width, target_height), Image.Resampling.LANCZOS)
elif img_aspect > target_aspect:
# Image is wider than target, fit height and crop/pad width
new_height = target_height
new_width = int(target_height * img_aspect)
img_resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
# Crop to center if wider
if new_width > target_width:
left = (new_width - target_width) // 2
img_resized = img_resized.crop((left, 0, left + target_width, target_height))
else:
# Pad with white if narrower
result = Image.new('RGB', (target_width, target_height), (255, 255, 255))
offset = (target_width - new_width) // 2
result.paste(img_resized, (offset, 0))
img_resized = result
else:
# Image is taller than target, fit width and crop/pad height
new_width = target_width
new_height = int(target_width / img_aspect)
img_resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
# Crop to center if taller
if new_height > target_height:
top = (new_height - target_height) // 2
img_resized = img_resized.crop((0, top, target_width, top + target_height))
else:
# Pad with white if shorter
result = Image.new('RGB', (target_width, target_height), (255, 255, 255))
offset = (target_height - new_height) // 2
result.paste(img_resized, (0, offset))
img_resized = result
# Save optimized image
img_resized.save(filepath, 'PNG', optimize=True, quality=95)
return True
except Exception as e:
log_action('error', f'Image resize error: {str(e)}')
return False
def optimize_image_to_fullhd(filepath: str) -> bool:
"""Optimize and resize image file to Full HD (1920x1080) maintaining aspect ratio."""
try:
from PIL import Image
img = Image.open(filepath)
fullhd_img = create_fullhd_image(img)
fullhd_img.save(filepath, 'PNG', optimize=True)
return True
except Exception as e:
log_action('error', f'Image optimization error: {str(e)}')
return False
@content_bp.route('/upload-media', methods=['POST'])
@login_required
def upload_media():
"""Upload media files to library with type-specific processing."""
try:
files = request.files.getlist('files')
content_type = request.form.get('content_type', 'image')
duration = request.form.get('duration', type=int, default=10)
playlist_id = request.form.get('playlist_id', type=int)
edit_on_player_enabled = request.form.get('edit_on_player_enabled', '0') == '1'
if not files or files[0].filename == '':
flash('No files provided.', 'warning')
return redirect(url_for('content.upload_media_page'))
upload_folder = current_app.config['UPLOAD_FOLDER']
os.makedirs(upload_folder, exist_ok=True)
uploaded_count = 0
background_count = 0
processing_errors = []
for file in files:
if file.filename == '':
continue
filename = secure_filename(file.filename)
filepath = os.path.join(upload_folder, filename)
# Check if file already exists
existing = Content.query.filter_by(filename=filename).first()
if existing:
log_action('warning', f'File {filename} already exists, skipping')
continue
# Save file first
file.save(filepath)
# Determine content type from extension
file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
# Check if file needs background processing (large files)
needs_background = file_ext in ['pdf', 'ppt', 'pptx', 'mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv']
if needs_background:
# Process in background thread
import uuid
task_id = str(uuid.uuid4())
_background_tasks[task_id] = {'status': 'queued', 'message': f'Queued {filename} for processing...'}
thread = threading.Thread(
target=process_file_in_background,
args=(current_app._get_current_object(), filepath, filename, file_ext, duration, playlist_id, task_id, edit_on_player_enabled)
)
thread.daemon = True
thread.start()
background_count += 1
log_action('info', f'Queued {filename} for background processing (task: {task_id})')
continue
# Process file based on type
processing_success = True
processing_message = ""
if file_ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']:
detected_type = 'image'
processing_success, processing_message = process_image_file(filepath, filename)
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv']:
detected_type = 'video'
processing_success, processing_message = process_video_file_extended(filepath, filename)
elif file_ext == 'pdf':
detected_type = 'pdf'
processing_success, processing_message = process_pdf_file(filepath, filename)
# For PDFs, pages are converted to individual images
# We need to add each page image as a separate content item
if processing_success and "converted to" in processing_message.lower():
# Find all page images that were created
base_name = os.path.splitext(filename)[0]
page_pattern = f"{base_name}_page*.png"
import glob
page_files = sorted(glob.glob(os.path.join(upload_folder, page_pattern)))
if page_files:
max_position = 0
if playlist_id:
playlist = Playlist.query.get(playlist_id)
max_position = db.session.query(db.func.max(playlist_content.c.position))\
.filter(playlist_content.c.playlist_id == playlist_id)\
.scalar() or 0
# Add each page as separate content
for page_file in page_files:
page_filename = os.path.basename(page_file)
# Create content record for page
page_content = Content(
filename=page_filename,
content_type='image',
duration=duration,
file_size=os.path.getsize(page_file)
)
db.session.add(page_content)
db.session.flush()
# Add to playlist if specified
if playlist_id:
max_position += 1
stmt = playlist_content.insert().values(
playlist_id=playlist_id,
content_id=page_content.id,
position=max_position,
duration=duration,
edit_on_player_enabled=edit_on_player_enabled
)
db.session.execute(stmt)
uploaded_count += 1
# Increment playlist version if pages were added
if playlist_id and page_files:
playlist.increment_version()
# Delete original PDF file
if os.path.exists(filepath):
os.remove(filepath)
log_action('info', f'Removed original PDF after conversion: {filename}')
continue # Skip normal content creation below
elif file_ext in ['ppt', 'pptx']:
detected_type = 'pptx'
processing_success, processing_message = process_presentation_file(filepath, filename)
# For presentations, slides are converted to individual images
# We need to add each slide image as a separate content item
if processing_success and "converted to" in processing_message.lower():
# Find all slide images that were created
base_name = os.path.splitext(filename)[0]
slide_pattern = f"{base_name}_slide_*.png"
import glob
slide_files = sorted(glob.glob(os.path.join(upload_folder, slide_pattern)))
if slide_files:
max_position = 0
if playlist_id:
playlist = Playlist.query.get(playlist_id)
max_position = db.session.query(db.func.max(playlist_content.c.position))\
.filter(playlist_content.c.playlist_id == playlist_id)\
.scalar() or 0
# Add each slide as separate content
for slide_file in slide_files:
slide_filename = os.path.basename(slide_file)
# Create content record for slide
slide_content = Content(
filename=slide_filename,
content_type='image',
duration=duration,
file_size=os.path.getsize(slide_file)
)
db.session.add(slide_content)
db.session.flush()
# Add to playlist if specified
if playlist_id:
max_position += 1
stmt = playlist_content.insert().values(
playlist_id=playlist_id,
content_id=slide_content.id,
position=max_position,
duration=duration,
edit_on_player_enabled=edit_on_player_enabled
)
db.session.execute(stmt)
uploaded_count += 1
# Increment playlist version if slides were added
if playlist_id and slide_files:
playlist.increment_version()
# Delete original PPTX file
if os.path.exists(filepath):
os.remove(filepath)
log_action('info', f'Removed original PPTX after conversion: {filename}')
continue # Skip normal content creation below
else:
detected_type = 'other'
if not processing_success:
processing_errors.append(f"{filename}: {processing_message}")
if os.path.exists(filepath):
os.remove(filepath) # Remove failed file
log_action('error', f'Processing failed for {filename}: {processing_message}')
continue
# Create content record (for non-presentation files or failed conversions)
if os.path.exists(filepath):
content = Content(
filename=filename,
content_type=detected_type,
duration=duration,
file_size=os.path.getsize(filepath)
)
db.session.add(content)
db.session.flush() # Get content ID
# Add to playlist if specified
if playlist_id:
playlist = Playlist.query.get(playlist_id)
if playlist:
# Get max position
max_position = db.session.query(db.func.max(playlist_content.c.position))\
.filter(playlist_content.c.playlist_id == playlist_id)\
.scalar() or 0
# Add to playlist
stmt = playlist_content.insert().values(
playlist_id=playlist_id,
content_id=content.id,
position=max_position + 1,
duration=duration,
edit_on_player_enabled=edit_on_player_enabled
)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
uploaded_count += 1
db.session.commit()
cache.clear()
log_action('info', f'Uploaded {uploaded_count} media files, {background_count} files processing in background')
# Show appropriate flash message
if processing_errors:
error_summary = '; '.join(processing_errors[:3])
if len(processing_errors) > 3:
error_summary += f' and {len(processing_errors) - 3} more...'
flash(f'Uploaded {uploaded_count} file(s). Errors: {error_summary}', 'warning')
elif background_count > 0:
bg_msg = f'{background_count} file(s) are being processed in the background (PDF, PPTX, or large videos). '
bg_msg += 'They will appear in the media library/playlist automatically when conversion completes. '
bg_msg += 'This may take a few minutes.'
if uploaded_count > 0:
flash(f'✅ Uploaded {uploaded_count} file(s) immediately. ⏳ {bg_msg}', 'info')
else:
flash(f'{bg_msg}', 'info')
elif playlist_id:
playlist = Playlist.query.get(playlist_id)
flash(f'Successfully uploaded {uploaded_count} file(s) to playlist "{playlist.name}"!', 'success')
else:
flash(f'Successfully uploaded {uploaded_count} file(s) to media library!', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error uploading media: {str(e)}')
flash('Error uploading media files.', 'danger')
return redirect(url_for('content.upload_media_page'))
@content_bp.route('/player/<int:player_id>/assign-playlist', methods=['POST'])
@login_required
def assign_player_to_playlist(player_id: int):
"""Assign a player to a playlist."""
player = Player.query.get_or_404(player_id)
try:
playlist_id = request.form.get('playlist_id', type=int)
if playlist_id:
playlist = Playlist.query.get_or_404(playlist_id)
player.playlist_id = playlist_id
log_action('info', f'Assigned player "{player.name}" to playlist "{playlist.name}"')
flash(f'Player "{player.name}" assigned to playlist "{playlist.name}".', 'success')
else:
player.playlist_id = None
log_action('info', f'Unassigned player "{player.name}" from playlist')
flash(f'Player "{player.name}" unassigned from playlist.', 'success')
db.session.commit()
cache.clear()
except Exception as e:
db.session.rollback()
log_action('error', f'Error assigning player to playlist: {str(e)}')
flash('Error assigning player to playlist.', 'danger')
return redirect(url_for('content.content_list'))