updated upload page
This commit is contained in:
@@ -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/<int:playlist_id>/bulk-remove', methods=['POST'])
|
||||
@login_required
|
||||
def bulk_remove_from_playlist(playlist_id: int):
|
||||
"""Remove multiple content items from playlist."""
|
||||
playlist = Playlist.query.get_or_404(playlist_id)
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
content_ids = data.get('content_ids', [])
|
||||
|
||||
if not content_ids:
|
||||
return jsonify({'success': False, 'message': 'No content IDs provided'}), 400
|
||||
|
||||
from app.models.playlist import playlist_content
|
||||
|
||||
# Remove all selected items
|
||||
stmt = playlist_content.delete().where(
|
||||
(playlist_content.c.playlist_id == playlist_id) &
|
||||
(playlist_content.c.content_id.in_(content_ids))
|
||||
)
|
||||
result = db.session.execute(stmt)
|
||||
|
||||
# Increment version
|
||||
playlist.increment_version()
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
|
||||
removed_count = result.rowcount if hasattr(result, 'rowcount') else len(content_ids)
|
||||
log_action('info', f'Bulk removed {removed_count} items from playlist "{playlist.name}"')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Removed {removed_count} item(s) from playlist',
|
||||
'removed_count': removed_count
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error bulk removing from playlist: {str(e)}')
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@content_bp.route('/playlist/<int:playlist_id>/reorder', methods=['POST'])
|
||||
@login_required
|
||||
def reorder_playlist_content(playlist_id: int):
|
||||
@@ -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')
|
||||
|
||||
@@ -191,12 +191,20 @@
|
||||
|
||||
<div class="content-grid">
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom: 20px;">📋 Playlist Content (Drag to Reorder)</h2>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||
<h2 style="margin: 0;">📋 Playlist Content (Drag to Reorder)</h2>
|
||||
<button id="bulk-delete-btn" class="btn btn-danger" style="display: none;" onclick="bulkDeleteSelected()">
|
||||
🗑️ Delete Selected (<span id="selected-count">0</span>)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if playlist_content %}
|
||||
<table class="playlist-table" id="playlist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40px;">
|
||||
<input type="checkbox" id="select-all" onchange="toggleSelectAll(this)" title="Select all">
|
||||
</th>
|
||||
<th style="width: 40px;"></th>
|
||||
<th style="width: 50px;">#</th>
|
||||
<th>Filename</th>
|
||||
@@ -209,6 +217,9 @@
|
||||
<tbody id="playlist-tbody">
|
||||
{% for content in playlist_content %}
|
||||
<tr class="draggable-row" draggable="true" data-content-id="{{ content.id }}">
|
||||
<td>
|
||||
<input type="checkbox" class="content-checkbox" data-content-id="{{ content.id }}" onchange="updateBulkDeleteButton()">
|
||||
</td>
|
||||
<td><span class="drag-handle">⋮⋮</span></td>
|
||||
<td>{{ loop.index }}</td>
|
||||
<td>{{ content.filename }}</td>
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user