Add real-time upload progress tracking, mobile-optimized manage_group page with player status cards
Features: - Real-time upload progress tracking with AJAX polling and session-based monitoring - API endpoint /api/upload_progress/<session_id> for progress updates - Video conversion progress tracking with background threads - Mobile-responsive design for manage_group page - Player status cards with feedback, playlist sync, and last activity - Bootstrap Icons integration throughout UI - Responsive layout (1/4 group info, 3/4 players on desktop) - Video thumbnails with play icon, image thumbnails in media lists - Bulk selection and delete for group media - Enhanced logging for video conversion debugging
This commit is contained in:
94
app/app.py
94
app/app.py
@@ -106,6 +106,10 @@ login_manager.login_view = 'login'
|
|||||||
|
|
||||||
migrate = Migrate(app, db)
|
migrate = Migrate(app, db)
|
||||||
|
|
||||||
|
# Global dictionary to track upload progress
|
||||||
|
# Format: {session_id: {'status': 'uploading/converting/complete', 'progress': 0-100, 'message': 'details', 'files_total': N, 'files_processed': N}}
|
||||||
|
upload_progress = {}
|
||||||
|
|
||||||
@app.route('/api/player-feedback', methods=['POST'])
|
@app.route('/api/player-feedback', methods=['POST'])
|
||||||
def api_player_feedback():
|
def api_player_feedback():
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -259,35 +263,72 @@ def logout():
|
|||||||
@admin_required
|
@admin_required
|
||||||
def upload_content():
|
def upload_content():
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
|
import uuid
|
||||||
|
|
||||||
target_type = request.form.get('target_type')
|
target_type = request.form.get('target_type')
|
||||||
target_id = request.form.get('target_id')
|
target_id = request.form.get('target_id')
|
||||||
files = request.files.getlist('files')
|
files = request.files.getlist('files')
|
||||||
duration = int(request.form['duration'])
|
duration = int(request.form['duration'])
|
||||||
return_url = request.form.get('return_url')
|
return_url = request.form.get('return_url')
|
||||||
media_type = request.form['media_type']
|
media_type = request.form['media_type']
|
||||||
|
session_id = request.form.get('session_id', str(uuid.uuid4()))
|
||||||
|
|
||||||
print(f"Target Type: {target_type}, Target ID: {target_id}, Media Type: {media_type}")
|
print(f"Target Type: {target_type}, Target ID: {target_id}, Media Type: {media_type}, Session ID: {session_id}")
|
||||||
|
|
||||||
if not target_type or not target_id:
|
if not target_type or not target_id:
|
||||||
flash('Please select a target type and target ID.', 'danger')
|
flash('Please select a target type and target ID.', 'danger')
|
||||||
return redirect(url_for('upload_content'))
|
return redirect(url_for('upload_content'))
|
||||||
|
|
||||||
|
# Initialize progress tracking
|
||||||
|
upload_progress[session_id] = {
|
||||||
|
'status': 'uploading',
|
||||||
|
'progress': 0,
|
||||||
|
'message': 'Starting upload...',
|
||||||
|
'files_total': len(files),
|
||||||
|
'files_processed': 0
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Process uploaded files and get results
|
# Process uploaded files and get results
|
||||||
results = process_uploaded_files(app, files, media_type, duration, target_type, target_id)
|
results = process_uploaded_files(app, files, media_type, duration, target_type, target_id, upload_progress, session_id)
|
||||||
|
|
||||||
# Check for any failed uploads
|
# Check if video conversion is happening in background
|
||||||
failed_files = [r for r in results if not r.get('success', True)]
|
if media_type == 'video':
|
||||||
if failed_files:
|
# For videos, don't mark as complete yet - background thread will do it
|
||||||
for failed in failed_files:
|
# Status remains as "converting" set by the background thread
|
||||||
flash(f"Error uploading {failed.get('filename', 'unknown file')}: {failed.get('message', 'Unknown error')}", 'warning')
|
flash('Video upload started. Conversion is in progress...', 'info')
|
||||||
else:
|
else:
|
||||||
flash('All files uploaded and processed successfully!', 'success')
|
# For non-videos (images, PDF, PPT), mark as complete
|
||||||
|
upload_progress[session_id] = {
|
||||||
|
'status': 'complete',
|
||||||
|
'progress': 100,
|
||||||
|
'message': 'All files processed successfully!',
|
||||||
|
'files_total': len(files),
|
||||||
|
'files_processed': len(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check for any failed uploads
|
||||||
|
failed_files = [r for r in results if not r.get('success', True)]
|
||||||
|
if failed_files:
|
||||||
|
for failed in failed_files:
|
||||||
|
flash(f"Error uploading {failed.get('filename', 'unknown file')}: {failed.get('message', 'Unknown error')}", 'warning')
|
||||||
|
else:
|
||||||
|
flash('All files uploaded and processed successfully!', 'success')
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error in upload_content: {e}")
|
print(f"Error in upload_content: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
|
# Mark as error
|
||||||
|
upload_progress[session_id] = {
|
||||||
|
'status': 'error',
|
||||||
|
'progress': 0,
|
||||||
|
'message': f'Upload failed: {str(e)}',
|
||||||
|
'files_total': len(files),
|
||||||
|
'files_processed': 0
|
||||||
|
}
|
||||||
|
|
||||||
flash(f'Upload failed: {str(e)}', 'danger')
|
flash(f'Upload failed: {str(e)}', 'danger')
|
||||||
|
|
||||||
return redirect(return_url)
|
return redirect(return_url)
|
||||||
@@ -714,6 +755,22 @@ def get_playlists():
|
|||||||
def media(filename):
|
def media(filename):
|
||||||
return send_from_directory(app.config['UPLOAD_FOLDER'], filename)
|
return send_from_directory(app.config['UPLOAD_FOLDER'], filename)
|
||||||
|
|
||||||
|
@app.route('/api/upload_progress/<session_id>', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
def get_upload_progress(session_id):
|
||||||
|
"""
|
||||||
|
API endpoint to get upload/conversion progress for a session.
|
||||||
|
Returns JSON with status, progress percentage, and current message.
|
||||||
|
"""
|
||||||
|
progress_data = upload_progress.get(session_id, {
|
||||||
|
'status': 'unknown',
|
||||||
|
'progress': 0,
|
||||||
|
'message': 'No active upload found',
|
||||||
|
'files_total': 0,
|
||||||
|
'files_processed': 0
|
||||||
|
})
|
||||||
|
return jsonify(progress_data)
|
||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
def inject_theme():
|
def inject_theme():
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
@@ -740,13 +797,32 @@ def create_group():
|
|||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def manage_group(group_id):
|
def manage_group(group_id):
|
||||||
|
from models.player_feedback import PlayerFeedback
|
||||||
|
|
||||||
group = Group.query.get_or_404(group_id)
|
group = Group.query.get_or_404(group_id)
|
||||||
content = get_group_content(group_id)
|
content = get_group_content(group_id)
|
||||||
# Debug content ordering
|
# Debug content ordering
|
||||||
print("Group content positions before sorting:", [(c.id, c.file_name, c.position) for c in content])
|
print("Group content positions before sorting:", [(c.id, c.file_name, c.position) for c in content])
|
||||||
content = sorted(content, key=lambda c: c.position)
|
content = sorted(content, key=lambda c: c.position)
|
||||||
print("Group content positions after sorting:", [(c.id, c.file_name, c.position) for c in content])
|
print("Group content positions after sorting:", [(c.id, c.file_name, c.position) for c in content])
|
||||||
return render_template('manage_group.html', group=group, content=content)
|
|
||||||
|
# Fetch player feedback for all players in the group
|
||||||
|
players_status = []
|
||||||
|
for player in group.players:
|
||||||
|
player_feedback = PlayerFeedback.query.filter_by(player_name=player.username)\
|
||||||
|
.order_by(PlayerFeedback.timestamp.desc())\
|
||||||
|
.limit(5)\
|
||||||
|
.all()
|
||||||
|
players_status.append({
|
||||||
|
'player': player,
|
||||||
|
'feedback': player_feedback,
|
||||||
|
'server_playlist_version': player.playlist_version
|
||||||
|
})
|
||||||
|
|
||||||
|
return render_template('manage_group.html',
|
||||||
|
group=group,
|
||||||
|
content=content,
|
||||||
|
players_status=players_status)
|
||||||
|
|
||||||
@app.route('/group/<int:group_id>/edit', methods=['GET', 'POST'])
|
@app.route('/group/<int:group_id>/edit', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Manage Group</title>
|
<title>Manage Group - {{ group.name }}</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
body.dark-mode {
|
body.dark-mode {
|
||||||
background-color: #121212;
|
background-color: #121212;
|
||||||
@@ -17,16 +18,72 @@
|
|||||||
.dark-mode label, .dark-mode th, .dark-mode td {
|
.dark-mode label, .dark-mode th, .dark-mode td {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Logo styling */
|
||||||
|
.logo {
|
||||||
|
max-height: 80px;
|
||||||
|
margin-right: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile optimizations */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.logo {
|
||||||
|
max-height: 50px;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
h5 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
h6 {
|
||||||
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
.btn {
|
.btn {
|
||||||
font-size: 0.9rem;
|
font-size: 0.85rem;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.4rem 0.8rem;
|
||||||
|
}
|
||||||
|
.btn-sm {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
}
|
}
|
||||||
.card {
|
.card {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.card-body {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
/* Stack buttons vertically on mobile */
|
||||||
|
.action-buttons .btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
/* Smaller text on mobile */
|
||||||
|
small {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
/* Reduce padding in tables */
|
||||||
|
.list-group-item {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smaller screens - further optimization */
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.container-fluid {
|
||||||
|
padding-left: 10px;
|
||||||
|
padding-right: 10px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.card-header h5 {
|
||||||
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,38 +106,135 @@
|
|||||||
.drag-over {
|
.drag-over {
|
||||||
border-top: 2px solid #0d6efd;
|
border-top: 2px solid #0d6efd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Player status card compact design */
|
||||||
|
.player-status-card {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.player-status-card {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="{{ 'dark-mode' if theme == 'dark' else '' }}">
|
<body class="{{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||||
<div class="container py-5">
|
<div class="container-fluid py-3 py-md-4 py-lg-5">
|
||||||
<h1 class="text-center mb-4">Manage Group: {{ group.name }}</h1>
|
<!-- Header with Logo and Title -->
|
||||||
|
<div class="d-flex justify-content-start align-items-center mb-3 mb-md-4">
|
||||||
<!-- Group Information Card -->
|
{% if logo_exists %}
|
||||||
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
|
<img src="{{ url_for('static', filename='resurse/logo.png') }}" alt="Logo" class="logo">
|
||||||
<div class="card-header bg-info text-white">
|
{% endif %}
|
||||||
<h2>Group Info</h2>
|
<div>
|
||||||
</div>
|
<h1 class="mb-1">Manage Group</h1>
|
||||||
<div class="card-body">
|
<p class="text-muted mb-0 d-none d-md-block">{{ group.name }}</p>
|
||||||
<p><strong>Group Name:</strong> {{ group.name }}</p>
|
|
||||||
<p><strong>Number of Players:</strong> {{ group.players|length }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- List of Players in the Group -->
|
<!-- Mobile: Show group name if not shown in header -->
|
||||||
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
|
<div class="d-md-none mb-3">
|
||||||
<div class="card-header bg-secondary text-white">
|
<div class="badge bg-primary fs-6">{{ group.name }}</div>
|
||||||
<h2>Players in Group</h2>
|
</div>
|
||||||
|
|
||||||
|
<!-- Row with Group Info (left) and Players Status (right) -->
|
||||||
|
<div class="row mb-3 mb-md-4">
|
||||||
|
<!-- Group Information Card - Responsive width -->
|
||||||
|
<div class="col-lg-3 col-md-4 col-12 mb-3">
|
||||||
|
<div class="card h-100 {{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||||
|
<div class="card-header bg-info text-white">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-info-circle me-2"></i>Group Info</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-3">
|
||||||
|
<div class="mb-2">
|
||||||
|
<small class="text-muted">Group Name</small>
|
||||||
|
<p class="mb-0"><strong>{{ group.name }}</strong></p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<small class="text-muted">Players</small>
|
||||||
|
<p class="mb-0"><strong>{{ group.players|length }}</strong></p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-0">
|
||||||
|
<small class="text-muted">Playlist Version</small>
|
||||||
|
<p class="mb-0"><span class="badge bg-info mt-1">v{{ group.playlist_version }}</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
|
||||||
<ul class="list-group">
|
<!-- Players Status Cards Container - 3/4 width on large screens -->
|
||||||
{% for player in group.players %}
|
<div class="col-lg-9 col-md-8 col-12">
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
<div class="card {{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||||
<div>
|
<div class="card-header bg-success text-white">
|
||||||
<strong>{{ player.username }}</strong> ({{ player.hostname }})
|
<h5 class="mb-0"><i class="bi bi-display me-2"></i>Players ({{ group.players|length }})</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-2 p-md-3">
|
||||||
|
{% if players_status %}
|
||||||
|
<div class="row g-2 g-md-3">
|
||||||
|
{% for player_status in players_status %}
|
||||||
|
<div class="col-xl-4 col-lg-6 col-12 mb-2">
|
||||||
|
<div class="card h-100 border-primary player-status-card {{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||||
|
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center py-2">
|
||||||
|
<h6 class="mb-0"><i class="bi bi-tv me-1"></i>{{ player_status.player.username }}</h6>
|
||||||
|
<a href="{{ url_for('player_page', player_id=player_status.player.id) }}"
|
||||||
|
class="btn btn-sm btn-light py-0 px-2" title="View Details">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-2">
|
||||||
|
<div class="mb-2">
|
||||||
|
<small class="text-muted"><i class="bi bi-hdd-network me-1"></i>Hostname:</small>
|
||||||
|
<small class="d-block">{{ player_status.player.hostname }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if player_status.feedback %}
|
||||||
|
<div class="mb-2">
|
||||||
|
<small class="text-muted"><i class="bi bi-activity me-1"></i>Status:</small>
|
||||||
|
<span class="badge bg-{{ 'success' if player_status.feedback[0].status in ['active', 'playing'] else 'danger' }}">
|
||||||
|
{{ player_status.feedback[0].status|title }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<small class="text-muted"><i class="bi bi-clock me-1"></i>Last Activity:</small>
|
||||||
|
<small class="d-block">{{ player_status.feedback[0].timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</small>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<small class="text-muted"><i class="bi bi-chat-dots me-1"></i>Message:</small>
|
||||||
|
<small class="d-block text-muted">{{ player_status.feedback[0].message[:50] }}{% if player_status.feedback[0].message|length > 50 %}...{% endif %}</small>
|
||||||
|
</div>
|
||||||
|
<div class="mb-0">
|
||||||
|
<small class="text-muted"><i class="bi bi-list-check me-1"></i>Playlist:</small>
|
||||||
|
{% if player_status.feedback[0].playlist_version %}
|
||||||
|
{% if player_status.feedback[0].playlist_version|int == player_status.server_playlist_version %}
|
||||||
|
<span class="badge bg-success">v{{ player_status.feedback[0].playlist_version }} ✓</span>
|
||||||
|
<small class="text-success d-block">In sync</small>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-warning text-dark">v{{ player_status.feedback[0].playlist_version }}</span>
|
||||||
|
<small class="text-warning d-block">⚠ Out of sync (server: v{{ player_status.server_playlist_version }})</small>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Unknown</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center text-muted py-2">
|
||||||
|
<p class="mb-1"><small>No status data</small></p>
|
||||||
|
<small>Player hasn't reported yet</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
{% endfor %}
|
||||||
{% endfor %}
|
</div>
|
||||||
</ul>
|
{% else %}
|
||||||
|
<div class="text-center text-muted py-3">
|
||||||
|
<i class="bi bi-inbox display-4 d-block mb-2"></i>
|
||||||
|
<p>No players in this group</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -130,16 +284,23 @@
|
|||||||
|
|
||||||
<!-- Media Thumbnail and Name -->
|
<!-- Media Thumbnail and Name -->
|
||||||
<div class="flex-grow-1 mb-2 mb-md-0 d-flex align-items-center">
|
<div class="flex-grow-1 mb-2 mb-md-0 d-flex align-items-center">
|
||||||
<img src="{{ url_for('static', filename='uploads/' ~ media.file_name) }}"
|
{% set file_ext = media.file_name.lower().split('.')[-1] %}
|
||||||
alt="thumbnail"
|
{% if file_ext in ['mp4', 'avi', 'mkv', 'mov', 'webm'] %}
|
||||||
style="width: 48px; height: 48px; object-fit: cover; margin-right: 10px; border-radius: 4px;"
|
<!-- Video file - show generic video icon -->
|
||||||
onerror="this.style.display='none';">
|
<div style="width: 48px; height: 48px; margin-right: 10px; border-radius: 4px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center;">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8 5v14l11-7z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<!-- Image file - show actual thumbnail -->
|
||||||
|
<img src="{{ url_for('static', filename='uploads/' ~ media.file_name) }}"
|
||||||
|
alt="thumbnail"
|
||||||
|
style="width: 48px; height: 48px; object-fit: cover; margin-right: 10px; border-radius: 4px;"
|
||||||
|
onerror="this.style.display='none';">
|
||||||
|
{% endif %}
|
||||||
<p class="mb-0"><strong>Media Name:</strong> {{ media.file_name }}</p>
|
<p class="mb-0"><strong>Media Name:</strong> {{ media.file_name }}</p>
|
||||||
</div>
|
</div>
|
||||||
<<<<<<< HEAD
|
|
||||||
=======
|
|
||||||
|
|
||||||
>>>>>>> 2255cc2 (Show media thumbnails in manage group page, matching player page style)
|
|
||||||
<form action="{{ url_for('edit_group_media_route', group_id=group.id, content_id=media.id) }}" method="post" class="d-flex align-items-center">
|
<form action="{{ url_for('edit_group_media_route', group_id=group.id, content_id=media.id) }}" method="post" class="d-flex align-items-center">
|
||||||
<div class="input-group me-2">
|
<div class="input-group me-2">
|
||||||
<span class="input-group-text">seconds</span>
|
<span class="input-group-text">seconds</span>
|
||||||
@@ -162,12 +323,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Upload Media Button -->
|
<!-- Upload Media Button -->
|
||||||
<div class="text-center mb-4">
|
<div class="text-center mb-3 action-buttons">
|
||||||
<a href="{{ url_for('upload_content', target_type='group', target_id=group.id, return_url=url_for('manage_group', group_id=group.id)) }}" class="btn btn-primary btn-lg">Go to Upload Media</a>
|
<a href="{{ url_for('upload_content', target_type='group', target_id=group.id, return_url=url_for('manage_group', group_id=group.id)) }}"
|
||||||
|
class="btn btn-primary btn-lg">
|
||||||
|
<i class="bi bi-cloud-upload me-2"></i>Upload Media
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Back to Dashboard Button -->
|
<!-- Back to Dashboard Button -->
|
||||||
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">Back to Dashboard</a>
|
<div class="text-center mb-3">
|
||||||
|
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">
|
||||||
|
<i class="bi bi-arrow-left me-2"></i>Back to Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
|||||||
@@ -214,10 +214,22 @@
|
|||||||
|
|
||||||
<!-- Media Thumbnail and Name -->
|
<!-- Media Thumbnail and Name -->
|
||||||
<div class="flex-grow-1 mb-2 mb-md-0 d-flex align-items-center">
|
<div class="flex-grow-1 mb-2 mb-md-0 d-flex align-items-center">
|
||||||
<img src="{{ url_for('static', filename='uploads/' ~ media.file_name) }}"
|
{% set file_ext = media.file_name.lower().split('.')[-1] %}
|
||||||
alt="thumbnail"
|
{% if file_ext in ['mp4', 'avi', 'mkv', 'mov', 'webm'] %}
|
||||||
style="width: 48px; height: 48px; object-fit: cover; margin-right: 10px; border-radius: 4px;"
|
<!-- Video Icon for video files -->
|
||||||
onerror="this.style.display='none';">
|
<div style="width: 48px; height: 48px; margin-right: 10px; border-radius: 4px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center;">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="white" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
||||||
|
<path d="M6.271 5.055a.5.5 0 0 1 .52.038l3.5 2.5a.5.5 0 0 1 0 .814l-3.5 2.5A.5.5 0 0 1 6 10.5v-5a.5.5 0 0 1 .271-.445z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<!-- Image thumbnail for image files -->
|
||||||
|
<img src="{{ url_for('static', filename='uploads/' ~ media.file_name) }}"
|
||||||
|
alt="thumbnail"
|
||||||
|
style="width: 48px; height: 48px; object-fit: cover; margin-right: 10px; border-radius: 4px;"
|
||||||
|
onerror="this.style.display='none';">
|
||||||
|
{% endif %}
|
||||||
<p class="mb-0"><strong>Media Name:</strong> {{ media.file_name }}</p>
|
<p class="mb-0"><strong>Media Name:</strong> {{ media.file_name }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<h1 class="mb-0">Upload Content</h1>
|
<h1 class="mb-0">Upload Content</h1>
|
||||||
</div>
|
</div>
|
||||||
<form id="upload-form" action="{{ url_for('upload_content') }}" method="post" enctype="multipart/form-data" onsubmit="showStatusModal()">
|
<form id="upload-form" action="{{ url_for('upload_content') }}" method="post" enctype="multipart/form-data" onsubmit="handleFormSubmit(event)">
|
||||||
<input type="hidden" name="return_url" value="{{ return_url }}">
|
<input type="hidden" name="return_url" value="{{ return_url }}">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6 col-12">
|
<div class="col-md-6 col-12">
|
||||||
@@ -223,82 +223,127 @@
|
|||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
let progressInterval = null;
|
||||||
|
let sessionId = null;
|
||||||
|
let statusModal = null;
|
||||||
|
let returnUrl = '{{ return_url }}';
|
||||||
|
|
||||||
|
// Generate unique session ID for this upload
|
||||||
|
function generateSessionId() {
|
||||||
|
return 'upload_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFormSubmit(event) {
|
||||||
|
event.preventDefault(); // Prevent default form submission
|
||||||
|
|
||||||
|
// Generate session ID and add it to the form
|
||||||
|
sessionId = generateSessionId();
|
||||||
|
const form = document.getElementById('upload-form');
|
||||||
|
let sessionInput = document.getElementById('session_id_input');
|
||||||
|
if (!sessionInput) {
|
||||||
|
sessionInput = document.createElement('input');
|
||||||
|
sessionInput.type = 'hidden';
|
||||||
|
sessionInput.name = 'session_id';
|
||||||
|
sessionInput.id = 'session_id_input';
|
||||||
|
form.appendChild(sessionInput);
|
||||||
|
}
|
||||||
|
sessionInput.value = sessionId;
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
showStatusModal();
|
||||||
|
|
||||||
|
// Submit form via AJAX
|
||||||
|
const formData = new FormData(form);
|
||||||
|
|
||||||
|
fetch(form.action, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Upload failed');
|
||||||
|
}
|
||||||
|
console.log('Form submitted successfully');
|
||||||
|
// Don't redirect yet - keep polling until status is complete
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Form submission error:', error);
|
||||||
|
if (upload_progress && sessionId) {
|
||||||
|
upload_progress[sessionId] = {
|
||||||
|
'status': 'error',
|
||||||
|
'progress': 0,
|
||||||
|
'message': 'Upload failed: ' + error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function showStatusModal() {
|
function showStatusModal() {
|
||||||
console.log("Processing popup triggered");
|
console.log("Processing popup triggered");
|
||||||
const statusModal = new bootstrap.Modal(document.getElementById('statusModal'));
|
|
||||||
|
statusModal = new bootstrap.Modal(document.getElementById('statusModal'));
|
||||||
statusModal.show();
|
statusModal.show();
|
||||||
|
|
||||||
// Update status message based on media type
|
{% if system_info %}
|
||||||
const mediaType = document.getElementById('media_type').value;
|
// Start system monitoring updates
|
||||||
|
startModalSystemMonitoring();
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
// Start polling progress
|
||||||
|
pollUploadProgress();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollUploadProgress() {
|
||||||
const statusMessage = document.getElementById('status-message');
|
const statusMessage = document.getElementById('status-message');
|
||||||
const progressBar = document.getElementById('progress-bar');
|
const progressBar = document.getElementById('progress-bar');
|
||||||
let progress = 0;
|
|
||||||
|
// Poll every 500ms for real-time updates
|
||||||
if (mediaType === 'video') {
|
progressInterval = setInterval(() => {
|
||||||
statusMessage.textContent = 'Uploading video...';
|
fetch(`/api/upload_progress/${sessionId}`)
|
||||||
// Stage 1: Uploading (0-40%)
|
.then(response => response.json())
|
||||||
let uploadInterval = setInterval(() => {
|
.then(data => {
|
||||||
progress += 8;
|
console.log('Progress update:', data);
|
||||||
if (progress >= 40) {
|
|
||||||
clearInterval(uploadInterval);
|
// Update progress bar
|
||||||
progress = 40;
|
progressBar.style.width = `${data.progress}%`;
|
||||||
progressBar.style.width = `${progress}%`;
|
progressBar.setAttribute('aria-valuenow', data.progress);
|
||||||
progressBar.setAttribute('aria-valuenow', progress);
|
|
||||||
// Stage 2: Converting
|
// Update status message
|
||||||
statusMessage.textContent = 'Converting video to standard format (29.97 fps, 1080p)...';
|
statusMessage.textContent = data.message;
|
||||||
let convertProgress = 40;
|
|
||||||
let convertInterval = setInterval(() => {
|
// If complete or error, stop polling and enable close button
|
||||||
convertProgress += 3;
|
if (data.status === 'complete' || data.status === 'error') {
|
||||||
progressBar.style.width = `${convertProgress}%`;
|
clearInterval(progressInterval);
|
||||||
progressBar.setAttribute('aria-valuenow', convertProgress);
|
progressInterval = null;
|
||||||
if (convertProgress >= 100) {
|
|
||||||
clearInterval(convertInterval);
|
{% if system_info %}
|
||||||
statusMessage.textContent = 'Video uploaded and converted successfully!';
|
stopModalSystemMonitoring();
|
||||||
// Stop system monitoring updates
|
{% endif %}
|
||||||
{% if system_info %}
|
|
||||||
stopModalSystemMonitoring();
|
const closeBtn = document.querySelector('[data-bs-dismiss="modal"]');
|
||||||
{% endif %}
|
closeBtn.disabled = false;
|
||||||
document.querySelector('[data-bs-dismiss="modal"]').disabled = false;
|
|
||||||
|
// Change progress bar color based on status
|
||||||
|
if (data.status === 'complete') {
|
||||||
|
progressBar.classList.remove('progress-bar-animated');
|
||||||
|
progressBar.classList.add('bg-success');
|
||||||
|
|
||||||
|
// Auto-close after 2 seconds and redirect
|
||||||
|
setTimeout(() => {
|
||||||
|
statusModal.hide();
|
||||||
|
window.location.href = returnUrl;
|
||||||
|
}, 2000);
|
||||||
|
} else if (data.status === 'error') {
|
||||||
|
progressBar.classList.remove('progress-bar-animated', 'progress-bar-striped');
|
||||||
|
progressBar.classList.add('bg-danger');
|
||||||
}
|
}
|
||||||
}, 600);
|
}
|
||||||
} else {
|
})
|
||||||
progressBar.style.width = `${progress}%`;
|
.catch(error => {
|
||||||
progressBar.setAttribute('aria-valuenow', progress);
|
console.error('Error fetching progress:', error);
|
||||||
}
|
statusMessage.textContent = 'Error tracking upload progress';
|
||||||
}, 400);
|
});
|
||||||
} else {
|
}, 500); // Poll every 500ms
|
||||||
// Default for other media types
|
|
||||||
switch(mediaType) {
|
|
||||||
case 'image':
|
|
||||||
statusMessage.textContent = 'Uploading images...';
|
|
||||||
break;
|
|
||||||
case 'pdf':
|
|
||||||
statusMessage.textContent = 'Converting PDF to 4K images. This may take a while...';
|
|
||||||
break;
|
|
||||||
case 'ppt':
|
|
||||||
statusMessage.textContent = 'Converting PowerPoint to images (PPTX → PDF → Images). This may take 2-5 minutes...';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
statusMessage.textContent = 'Uploading and processing your files. Please wait...';
|
|
||||||
}
|
|
||||||
// Simulate progress updates
|
|
||||||
let interval = setInterval(() => {
|
|
||||||
const increment = (mediaType === 'image') ? 20 : 5;
|
|
||||||
progress += increment;
|
|
||||||
if (progress >= 100) {
|
|
||||||
clearInterval(interval);
|
|
||||||
statusMessage.textContent = 'Files uploaded and processed successfully!';
|
|
||||||
{% if system_info %}
|
|
||||||
stopModalSystemMonitoring();
|
|
||||||
{% endif %}
|
|
||||||
document.querySelector('[data-bs-dismiss="modal"]').disabled = false;
|
|
||||||
} else {
|
|
||||||
progressBar.style.width = `${progress}%`;
|
|
||||||
progressBar.setAttribute('aria-valuenow', progress);
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
{% if system_info %}
|
{% if system_info %}
|
||||||
|
|||||||
@@ -55,39 +55,162 @@ def convert_video(input_file, output_folder):
|
|||||||
print(f"Video conversion skipped for: {input_file}")
|
print(f"Video conversion skipped for: {input_file}")
|
||||||
return input_file
|
return input_file
|
||||||
|
|
||||||
def convert_video_and_update_playlist(app, file_path, original_filename, target_type, target_id, duration):
|
def convert_video_and_update_playlist(app, file_path, original_filename, target_type, target_id, duration, upload_progress=None, session_id=None, file_index=0, total_files=1):
|
||||||
|
"""
|
||||||
|
Convert video to Raspberry Pi optimized format, then add to playlist.
|
||||||
|
This ensures players only download optimized videos.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
upload_progress (dict): Global progress tracking dictionary
|
||||||
|
session_id (str): Unique session identifier for progress tracking
|
||||||
|
file_index (int): Current file index being processed
|
||||||
|
total_files (int): Total number of files being processed
|
||||||
|
"""
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
print(f"Starting video normalization for: {file_path}")
|
print(f"Starting video optimization for Raspberry Pi: {file_path}")
|
||||||
# Only process .mp4 files
|
|
||||||
if not file_path.lower().endswith('.mp4'):
|
# Update progress - conversion starting
|
||||||
print(f"Skipping non-mp4 file: {file_path}")
|
if upload_progress and session_id:
|
||||||
|
print(f"[VIDEO CONVERSION] Setting initial progress for session {session_id}")
|
||||||
|
upload_progress[session_id] = {
|
||||||
|
'status': 'converting',
|
||||||
|
'progress': 40,
|
||||||
|
'message': f'Optimizing video for Raspberry Pi (30fps, H.264)...',
|
||||||
|
'files_total': total_files,
|
||||||
|
'files_processed': file_index
|
||||||
|
}
|
||||||
|
print(f"[VIDEO CONVERSION] Progress set: {upload_progress[session_id]}")
|
||||||
|
else:
|
||||||
|
print(f"[VIDEO CONVERSION] WARNING: upload_progress or session_id is None!")
|
||||||
|
|
||||||
|
# Only process video files
|
||||||
|
if not file_path.lower().endswith(('.mp4', '.avi', '.mkv', '.mov', '.webm')):
|
||||||
|
print(f"Skipping non-video file: {file_path}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Prepare temp output file
|
# Prepare temp output file
|
||||||
temp_dir = tempfile.gettempdir()
|
temp_dir = tempfile.gettempdir()
|
||||||
temp_output = os.path.join(temp_dir, f"normalized_{os.path.basename(file_path)}")
|
temp_output = os.path.join(temp_dir, f"optimized_{os.path.basename(file_path)}")
|
||||||
|
|
||||||
|
# Enhanced ffmpeg command for Raspberry Pi optimization
|
||||||
ffmpeg_cmd = [
|
ffmpeg_cmd = [
|
||||||
'ffmpeg', '-y', '-i', file_path,
|
'ffmpeg', '-y', '-i', file_path,
|
||||||
'-c:v', 'libx264', '-profile:v', 'main',
|
'-c:v', 'libx264', # H.264 codec
|
||||||
# Bitrate is not forced, so we allow lower bitrates
|
'-preset', 'medium', # Balanced encoding speed/quality
|
||||||
'-vf', 'scale=1920:1080,fps=29.97',
|
'-profile:v', 'main', # Main profile for compatibility
|
||||||
'-c:a', 'copy',
|
'-crf', '23', # Constant quality (23 is good balance)
|
||||||
|
'-maxrate', '8M', # Max bitrate 8Mbps
|
||||||
|
'-bufsize', '12M', # Buffer size
|
||||||
|
'-vf', 'scale=\'min(1920,iw)\':\'min(1080,ih)\':force_original_aspect_ratio=decrease,fps=30', # Scale down if needed, 30fps
|
||||||
|
'-r', '30', # Output framerate 30fps
|
||||||
|
'-c:a', 'aac', # AAC audio codec
|
||||||
|
'-b:a', '128k', # Audio bitrate 128kbps
|
||||||
|
'-movflags', '+faststart', # Enable fast start for web streaming
|
||||||
temp_output
|
temp_output
|
||||||
]
|
]
|
||||||
print(f"Running ffmpeg: {' '.join(ffmpeg_cmd)}")
|
|
||||||
|
print(f"Running ffmpeg optimization: {' '.join(ffmpeg_cmd)}")
|
||||||
|
print(f"Settings: 1920x1080 max, 30fps, H.264, 8Mbps max bitrate")
|
||||||
|
|
||||||
|
# Update progress - conversion in progress
|
||||||
|
if upload_progress and session_id:
|
||||||
|
upload_progress[session_id]['progress'] = 50
|
||||||
|
upload_progress[session_id]['message'] = 'Converting video (this may take a few minutes)...'
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(ffmpeg_cmd, capture_output=True, text=True, timeout=1800)
|
result = subprocess.run(ffmpeg_cmd, capture_output=True, text=True, timeout=1800)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
print(f"ffmpeg error: {result.stderr}")
|
print(f"ffmpeg error: {result.stderr}")
|
||||||
|
print(f"Video conversion failed for: {original_filename}")
|
||||||
|
|
||||||
|
# Update progress - error
|
||||||
|
if upload_progress and session_id:
|
||||||
|
upload_progress[session_id] = {
|
||||||
|
'status': 'error',
|
||||||
|
'progress': 0,
|
||||||
|
'message': f'Video conversion failed: {original_filename}',
|
||||||
|
'files_total': total_files,
|
||||||
|
'files_processed': file_index
|
||||||
|
}
|
||||||
|
|
||||||
|
# Delete the unconverted file
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
os.remove(file_path)
|
||||||
|
print(f"Removed unconverted video file: {file_path}")
|
||||||
return None
|
return None
|
||||||
# Replace original file with normalized one
|
|
||||||
|
# Update progress - replacing file
|
||||||
|
if upload_progress and session_id:
|
||||||
|
upload_progress[session_id]['progress'] = 80
|
||||||
|
upload_progress[session_id]['message'] = 'Saving optimized video and adding to playlist...'
|
||||||
|
|
||||||
|
# Replace original file with optimized one
|
||||||
shutil.move(temp_output, file_path)
|
shutil.move(temp_output, file_path)
|
||||||
print(f"Video normalized and replaced: {file_path}")
|
print(f"Video optimized and replaced: {file_path}")
|
||||||
|
print(f"Video is now optimized for Raspberry Pi playback (30fps, max 1080p)")
|
||||||
|
|
||||||
|
# NOW add to playlist after successful conversion
|
||||||
|
with app.app_context():
|
||||||
|
if target_type == 'group':
|
||||||
|
group = Group.query.get_or_404(target_id)
|
||||||
|
for player in group.players:
|
||||||
|
new_content = Content(file_name=original_filename, duration=duration, player_id=player.id)
|
||||||
|
db.session.add(new_content)
|
||||||
|
player.playlist_version += 1
|
||||||
|
group.playlist_version += 1
|
||||||
|
print(f"Video added to group '{group.name}' playlist after optimization")
|
||||||
|
elif target_type == 'player':
|
||||||
|
player = Player.query.get_or_404(target_id)
|
||||||
|
new_content = Content(file_name=original_filename, duration=duration, player_id=target_id)
|
||||||
|
db.session.add(new_content)
|
||||||
|
player.playlist_version += 1
|
||||||
|
print(f"Video added to player '{player.username}' playlist after optimization")
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
print(f"Playlist updated with optimized video: {original_filename}")
|
||||||
|
|
||||||
|
# Update progress - complete
|
||||||
|
if upload_progress and session_id:
|
||||||
|
print(f"[VIDEO CONVERSION] Video conversion complete! Updating progress for session {session_id}")
|
||||||
|
upload_progress[session_id] = {
|
||||||
|
'status': 'complete',
|
||||||
|
'progress': 100,
|
||||||
|
'message': f'Video conversion complete! Added to playlist.',
|
||||||
|
'files_total': total_files,
|
||||||
|
'files_processed': file_index + 1
|
||||||
|
}
|
||||||
|
print(f"[VIDEO CONVERSION] Final progress: {upload_progress[session_id]}")
|
||||||
|
else:
|
||||||
|
print(f"[VIDEO CONVERSION] WARNING: Cannot update completion status - upload_progress or session_id is None!")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error during video normalization: {e}")
|
print(f"[VIDEO CONVERSION] ERROR during video optimization: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
# Update progress - error
|
||||||
|
if upload_progress and session_id:
|
||||||
|
print(f"[VIDEO CONVERSION] Setting error status for session {session_id}")
|
||||||
|
upload_progress[session_id] = {
|
||||||
|
'status': 'error',
|
||||||
|
'progress': 0,
|
||||||
|
'message': f'Error during video conversion: {str(e)}',
|
||||||
|
'files_total': total_files,
|
||||||
|
'files_processed': file_index
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
print(f"[VIDEO CONVERSION] WARNING: Cannot update error status - upload_progress or session_id is None!")
|
||||||
|
|
||||||
|
# Delete the unconverted file on error
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
os.remove(file_path)
|
||||||
|
print(f"Removed unconverted video file due to error: {file_path}")
|
||||||
return None
|
return None
|
||||||
# No need to update playlist, as filename remains the same
|
|
||||||
|
# Filename remains the same
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# PDF conversion functions
|
# PDF conversion functions
|
||||||
@@ -188,7 +311,7 @@ def update_playlist_with_files(image_filenames, duration, target_type, target_id
|
|||||||
print(f"Error updating playlist: {e}")
|
print(f"Error updating playlist: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def process_pdf(input_file, output_folder, duration, target_type, target_id):
|
def process_pdf(input_file, output_folder, duration, target_type, target_id, upload_progress=None, session_id=None, file_index=0, total_files=1):
|
||||||
"""
|
"""
|
||||||
Process a PDF file: convert to images and update playlist.
|
Process a PDF file: convert to images and update playlist.
|
||||||
|
|
||||||
@@ -198,6 +321,10 @@ def process_pdf(input_file, output_folder, duration, target_type, target_id):
|
|||||||
duration (int): Duration in seconds for each image
|
duration (int): Duration in seconds for each image
|
||||||
target_type (str): 'player' or 'group'
|
target_type (str): 'player' or 'group'
|
||||||
target_id (int): ID of the player or group
|
target_id (int): ID of the player or group
|
||||||
|
upload_progress (dict): Global progress tracking dictionary
|
||||||
|
session_id (str): Unique session identifier for progress tracking
|
||||||
|
file_index (int): Current file index being processed
|
||||||
|
total_files (int): Total number of files being processed
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if successful, False otherwise
|
bool: True if successful, False otherwise
|
||||||
@@ -205,6 +332,16 @@ def process_pdf(input_file, output_folder, duration, target_type, target_id):
|
|||||||
print(f"Processing PDF file: {input_file}")
|
print(f"Processing PDF file: {input_file}")
|
||||||
print(f"Output folder: {output_folder}")
|
print(f"Output folder: {output_folder}")
|
||||||
|
|
||||||
|
# Update progress - starting PDF conversion
|
||||||
|
if upload_progress and session_id:
|
||||||
|
upload_progress[session_id] = {
|
||||||
|
'status': 'converting',
|
||||||
|
'progress': 50,
|
||||||
|
'message': f'Converting PDF to images (300 DPI)...',
|
||||||
|
'files_total': total_files,
|
||||||
|
'files_processed': file_index
|
||||||
|
}
|
||||||
|
|
||||||
# Ensure output folder exists
|
# Ensure output folder exists
|
||||||
if not os.path.exists(output_folder):
|
if not os.path.exists(output_folder):
|
||||||
os.makedirs(output_folder, exist_ok=True)
|
os.makedirs(output_folder, exist_ok=True)
|
||||||
@@ -213,17 +350,42 @@ def process_pdf(input_file, output_folder, duration, target_type, target_id):
|
|||||||
# Convert PDF to images using standard quality (delete PDF after successful conversion)
|
# Convert PDF to images using standard quality (delete PDF after successful conversion)
|
||||||
image_filenames = convert_pdf_to_images(input_file, output_folder, delete_pdf=True, dpi=300)
|
image_filenames = convert_pdf_to_images(input_file, output_folder, delete_pdf=True, dpi=300)
|
||||||
|
|
||||||
|
# Update progress - adding to playlist
|
||||||
|
if upload_progress and session_id:
|
||||||
|
upload_progress[session_id]['progress'] = 80
|
||||||
|
upload_progress[session_id]['message'] = f'Adding {len(image_filenames)} images to playlist...'
|
||||||
|
|
||||||
# Update playlist with generated images
|
# Update playlist with generated images
|
||||||
if image_filenames:
|
if image_filenames:
|
||||||
success = update_playlist_with_files(image_filenames, duration, target_type, target_id)
|
success = update_playlist_with_files(image_filenames, duration, target_type, target_id)
|
||||||
if success:
|
if success:
|
||||||
print(f"Successfully processed PDF: {len(image_filenames)} images added to playlist")
|
print(f"Successfully processed PDF: {len(image_filenames)} images added to playlist")
|
||||||
|
|
||||||
|
# Update progress - complete
|
||||||
|
if upload_progress and session_id:
|
||||||
|
upload_progress[session_id] = {
|
||||||
|
'status': 'complete',
|
||||||
|
'progress': 100,
|
||||||
|
'message': f'PDF converted to {len(image_filenames)} images and added to playlist!',
|
||||||
|
'files_total': total_files,
|
||||||
|
'files_processed': file_index + 1
|
||||||
|
}
|
||||||
return success
|
return success
|
||||||
else:
|
else:
|
||||||
print("Failed to convert PDF to images")
|
print("Failed to convert PDF to images")
|
||||||
|
|
||||||
|
# Update progress - error
|
||||||
|
if upload_progress and session_id:
|
||||||
|
upload_progress[session_id] = {
|
||||||
|
'status': 'error',
|
||||||
|
'progress': 0,
|
||||||
|
'message': 'Failed to convert PDF to images',
|
||||||
|
'files_total': total_files,
|
||||||
|
'files_processed': file_index
|
||||||
|
}
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def process_pptx(input_file, output_folder, duration, target_type, target_id):
|
def process_pptx(input_file, output_folder, duration, target_type, target_id, upload_progress=None, session_id=None, file_index=0, total_files=1):
|
||||||
"""
|
"""
|
||||||
Process a PPTX file: convert to PDF first, then to JPG images (same workflow as PDF).
|
Process a PPTX file: convert to PDF first, then to JPG images (same workflow as PDF).
|
||||||
|
|
||||||
@@ -233,6 +395,10 @@ def process_pptx(input_file, output_folder, duration, target_type, target_id):
|
|||||||
duration (int): Duration in seconds for each image
|
duration (int): Duration in seconds for each image
|
||||||
target_type (str): 'player' or 'group'
|
target_type (str): 'player' or 'group'
|
||||||
target_id (int): ID of the player or group
|
target_id (int): ID of the player or group
|
||||||
|
upload_progress (dict): Global progress tracking dictionary
|
||||||
|
session_id (str): Unique session identifier for progress tracking
|
||||||
|
file_index (int): Current file index being processed
|
||||||
|
total_files (int): Total number of files being processed
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if successful, False otherwise
|
bool: True if successful, False otherwise
|
||||||
@@ -240,6 +406,16 @@ def process_pptx(input_file, output_folder, duration, target_type, target_id):
|
|||||||
print(f"Processing PPTX file using PDF workflow: {input_file}")
|
print(f"Processing PPTX file using PDF workflow: {input_file}")
|
||||||
print(f"Output folder: {output_folder}")
|
print(f"Output folder: {output_folder}")
|
||||||
|
|
||||||
|
# Update progress - starting PPTX conversion (step 1)
|
||||||
|
if upload_progress and session_id:
|
||||||
|
upload_progress[session_id] = {
|
||||||
|
'status': 'converting',
|
||||||
|
'progress': 40,
|
||||||
|
'message': f'Converting PowerPoint to PDF (Step 1/3)...',
|
||||||
|
'files_total': total_files,
|
||||||
|
'files_processed': file_index
|
||||||
|
}
|
||||||
|
|
||||||
# Ensure output folder exists
|
# Ensure output folder exists
|
||||||
if not os.path.exists(output_folder):
|
if not os.path.exists(output_folder):
|
||||||
os.makedirs(output_folder, exist_ok=True)
|
os.makedirs(output_folder, exist_ok=True)
|
||||||
@@ -258,10 +434,25 @@ def process_pptx(input_file, output_folder, duration, target_type, target_id):
|
|||||||
print("- Corrupted PPTX file")
|
print("- Corrupted PPTX file")
|
||||||
print("- Insufficient memory")
|
print("- Insufficient memory")
|
||||||
print("- File permission issues")
|
print("- File permission issues")
|
||||||
|
|
||||||
|
# Update progress - error
|
||||||
|
if upload_progress and session_id:
|
||||||
|
upload_progress[session_id] = {
|
||||||
|
'status': 'error',
|
||||||
|
'progress': 0,
|
||||||
|
'message': 'Failed to convert PowerPoint to PDF',
|
||||||
|
'files_total': total_files,
|
||||||
|
'files_processed': file_index
|
||||||
|
}
|
||||||
return False
|
return False
|
||||||
|
|
||||||
print(f"PPTX successfully converted to PDF: {pdf_file}")
|
print(f"PPTX successfully converted to PDF: {pdf_file}")
|
||||||
|
|
||||||
|
# Update progress - step 2
|
||||||
|
if upload_progress and session_id:
|
||||||
|
upload_progress[session_id]['progress'] = 60
|
||||||
|
upload_progress[session_id]['message'] = 'Converting PDF to images (Step 2/3, 300 DPI)...'
|
||||||
|
|
||||||
# Step 2: Use the same PDF to images workflow as direct PDF uploads
|
# Step 2: Use the same PDF to images workflow as direct PDF uploads
|
||||||
print("Step 2: Converting PDF to JPG images...")
|
print("Step 2: Converting PDF to JPG images...")
|
||||||
# Convert PDF to JPG images (300 DPI, same as PDF workflow)
|
# Convert PDF to JPG images (300 DPI, same as PDF workflow)
|
||||||
@@ -274,6 +465,16 @@ def process_pptx(input_file, output_folder, duration, target_type, target_id):
|
|||||||
print("- PDF corruption during conversion")
|
print("- PDF corruption during conversion")
|
||||||
print("- Insufficient disk space")
|
print("- Insufficient disk space")
|
||||||
print("- Memory issues during image processing")
|
print("- Memory issues during image processing")
|
||||||
|
|
||||||
|
# Update progress - error
|
||||||
|
if upload_progress and session_id:
|
||||||
|
upload_progress[session_id] = {
|
||||||
|
'status': 'error',
|
||||||
|
'progress': 0,
|
||||||
|
'message': 'Failed to convert PDF to images',
|
||||||
|
'files_total': total_files,
|
||||||
|
'files_processed': file_index
|
||||||
|
}
|
||||||
return False
|
return False
|
||||||
|
|
||||||
print(f"Generated {len(image_filenames)} JPG images from PPTX → PDF")
|
print(f"Generated {len(image_filenames)} JPG images from PPTX → PDF")
|
||||||
@@ -283,11 +484,26 @@ def process_pptx(input_file, output_folder, duration, target_type, target_id):
|
|||||||
os.remove(input_file)
|
os.remove(input_file)
|
||||||
print(f"Original PPTX file deleted: {input_file}")
|
print(f"Original PPTX file deleted: {input_file}")
|
||||||
|
|
||||||
|
# Update progress - step 3
|
||||||
|
if upload_progress and session_id:
|
||||||
|
upload_progress[session_id]['progress'] = 85
|
||||||
|
upload_progress[session_id]['message'] = f'Adding {len(image_filenames)} images to playlist (Step 3/3)...'
|
||||||
|
|
||||||
# Step 4: Update playlist with generated images in sequential order
|
# Step 4: Update playlist with generated images in sequential order
|
||||||
print("Step 3: Adding images to playlist...")
|
print("Step 3: Adding images to playlist...")
|
||||||
success = update_playlist_with_files(image_filenames, duration, target_type, target_id)
|
success = update_playlist_with_files(image_filenames, duration, target_type, target_id)
|
||||||
if success:
|
if success:
|
||||||
print(f"Successfully processed PPTX: {len(image_filenames)} images added to playlist")
|
print(f"Successfully processed PPTX: {len(image_filenames)} images added to playlist")
|
||||||
|
|
||||||
|
# Update progress - complete
|
||||||
|
if upload_progress and session_id:
|
||||||
|
upload_progress[session_id] = {
|
||||||
|
'status': 'complete',
|
||||||
|
'progress': 100,
|
||||||
|
'message': f'PowerPoint converted to {len(image_filenames)} images and added to playlist!',
|
||||||
|
'files_total': total_files,
|
||||||
|
'files_processed': file_index + 1
|
||||||
|
}
|
||||||
else:
|
else:
|
||||||
print("Error: Failed to add images to playlist database")
|
print("Error: Failed to add images to playlist database")
|
||||||
return success
|
return success
|
||||||
@@ -298,10 +514,14 @@ def process_pptx(input_file, output_folder, duration, target_type, target_id):
|
|||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def process_uploaded_files(app, files, media_type, duration, target_type, target_id):
|
def process_uploaded_files(app, files, media_type, duration, target_type, target_id, upload_progress=None, session_id=None):
|
||||||
"""
|
"""
|
||||||
Process uploaded files based on media type and add them to playlists.
|
Process uploaded files based on media type and add them to playlists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
upload_progress (dict): Global progress tracking dictionary
|
||||||
|
session_id (str): Unique session identifier for progress tracking
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list: List of result dictionaries with success status and messages
|
list: List of result dictionaries with success status and messages
|
||||||
"""
|
"""
|
||||||
@@ -316,8 +536,21 @@ def process_uploaded_files(app, files, media_type, duration, target_type, target
|
|||||||
player = Player.query.get_or_404(target_id)
|
player = Player.query.get_or_404(target_id)
|
||||||
target_name = player.username
|
target_name = player.username
|
||||||
|
|
||||||
for file in files:
|
total_files = len(files)
|
||||||
|
|
||||||
|
for file_index, file in enumerate(files):
|
||||||
try:
|
try:
|
||||||
|
# Update progress - uploading phase
|
||||||
|
if upload_progress and session_id:
|
||||||
|
file_progress = int((file_index / total_files) * 30) # 0-30% for file uploads
|
||||||
|
upload_progress[session_id] = {
|
||||||
|
'status': 'uploading',
|
||||||
|
'progress': file_progress,
|
||||||
|
'message': f'Uploading file {file_index + 1}/{total_files}: {file.filename}...',
|
||||||
|
'files_total': total_files,
|
||||||
|
'files_processed': file_index
|
||||||
|
}
|
||||||
|
|
||||||
# Generate a secure filename and save the file
|
# Generate a secure filename and save the file
|
||||||
filename = secure_filename(file.filename)
|
filename = secure_filename(file.filename)
|
||||||
|
|
||||||
@@ -338,37 +571,54 @@ def process_uploaded_files(app, files, media_type, duration, target_type, target
|
|||||||
result = {'filename': filename, 'success': True, 'message': ''}
|
result = {'filename': filename, 'success': True, 'message': ''}
|
||||||
|
|
||||||
if media_type == 'image':
|
if media_type == 'image':
|
||||||
|
if upload_progress and session_id:
|
||||||
|
upload_progress[session_id]['message'] = f'Adding image {file_index + 1}/{total_files} to playlist...'
|
||||||
|
upload_progress[session_id]['progress'] = int(30 + (file_index / total_files) * 70)
|
||||||
|
|
||||||
add_image_to_playlist(app, file, filename, duration, target_type, target_id)
|
add_image_to_playlist(app, file, filename, duration, target_type, target_id)
|
||||||
result['message'] = f"Image {filename} added to playlist"
|
result['message'] = f"Image {filename} added to playlist"
|
||||||
log_upload('image', filename, target_type, target_id)
|
log_upload('image', filename, target_type, target_id)
|
||||||
|
|
||||||
elif media_type == 'video':
|
elif media_type == 'video':
|
||||||
# For videos, add to playlist then start conversion in background
|
# For videos, save file then start conversion in background
|
||||||
if target_type == 'group':
|
# Video will be added to playlist AFTER conversion completes
|
||||||
group = Group.query.get_or_404(target_id)
|
print(f"Video uploaded: {filename}")
|
||||||
for player in group.players:
|
print(f"Starting background optimization - video will be added to playlist when ready")
|
||||||
new_content = Content(file_name=filename, duration=duration, player_id=player.id)
|
|
||||||
db.session.add(new_content)
|
if upload_progress and session_id:
|
||||||
player.playlist_version += 1
|
upload_progress[session_id] = {
|
||||||
group.playlist_version += 1
|
'status': 'converting',
|
||||||
elif target_type == 'player':
|
'progress': 40,
|
||||||
player = Player.query.get_or_404(target_id)
|
'message': f'Converting video {file_index + 1}/{total_files} to 30fps (this may take a few minutes)...',
|
||||||
new_content = Content(file_name=filename, duration=duration, player_id=target_id)
|
'files_total': total_files,
|
||||||
db.session.add(new_content)
|
'files_processed': file_index
|
||||||
player.playlist_version += 1
|
}
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
# Start background conversion using absolute path
|
# Start background conversion using absolute path
|
||||||
import threading
|
import threading
|
||||||
threading.Thread(target=convert_video_and_update_playlist,
|
print(f"[VIDEO UPLOAD] Starting background thread for video conversion. Session ID: {session_id}")
|
||||||
args=(app, file_path, filename, target_type, target_id, duration)).start()
|
print(f"[VIDEO UPLOAD] Parameters: file_path={file_path}, filename={filename}, target={target_type}/{target_id}")
|
||||||
result['message'] = f"Video {filename} added to playlist and being processed"
|
thread = threading.Thread(target=convert_video_and_update_playlist,
|
||||||
|
args=(app, file_path, filename, target_type, target_id, duration, upload_progress, session_id, file_index, total_files))
|
||||||
|
thread.daemon = True # Make thread daemon so it doesn't block shutdown
|
||||||
|
thread.start()
|
||||||
|
print(f"[VIDEO UPLOAD] Background thread started: {thread.name}")
|
||||||
|
result['message'] = f"Video {filename} is being optimized for Raspberry Pi (30fps, max 1080p). It will be added to playlist when ready."
|
||||||
log_upload('video', filename, target_type, target_id)
|
log_upload('video', filename, target_type, target_id)
|
||||||
|
|
||||||
elif media_type == 'pdf':
|
elif media_type == 'pdf':
|
||||||
|
if upload_progress and session_id:
|
||||||
|
upload_progress[session_id] = {
|
||||||
|
'status': 'converting',
|
||||||
|
'progress': 40,
|
||||||
|
'message': f'Converting PDF {file_index + 1}/{total_files} to images...',
|
||||||
|
'files_total': total_files,
|
||||||
|
'files_processed': file_index
|
||||||
|
}
|
||||||
|
|
||||||
# For PDFs, convert to images and update playlist using absolute path
|
# For PDFs, convert to images and update playlist using absolute path
|
||||||
success = process_pdf(file_path, upload_folder,
|
success = process_pdf(file_path, upload_folder,
|
||||||
duration, target_type, target_id)
|
duration, target_type, target_id, upload_progress, session_id, file_index, total_files)
|
||||||
if success:
|
if success:
|
||||||
result['message'] = f"PDF {filename} processed successfully"
|
result['message'] = f"PDF {filename} processed successfully"
|
||||||
log_process('pdf', filename, target_type, target_id)
|
log_process('pdf', filename, target_type, target_id)
|
||||||
@@ -377,9 +627,18 @@ def process_uploaded_files(app, files, media_type, duration, target_type, target
|
|||||||
result['message'] = f"Error processing PDF file: {filename}"
|
result['message'] = f"Error processing PDF file: {filename}"
|
||||||
|
|
||||||
elif media_type == 'ppt':
|
elif media_type == 'ppt':
|
||||||
|
if upload_progress and session_id:
|
||||||
|
upload_progress[session_id] = {
|
||||||
|
'status': 'converting',
|
||||||
|
'progress': 30,
|
||||||
|
'message': f'Converting PowerPoint {file_index + 1}/{total_files} to images (PPTX → PDF → Images, may take 2-5 minutes)...',
|
||||||
|
'files_total': total_files,
|
||||||
|
'files_processed': file_index
|
||||||
|
}
|
||||||
|
|
||||||
# For PPT/PPTX, convert to PDF, then to images, and update playlist using absolute path
|
# For PPT/PPTX, convert to PDF, then to images, and update playlist using absolute path
|
||||||
success = process_pptx(file_path, upload_folder,
|
success = process_pptx(file_path, upload_folder,
|
||||||
duration, target_type, target_id)
|
duration, target_type, target_id, upload_progress, session_id, file_index, total_files)
|
||||||
if success:
|
if success:
|
||||||
result['message'] = f"PowerPoint {filename} processed successfully"
|
result['message'] = f"PowerPoint {filename} processed successfully"
|
||||||
log_process('ppt', filename, target_type, target_id)
|
log_process('ppt', filename, target_type, target_id)
|
||||||
|
|||||||
Reference in New Issue
Block a user