diff --git a/app/blueprints/content.py b/app/blueprints/content.py index acf8efb..7b4c4cd 100644 --- a/app/blueprints/content.py +++ b/app/blueprints/content.py @@ -3,7 +3,9 @@ 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 @@ -11,6 +13,9 @@ 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') @@ -267,6 +272,48 @@ def remove_content_from_playlist(playlist_id: int, content_id: int): 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): @@ -479,6 +526,176 @@ def process_pdf_file(filepath: str, filename: str) -> tuple[bool, str]: 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): + """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 + ) + db.session.execute(stmt) + + if playlist_id and page_files: + playlist = Playlist.query.get(playlist_id) + if playlist: + playlist.version += 1 + + db.session.commit() + + 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 + ) + db.session.execute(stmt) + + if playlist_id and slide_files: + playlist = Playlist.query.get(playlist_id) + if playlist: + playlist.version += 1 + + db.session.commit() + + 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 + ) + db.session.execute(stmt) + playlist.version += 1 + + db.session.commit() + _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: @@ -738,6 +955,7 @@ def upload_media(): os.makedirs(upload_folder, exist_ok=True) uploaded_count = 0 + background_count = 0 processing_errors = [] for file in files: @@ -759,6 +977,26 @@ def upload_media(): # 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) + ) + 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 = "" @@ -936,7 +1174,7 @@ def upload_media(): db.session.commit() cache.clear() - log_action('info', f'Uploaded {uploaded_count} media files') + log_action('info', f'Uploaded {uploaded_count} media files, {background_count} files processing in background') # Show appropriate flash message if processing_errors: @@ -944,6 +1182,14 @@ def upload_media(): 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') diff --git a/app/templates/content/manage_playlist_content.html b/app/templates/content/manage_playlist_content.html index f94ddca..581cd41 100644 --- a/app/templates/content/manage_playlist_content.html +++ b/app/templates/content/manage_playlist_content.html @@ -191,12 +191,20 @@
-

📋 Playlist Content (Drag to Reorder)

+
+

📋 Playlist Content (Drag to Reorder)

+ +
{% if playlist_content %} + @@ -209,6 +217,9 @@ {% for content in playlist_content %} + @@ -415,6 +426,82 @@ function toggleAudio(contentId, enabled) { if (checkbox) checkbox.checked = !enabled; }); } + +function toggleSelectAll(checkbox) { + const checkboxes = document.querySelectorAll('.content-checkbox'); + checkboxes.forEach(cb => { + cb.checked = checkbox.checked; + }); + updateBulkDeleteButton(); +} + +function updateBulkDeleteButton() { + const checkboxes = document.querySelectorAll('.content-checkbox:checked'); + const count = checkboxes.length; + const bulkDeleteBtn = document.getElementById('bulk-delete-btn'); + const selectedCount = document.getElementById('selected-count'); + + if (count > 0) { + bulkDeleteBtn.style.display = 'block'; + selectedCount.textContent = count; + } else { + bulkDeleteBtn.style.display = 'none'; + } + + // Update select-all checkbox state + const allCheckboxes = document.querySelectorAll('.content-checkbox'); + const selectAllCheckbox = document.getElementById('select-all'); + if (selectAllCheckbox) { + selectAllCheckbox.checked = allCheckboxes.length > 0 && count === allCheckboxes.length; + selectAllCheckbox.indeterminate = count > 0 && count < allCheckboxes.length; + } +} + +function bulkDeleteSelected() { + const checkboxes = document.querySelectorAll('.content-checkbox:checked'); + const contentIds = Array.from(checkboxes).map(cb => parseInt(cb.dataset.contentId)); + + if (contentIds.length === 0) { + alert('No items selected'); + return; + } + + const confirmMsg = `Are you sure you want to remove ${contentIds.length} item(s) from this playlist?`; + if (!confirm(confirmMsg)) { + return; + } + + // Show loading state + const bulkDeleteBtn = document.getElementById('bulk-delete-btn'); + const originalText = bulkDeleteBtn.innerHTML; + bulkDeleteBtn.disabled = true; + bulkDeleteBtn.innerHTML = '⏳ Removing...'; + + fetch('{{ url_for("content.bulk_remove_from_playlist", playlist_id=playlist.id) }}', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ content_ids: contentIds }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + // Reload page to show updated playlist + window.location.reload(); + } else { + alert('Error removing items: ' + data.message); + bulkDeleteBtn.disabled = false; + bulkDeleteBtn.innerHTML = originalText; + } + }) + .catch(error => { + console.error('Error:', error); + alert('Error removing items from playlist'); + bulkDeleteBtn.disabled = false; + bulkDeleteBtn.innerHTML = originalText; + }); +} {% endblock %}
+ + # Filename
+ + ⋮⋮ {{ loop.index }} {{ content.filename }}