"""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 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//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//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//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 all_content = Content.query.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//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//remove-content/', 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) 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//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//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//update-muted/', 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//update-edit-enabled/', 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('/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//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'))