"""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, current_user 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 from app.utils.access import editor_required, can_edit_playlist, get_editable_playlist_ids # 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() editable_ids = get_editable_playlist_ids(current_user) return render_template('content/content_list_new.html', playlists=playlists, media_files=media_files, total_media_count=total_media_count, players=players, editable_ids=editable_ids) @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 @editor_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 @editor_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 @editor_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) can_edit = can_edit_playlist(current_user, 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, can_edit=can_edit) @content_bp.route('/playlist//add-content', methods=['POST']) @login_required def add_content_to_playlist(playlist_id: int): """Add content to playlist.""" if not can_edit_playlist(current_user, playlist_id): flash('You do not have permission to edit this playlist.', 'danger') return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id)) 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('/add-weblink', methods=['POST']) @login_required def add_weblink(): """Create a web link content item from the Upload Media page. Optionally adds it directly to a playlist if playlist_id is supplied. Returns JSON when the request carries Accept: application/json, otherwise redirects back to the upload page. """ use_json = 'application/json' in request.accept_mimetypes.best or \ request.headers.get('X-Requested-With') == 'XMLHttpRequest' 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 playlist_id = request.form.get('playlist_id', type=int) parsed = urlparse(web_url) if parsed.scheme.lower() not in ('http', 'https') or not parsed.netloc: if use_json: return jsonify({'success': False, 'error': 'Please enter a valid http:// or https:// web address.'}), 400 flash('Please enter a valid http:// or https:// web address.', 'warning') return redirect(url_for('content.upload_media_page')) if not duration or duration < 1: duration = 30 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() if playlist_id: playlist = Playlist.query.get(playlist_id) if 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() log_action('info', f'Web link "{web_url}" added to playlist "{playlist.name}"') else: log_action('warning', f'Web link "{web_url}" created; playlist {playlist_id} not found') else: log_action('info', f'Web link "{web_url}" added to media library') db.session.commit() cache.clear() if use_json: return jsonify({'success': True, 'content_id': content.id, 'message': 'Web link added successfully.'}) flash('Web link added successfully.', 'success') except Exception as e: db.session.rollback() log_action('error', f'Error adding web link: {str(e)}') if use_json: return jsonify({'success': False, 'error': 'Failed to add web link.'}), 500 flash('Error adding web link.', 'danger') return redirect(url_for('content.upload_media_page')) @content_bp.route('/playlist//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.""" if not can_edit_playlist(current_user, playlist_id): flash('You do not have permission to edit this playlist.', 'danger') return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id)) 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//remove-content/', methods=['POST']) @login_required def remove_content_from_playlist(playlist_id: int, content_id: int): """Remove content from playlist.""" if not can_edit_playlist(current_user, playlist_id): flash('You do not have permission to edit this playlist.', 'danger') return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id)) 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//bulk-remove', methods=['POST']) @login_required def bulk_remove_from_playlist(playlist_id: int): """Remove multiple content items from playlist.""" if not can_edit_playlist(current_user, playlist_id): return jsonify({'success': False, 'message': 'Permission denied'}), 403 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.""" if not can_edit_playlist(current_user, playlist_id): return jsonify({'success': False, 'message': 'Permission denied'}), 403 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('/playlist//update-duration/', 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//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'))