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)
|
flash, jsonify, current_app)
|
||||||
from flask_login import login_required
|
from flask_login import login_required
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
from typing import Optional
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
|
|
||||||
from app.extensions import db, cache
|
from app.extensions import db, cache
|
||||||
from app.models import Content, Playlist, Player
|
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.logger import log_action
|
||||||
from app.utils.uploads import process_video_file, set_upload_progress
|
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 = 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))
|
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'])
|
@content_bp.route('/playlist/<int:playlist_id>/reorder', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def reorder_playlist_content(playlist_id: int):
|
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)}"
|
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]:
|
def process_presentation_file(filepath: str, filename: str) -> tuple[bool, str]:
|
||||||
"""Process PowerPoint presentation files by converting slides to images."""
|
"""Process PowerPoint presentation files by converting slides to images."""
|
||||||
try:
|
try:
|
||||||
@@ -738,6 +955,7 @@ def upload_media():
|
|||||||
os.makedirs(upload_folder, exist_ok=True)
|
os.makedirs(upload_folder, exist_ok=True)
|
||||||
|
|
||||||
uploaded_count = 0
|
uploaded_count = 0
|
||||||
|
background_count = 0
|
||||||
processing_errors = []
|
processing_errors = []
|
||||||
|
|
||||||
for file in files:
|
for file in files:
|
||||||
@@ -759,6 +977,26 @@ def upload_media():
|
|||||||
# Determine content type from extension
|
# Determine content type from extension
|
||||||
file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
|
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
|
# Process file based on type
|
||||||
processing_success = True
|
processing_success = True
|
||||||
processing_message = ""
|
processing_message = ""
|
||||||
@@ -936,7 +1174,7 @@ def upload_media():
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
cache.clear()
|
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
|
# Show appropriate flash message
|
||||||
if processing_errors:
|
if processing_errors:
|
||||||
@@ -944,6 +1182,14 @@ def upload_media():
|
|||||||
if len(processing_errors) > 3:
|
if len(processing_errors) > 3:
|
||||||
error_summary += f' and {len(processing_errors) - 3} more...'
|
error_summary += f' and {len(processing_errors) - 3} more...'
|
||||||
flash(f'Uploaded {uploaded_count} file(s). Errors: {error_summary}', 'warning')
|
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:
|
elif playlist_id:
|
||||||
playlist = Playlist.query.get(playlist_id)
|
playlist = Playlist.query.get(playlist_id)
|
||||||
flash(f'Successfully uploaded {uploaded_count} file(s) to playlist "{playlist.name}"!', 'success')
|
flash(f'Successfully uploaded {uploaded_count} file(s) to playlist "{playlist.name}"!', 'success')
|
||||||
|
|||||||
@@ -191,12 +191,20 @@
|
|||||||
|
|
||||||
<div class="content-grid">
|
<div class="content-grid">
|
||||||
<div class="card">
|
<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 %}
|
{% if playlist_content %}
|
||||||
<table class="playlist-table" id="playlist-table">
|
<table class="playlist-table" id="playlist-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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: 40px;"></th>
|
||||||
<th style="width: 50px;">#</th>
|
<th style="width: 50px;">#</th>
|
||||||
<th>Filename</th>
|
<th>Filename</th>
|
||||||
@@ -209,6 +217,9 @@
|
|||||||
<tbody id="playlist-tbody">
|
<tbody id="playlist-tbody">
|
||||||
{% for content in playlist_content %}
|
{% for content in playlist_content %}
|
||||||
<tr class="draggable-row" draggable="true" data-content-id="{{ content.id }}">
|
<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><span class="drag-handle">⋮⋮</span></td>
|
||||||
<td>{{ loop.index }}</td>
|
<td>{{ loop.index }}</td>
|
||||||
<td>{{ content.filename }}</td>
|
<td>{{ content.filename }}</td>
|
||||||
@@ -415,6 +426,82 @@ function toggleAudio(contentId, enabled) {
|
|||||||
if (checkbox) checkbox.checked = !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>
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user