updated app start
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,4 @@
|
||||
digiscreen/
|
||||
venv/
|
||||
.env
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
555
app.py
555
app.py
@@ -1,17 +1,25 @@
|
||||
import os
|
||||
import click
|
||||
import time
|
||||
import psutil
|
||||
import threading
|
||||
from flask import Flask, render_template, request, redirect, url_for, session, flash, jsonify, send_from_directory
|
||||
from flask_migrate import Migrate
|
||||
import subprocess
|
||||
from werkzeug.utils import secure_filename
|
||||
from functools import wraps
|
||||
from functools import wraps, lru_cache
|
||||
from extensions import db, bcrypt, login_manager
|
||||
from sqlalchemy import text
|
||||
from dotenv import load_dotenv
|
||||
import logging
|
||||
import gc
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
# Configure logging for better performance monitoring
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
|
||||
# First import models
|
||||
from models import User, Player, Group, Content, ServerLog, group_player
|
||||
|
||||
@@ -38,7 +46,7 @@ from utils.uploads import (
|
||||
add_image_to_playlist,
|
||||
convert_video_and_update_playlist,
|
||||
process_pdf,
|
||||
process_pptx,
|
||||
process_pptx_improved,
|
||||
process_uploaded_files
|
||||
)
|
||||
|
||||
@@ -57,8 +65,18 @@ db_path = os.path.join(instance_dir, 'dashboard.db')
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}'
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
|
||||
# Set maximum content length to 1GB
|
||||
app.config['MAX_CONTENT_LENGTH'] = 2048 * 2048 * 2048 # 2GB, adjust as needed
|
||||
# Performance configuration
|
||||
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
|
||||
'pool_pre_ping': True,
|
||||
'pool_recycle': 300,
|
||||
'connect_args': {'timeout': 10}
|
||||
}
|
||||
|
||||
# Set maximum content length to 1GB (reduced from 2GB)
|
||||
app.config['MAX_CONTENT_LENGTH'] = 1024 * 1024 * 1024 # 1GB
|
||||
|
||||
# Add timeout configuration
|
||||
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 31536000 # Cache static files for 1 year
|
||||
|
||||
# Ensure the instance folder exists
|
||||
os.makedirs(app.instance_path, exist_ok=True)
|
||||
@@ -314,6 +332,7 @@ def add_player():
|
||||
orientation = request.form.get('orientation', 'Landscape') # <-- Get orientation
|
||||
add_player_util(username, hostname, password, quickconnect_password, orientation) # <-- Pass orientation
|
||||
flash(f'Player "{username}" added successfully.', 'success')
|
||||
clear_player_cache() # Clear cache when player is added
|
||||
return redirect(url_for('dashboard'))
|
||||
return render_template('add_player.html')
|
||||
|
||||
@@ -330,6 +349,7 @@ def edit_player(player_id):
|
||||
orientation = request.form.get('orientation', player.orientation) # <-- Get orientation
|
||||
edit_player_util(player_id, username, hostname, password, quickconnect_password, orientation) # <-- Pass orientation
|
||||
flash(f'Player "{username}" updated successfully.', 'success')
|
||||
clear_player_cache() # Clear cache when player is updated
|
||||
return redirect(url_for('player_page', player_id=player.id))
|
||||
|
||||
return_url = request.args.get('return_url', url_for('player_page', player_id=player.id))
|
||||
@@ -344,6 +364,103 @@ def change_theme():
|
||||
db.session.commit()
|
||||
return redirect(url_for('admin'))
|
||||
|
||||
# Group management routes
|
||||
@app.route('/group/create', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def create_group():
|
||||
if request.method == 'POST':
|
||||
name = request.form['name']
|
||||
player_ids = request.form.getlist('players')
|
||||
orientation = request.form.get('orientation', 'Landscape')
|
||||
|
||||
try:
|
||||
# Convert player_ids to integers
|
||||
player_ids = [int(pid) for pid in player_ids]
|
||||
group = create_group_util(name, player_ids, orientation)
|
||||
flash(f'Group "{name}" created successfully.', 'success')
|
||||
return redirect(url_for('dashboard'))
|
||||
except ValueError as e:
|
||||
flash(str(e), 'danger')
|
||||
return redirect(url_for('create_group'))
|
||||
|
||||
# GET request - show create group form
|
||||
players = Player.query.filter_by(locked_to_group_id=None).all() # Only available players
|
||||
return render_template('create_group.html', players=players)
|
||||
|
||||
@app.route('/group/<int:group_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def edit_group(group_id):
|
||||
group = Group.query.get_or_404(group_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
name = request.form['name']
|
||||
player_ids = request.form.getlist('players')
|
||||
orientation = request.form.get('orientation', group.orientation)
|
||||
|
||||
try:
|
||||
# Convert player_ids to integers
|
||||
player_ids = [int(pid) for pid in player_ids]
|
||||
edit_group_util(group_id, name, player_ids, orientation)
|
||||
flash(f'Group "{name}" updated successfully.', 'success')
|
||||
return redirect(url_for('dashboard'))
|
||||
except ValueError as e:
|
||||
flash(str(e), 'danger')
|
||||
return redirect(url_for('edit_group', group_id=group_id))
|
||||
|
||||
# GET request - show edit group form
|
||||
players = Player.query.all()
|
||||
return render_template('edit_group.html', group=group, players=players)
|
||||
|
||||
@app.route('/group/<int:group_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_group(group_id):
|
||||
delete_group_util(group_id)
|
||||
flash('Group deleted successfully.', 'success')
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
@app.route('/group/<int:group_id>')
|
||||
@login_required
|
||||
def manage_group(group_id):
|
||||
group = Group.query.get_or_404(group_id)
|
||||
content = get_group_content(group_id)
|
||||
return render_template('manage_group.html', group=group, content=content)
|
||||
|
||||
@app.route('/group/<int:group_id>/fullscreen', methods=['GET', 'POST'])
|
||||
def group_fullscreen(group_id):
|
||||
group = Group.query.get_or_404(group_id)
|
||||
content = get_group_content(group_id)
|
||||
return render_template('group_fullscreen.html', group=group, content=content)
|
||||
|
||||
@app.route('/group/<int:group_id>/media/<int:content_id>/edit', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def edit_group_media(group_id, content_id):
|
||||
new_duration = int(request.form['duration'])
|
||||
success = edit_group_media(group_id, content_id, new_duration)
|
||||
|
||||
if success:
|
||||
flash('Media duration updated successfully.', 'success')
|
||||
else:
|
||||
flash('Error updating media duration.', 'danger')
|
||||
|
||||
return redirect(url_for('manage_group', group_id=group_id))
|
||||
|
||||
@app.route('/group/<int:group_id>/media/<int:content_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_group_media(group_id, content_id):
|
||||
success = delete_group_media(group_id, content_id)
|
||||
|
||||
if success:
|
||||
flash('Media deleted successfully.', 'success')
|
||||
else:
|
||||
flash('Error deleting media.', 'danger')
|
||||
|
||||
return redirect(url_for('manage_group', group_id=group_id))
|
||||
|
||||
@app.route('/upload_logo', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
@@ -414,177 +531,103 @@ def clean_unused_files():
|
||||
flash('Unused files have been cleaned.', 'success')
|
||||
return redirect(url_for('admin'))
|
||||
|
||||
# Cache for frequently accessed data
|
||||
@lru_cache(maxsize=128)
|
||||
def get_player_by_hostname(hostname):
|
||||
"""Cached function to get player by hostname"""
|
||||
return Player.query.filter_by(hostname=hostname).first()
|
||||
|
||||
# Clear cache when players are modified
|
||||
def clear_player_cache():
|
||||
get_player_by_hostname.cache_clear()
|
||||
|
||||
# Optimized API endpoint with caching
|
||||
@app.route('/api/playlists', methods=['GET'])
|
||||
def get_playlists():
|
||||
hostname = request.args.get('hostname')
|
||||
quickconnect_code = request.args.get('quickconnect_code')
|
||||
|
||||
# Validate the parameters
|
||||
# Validate parameters early
|
||||
if not hostname or not quickconnect_code:
|
||||
return jsonify({'error': 'Hostname and quick connect code are required'}), 400
|
||||
|
||||
# Find the player by hostname and verify the quickconnect code
|
||||
player = Player.query.filter_by(hostname=hostname).first()
|
||||
if not player or not bcrypt.check_password_hash(player.quickconnect_password, quickconnect_code):
|
||||
return jsonify({'error': 'Invalid hostname or quick connect code'}), 404
|
||||
try:
|
||||
# Use cached function for better performance
|
||||
player = get_player_by_hostname(hostname)
|
||||
if not player:
|
||||
return jsonify({'error': 'Player not found'}), 404
|
||||
|
||||
# Verify quickconnect code
|
||||
if not bcrypt.check_password_hash(player.quickconnect_password, quickconnect_code):
|
||||
return jsonify({'error': 'Invalid credentials'}), 401
|
||||
|
||||
# Check if player is locked to a group
|
||||
if player.locked_to_group_id:
|
||||
# Get content for all players in the group to ensure shared content
|
||||
group_players = player.locked_to_group.players
|
||||
player_ids = [p.id for p in group_players]
|
||||
# Optimized content query
|
||||
if player.locked_to_group_id:
|
||||
# More efficient group content query
|
||||
content = db.session.query(Content).join(Player).filter(
|
||||
Player.locked_to_group_id == player.locked_to_group_id
|
||||
).distinct(Content.file_name).order_by(Content.position).all()
|
||||
else:
|
||||
# Get player's individual content with limit
|
||||
content = Content.query.filter_by(player_id=player.id).order_by(Content.position).all()
|
||||
|
||||
# Use the first occurrence of each file for the playlist
|
||||
content_query = (
|
||||
db.session.query(
|
||||
Content.file_name,
|
||||
db.func.min(Content.id).label('id'),
|
||||
db.func.min(Content.duration).label('duration')
|
||||
)
|
||||
.filter(Content.player_id.in_(player_ids))
|
||||
.group_by(Content.file_name)
|
||||
)
|
||||
# Build playlist efficiently
|
||||
playlist = []
|
||||
for media in content:
|
||||
playlist.append({
|
||||
'file_name': media.file_name,
|
||||
'url': f"http://{request.host}/media/{media.file_name}",
|
||||
'duration': media.duration
|
||||
})
|
||||
|
||||
# Force garbage collection for memory management
|
||||
gc.collect()
|
||||
|
||||
return jsonify({
|
||||
'playlist': playlist,
|
||||
'playlist_version': player.playlist_version,
|
||||
'hashed_quickconnect': player.quickconnect_password
|
||||
})
|
||||
|
||||
content = db.session.query(Content).filter(
|
||||
Content.id.in_([c.id for c in content_query])
|
||||
).all()
|
||||
else:
|
||||
# Get player's individual content
|
||||
content = Content.query.filter_by(player_id=player.id).all()
|
||||
|
||||
playlist = [
|
||||
{
|
||||
'file_name': media.file_name,
|
||||
'url': f"http://{request.host}/media/{media.file_name}",
|
||||
'duration': media.duration
|
||||
}
|
||||
for media in content
|
||||
]
|
||||
|
||||
# Return the playlist, version, and hashed quickconnect code
|
||||
return jsonify({
|
||||
'playlist': playlist,
|
||||
'playlist_version': player.playlist_version,
|
||||
'hashed_quickconnect': player.quickconnect_password
|
||||
})
|
||||
except Exception as e:
|
||||
app.logger.error(f"API Error: {str(e)}")
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
# Optimized media serving with proper caching
|
||||
@app.route('/media/<path:filename>')
|
||||
def media(filename):
|
||||
return send_from_directory(app.config['UPLOAD_FOLDER'], filename)
|
||||
|
||||
@app.context_processor
|
||||
def inject_theme():
|
||||
if current_user.is_authenticated:
|
||||
theme = current_user.theme
|
||||
else:
|
||||
theme = 'light'
|
||||
return dict(theme=theme)
|
||||
|
||||
@app.route('/group/create', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def create_group():
|
||||
if request.method == 'POST':
|
||||
group_name = request.form['name']
|
||||
player_ids = request.form.getlist('players')
|
||||
orientation = request.form.get('orientation', 'Landscape')
|
||||
create_group_util(group_name, player_ids, orientation)
|
||||
flash(f'Group "{group_name}" created successfully.', 'success')
|
||||
return redirect(url_for('dashboard'))
|
||||
players = Player.query.all()
|
||||
return render_template('create_group.html', players=players)
|
||||
|
||||
@app.route('/group/<int:group_id>/manage')
|
||||
@login_required
|
||||
@admin_required
|
||||
def manage_group(group_id):
|
||||
group = Group.query.get_or_404(group_id)
|
||||
content = get_group_content(group_id)
|
||||
# Debug content ordering
|
||||
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)
|
||||
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)
|
||||
|
||||
@app.route('/group/<int:group_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def edit_group(group_id):
|
||||
group = Group.query.get_or_404(group_id)
|
||||
if request.method == 'POST':
|
||||
name = request.form['name']
|
||||
player_ids = request.form.getlist('players')
|
||||
orientation = request.form.get('orientation', group.orientation)
|
||||
edit_group_util(group_id, name, player_ids, orientation)
|
||||
flash(f'Group "{name}" updated successfully.', 'success')
|
||||
return redirect(url_for('dashboard'))
|
||||
players = Player.query.all()
|
||||
return render_template('edit_group.html', group=group, players=players)
|
||||
|
||||
@app.route('/group/<int:group_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_group(group_id):
|
||||
group = Group.query.get_or_404(group_id)
|
||||
group_name = group.name
|
||||
delete_group_util(group_id)
|
||||
flash(f'Group "{group_name}" deleted successfully.', 'success')
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
@app.route('/group/<int:group_id>/fullscreen', methods=['GET'])
|
||||
@login_required
|
||||
def group_fullscreen(group_id):
|
||||
group = Group.query.get_or_404(group_id)
|
||||
content = Content.query.filter(Content.player_id.in_([player.id for player in group.players])).order_by(Content.position).all()
|
||||
return render_template('group_fullscreen.html', group=group, content=content)
|
||||
|
||||
@app.route('/group/<int:group_id>/media/<int:content_id>/edit', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def edit_group_media_route(group_id, content_id):
|
||||
new_duration = int(request.form['duration'])
|
||||
success = edit_group_media(group_id, content_id, new_duration)
|
||||
|
||||
if success:
|
||||
flash('Media duration updated successfully.', 'success')
|
||||
else:
|
||||
flash('Error updating media duration.', 'danger')
|
||||
|
||||
return redirect(url_for('manage_group', group_id=group_id))
|
||||
|
||||
@app.route('/group/<int:group_id>/media/<int:content_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_group_media_route(group_id, content_id):
|
||||
success = delete_group_media(group_id, content_id)
|
||||
|
||||
if success:
|
||||
flash('Media deleted successfully.', 'success')
|
||||
else:
|
||||
flash('Error deleting media.', 'danger')
|
||||
|
||||
return redirect(url_for('manage_group', group_id=group_id))
|
||||
try:
|
||||
response = send_from_directory(app.config['UPLOAD_FOLDER'], filename)
|
||||
# Add caching headers for better performance
|
||||
response.cache_control.max_age = 86400 # Cache for 24 hours
|
||||
response.cache_control.public = True
|
||||
return response
|
||||
except Exception as e:
|
||||
app.logger.error(f"Media serving error: {str(e)}")
|
||||
return jsonify({'error': 'File not found'}), 404
|
||||
|
||||
# Optimized playlist version check
|
||||
@app.route('/api/playlist_version', methods=['GET'])
|
||||
def get_playlist_version():
|
||||
hostname = request.args.get('hostname')
|
||||
quickconnect_code = request.args.get('quickconnect_code')
|
||||
|
||||
# Validate the parameters
|
||||
if not hostname or not quickconnect_code:
|
||||
return jsonify({'error': 'Hostname and quick connect code are required'}), 400
|
||||
|
||||
# Find the player by hostname and verify the quickconnect code
|
||||
player = Player.query.filter_by(hostname=hostname).first()
|
||||
if not player or not bcrypt.check_password_hash(player.quickconnect_password, quickconnect_code):
|
||||
return jsonify({'error': 'Invalid hostname or quick connect code'}), 404
|
||||
try:
|
||||
# Use cached function
|
||||
player = get_player_by_hostname(hostname)
|
||||
if not player or not bcrypt.check_password_hash(player.quickconnect_password, quickconnect_code):
|
||||
return jsonify({'error': 'Invalid credentials'}), 401
|
||||
|
||||
# Return the playlist version and hashed quickconnect code
|
||||
return jsonify({
|
||||
'playlist_version': player.playlist_version,
|
||||
'hashed_quickconnect': player.quickconnect_password
|
||||
})
|
||||
return jsonify({
|
||||
'playlist_version': player.playlist_version,
|
||||
'hashed_quickconnect': player.quickconnect_password
|
||||
})
|
||||
except Exception as e:
|
||||
app.logger.error(f"Version check error: {str(e)}")
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
@app.route('/player/<int:player_id>/update_order', methods=['POST'])
|
||||
@login_required
|
||||
@@ -660,6 +703,218 @@ if not app.debug or os.environ.get('WERKZEUG_RUN_MAIN') == 'true':
|
||||
db.create_all()
|
||||
create_default_user(db, User, bcrypt)
|
||||
|
||||
# Performance monitoring functions
|
||||
def get_system_stats():
|
||||
"""Get current system performance statistics"""
|
||||
try:
|
||||
cpu_percent = psutil.cpu_percent(interval=1)
|
||||
memory = psutil.virtual_memory()
|
||||
disk = psutil.disk_usage('/')
|
||||
|
||||
return {
|
||||
'cpu_percent': cpu_percent,
|
||||
'memory_percent': memory.percent,
|
||||
'memory_used_mb': memory.used / (1024 * 1024),
|
||||
'memory_total_mb': memory.total / (1024 * 1024),
|
||||
'disk_percent': disk.percent,
|
||||
'disk_used_gb': disk.used / (1024 * 1024 * 1024),
|
||||
'disk_total_gb': disk.total / (1024 * 1024 * 1024),
|
||||
'timestamp': time.time()
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error getting system stats: {e}")
|
||||
return None
|
||||
|
||||
# Performance monitoring endpoint
|
||||
@app.route('/api/performance', methods=['GET'])
|
||||
@login_required
|
||||
def get_performance_stats():
|
||||
"""API endpoint to get real-time performance statistics"""
|
||||
stats = get_system_stats()
|
||||
if stats:
|
||||
return jsonify(stats)
|
||||
else:
|
||||
return jsonify({'error': 'Unable to get system stats'}), 500
|
||||
|
||||
# Enhanced upload endpoint with monitoring
|
||||
@app.route('/upload_content_monitored', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def upload_content_monitored():
|
||||
"""Enhanced upload endpoint with performance monitoring"""
|
||||
start_time = time.time()
|
||||
start_stats = get_system_stats()
|
||||
|
||||
target_type = request.form.get('target_type')
|
||||
target_id = request.form.get('target_id')
|
||||
files = request.files.getlist('files')
|
||||
duration = int(request.form['duration'])
|
||||
return_url = request.form.get('return_url')
|
||||
media_type = request.form['media_type']
|
||||
|
||||
print(f"=== UPLOAD MONITORING START ===")
|
||||
print(f"Target Type: {target_type}, Target ID: {target_id}, Media Type: {media_type}")
|
||||
print(f"Number of files: {len(files)}")
|
||||
print(f"Start CPU: {start_stats['cpu_percent']}%, Memory: {start_stats['memory_percent']}%")
|
||||
|
||||
if not target_type or not target_id:
|
||||
flash('Please select a target type and target ID.', 'danger')
|
||||
return redirect(url_for('upload_content'))
|
||||
|
||||
# Monitor during file processing
|
||||
def monitor_upload():
|
||||
"""Background monitoring thread"""
|
||||
while True:
|
||||
stats = get_system_stats()
|
||||
if stats:
|
||||
print(f"[MONITOR] CPU: {stats['cpu_percent']}%, Memory: {stats['memory_percent']}%, Time: {time.time() - start_time:.1f}s")
|
||||
time.sleep(2)
|
||||
|
||||
# Start monitoring thread
|
||||
monitor_thread = threading.Thread(target=monitor_upload, daemon=True)
|
||||
monitor_thread.start()
|
||||
|
||||
# Process uploaded files and get results
|
||||
results = process_uploaded_files(app, files, media_type, duration, target_type, target_id)
|
||||
|
||||
end_time = time.time()
|
||||
end_stats = get_system_stats()
|
||||
total_time = end_time - start_time
|
||||
|
||||
print(f"=== UPLOAD MONITORING END ===")
|
||||
print(f"Total processing time: {total_time:.2f} seconds")
|
||||
print(f"End CPU: {end_stats['cpu_percent']}%, Memory: {end_stats['memory_percent']}%")
|
||||
print(f"CPU change: {end_stats['cpu_percent'] - start_stats['cpu_percent']:.1f}%")
|
||||
print(f"Memory change: {end_stats['memory_percent'] - start_stats['memory_percent']:.1f}%")
|
||||
|
||||
# Log performance metrics
|
||||
log_action(f"Upload completed: {len(files)} files, {total_time:.2f}s, CPU: {start_stats['cpu_percent']}% → {end_stats['cpu_percent']}%")
|
||||
|
||||
return redirect(return_url)
|
||||
@app.route('/player/<int:player_id>/bulk_delete_content', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def bulk_delete_player_content(player_id):
|
||||
"""Bulk delete content for a specific player"""
|
||||
if not request.is_json:
|
||||
return jsonify({'success': False, 'error': 'Invalid request format'}), 400
|
||||
|
||||
player = Player.query.get_or_404(player_id)
|
||||
content_ids = request.json.get('content_ids', [])
|
||||
|
||||
if not content_ids:
|
||||
return jsonify({'success': False, 'error': 'No content IDs provided'}), 400
|
||||
|
||||
try:
|
||||
# Get all content items to delete
|
||||
content_items = Content.query.filter(
|
||||
Content.id.in_(content_ids),
|
||||
Content.player_id == player_id
|
||||
).all()
|
||||
|
||||
if not content_items:
|
||||
return jsonify({'success': False, 'error': 'No valid content found to delete'}), 404
|
||||
|
||||
# Delete the content items
|
||||
deleted_count = 0
|
||||
for content in content_items:
|
||||
# Delete the actual file from filesystem
|
||||
file_path = os.path.join(app.config['UPLOAD_FOLDER'], content.file_name)
|
||||
if os.path.exists(file_path):
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except OSError as e:
|
||||
app.logger.warning(f"Could not delete file {file_path}: {e}")
|
||||
|
||||
# Delete from database
|
||||
db.session.delete(content)
|
||||
deleted_count += 1
|
||||
|
||||
# Update playlist version for the player
|
||||
player.playlist_version += 1
|
||||
db.session.commit()
|
||||
|
||||
# Clear cache
|
||||
clear_player_cache()
|
||||
|
||||
# Log the action
|
||||
log_action(f"Bulk deleted {deleted_count} content items from player {player.username}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'deleted_count': deleted_count,
|
||||
'new_playlist_version': player.playlist_version
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
app.logger.error(f"Error in bulk delete: {str(e)}")
|
||||
return jsonify({'success': False, 'error': 'Database error occurred'}), 500
|
||||
|
||||
@app.route('/group/<int:group_id>/bulk_delete_content', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def bulk_delete_group_content(group_id):
|
||||
"""Bulk delete content for a specific group"""
|
||||
if not request.is_json:
|
||||
return jsonify({'success': False, 'error': 'Invalid request format'}), 400
|
||||
|
||||
group = Group.query.get_or_404(group_id)
|
||||
content_ids = request.json.get('content_ids', [])
|
||||
|
||||
if not content_ids:
|
||||
return jsonify({'success': False, 'error': 'No content IDs provided'}), 400
|
||||
|
||||
try:
|
||||
# Get player IDs in the group
|
||||
player_ids = [p.id for p in group.players]
|
||||
|
||||
# Get all content items to delete that belong to players in this group
|
||||
content_items = Content.query.filter(
|
||||
Content.id.in_(content_ids),
|
||||
Content.player_id.in_(player_ids)
|
||||
).all()
|
||||
|
||||
if not content_items:
|
||||
return jsonify({'success': False, 'error': 'No valid content found to delete'}), 404
|
||||
|
||||
# Delete the content items
|
||||
deleted_count = 0
|
||||
for content in content_items:
|
||||
# Delete the actual file from filesystem
|
||||
file_path = os.path.join(app.config['UPLOAD_FOLDER'], content.file_name)
|
||||
if os.path.exists(file_path):
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except OSError as e:
|
||||
app.logger.warning(f"Could not delete file {file_path}: {e}")
|
||||
|
||||
# Delete from database
|
||||
db.session.delete(content)
|
||||
deleted_count += 1
|
||||
|
||||
# Update playlist version for all players in the group
|
||||
for player in group.players:
|
||||
player.playlist_version += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Clear cache
|
||||
clear_player_cache()
|
||||
|
||||
# Log the action
|
||||
log_action(f"Bulk deleted {deleted_count} content items from group {group.name}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'deleted_count': deleted_count
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
app.logger.error(f"Error in group bulk delete: {str(e)}")
|
||||
return jsonify({'success': False, 'error': 'Database error occurred'}), 500
|
||||
|
||||
# Add this at the end of app.py
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True, host='0.0.0.0', port=5000)
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
#version: '"1.1.0"'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
image: digiserver:latest
|
||||
image: digi-server:latest
|
||||
ports:
|
||||
- "8880:5000"
|
||||
- "80:5000"
|
||||
environment:
|
||||
- FLASK_APP=app.py
|
||||
- FLASK_RUN_HOST=0.0.0.0
|
||||
- ADMIN_USER=admin
|
||||
- ADMIN_PASSWORD=Initial01!
|
||||
- SECRET_KEY=Ma_Duc_Dupa_Merele_Lui_Ana
|
||||
- FLASK_APP
|
||||
- FLASK_RUN_HOST
|
||||
- ADMIN_USER
|
||||
- ADMIN_PASSWORD
|
||||
- SECRET_KEY
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- /opt/digi-s/instance:/app/instance
|
||||
- /opt/digi-s/uploads:/app/static/uploads
|
||||
- /home/pi/Desktop/digi-server/instance:/app/instance
|
||||
- /home/pi/Desktop/digi-server/uploads:/app/static/uploads
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpus: '1.0'
|
||||
reservations:
|
||||
memory: 128M
|
||||
cpus: '0.25'
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5000/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
BIN
instance/dashboard.db
Normal file
BIN
instance/dashboard.db
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 361 KiB |
BIN
static/uploads/SampleVideo_1280x720_1mb.mp4
Normal file
BIN
static/uploads/SampleVideo_1280x720_1mb.mp4
Normal file
Binary file not shown.
BIN
static/uploads/wp2782770-1846651530.jpg
Normal file
BIN
static/uploads/wp2782770-1846651530.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 794 KiB |
@@ -210,6 +210,59 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Performance Monitoring Dashboard -->
|
||||
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h2>Performance Monitor</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="text-center">
|
||||
<h5>CPU Usage</h5>
|
||||
<div id="cpu-gauge" class="progress mb-2">
|
||||
<div class="progress-bar bg-info" role="progressbar" style="width: 0%"></div>
|
||||
</div>
|
||||
<small id="cpu-text">0%</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-center">
|
||||
<h5>Memory Usage</h5>
|
||||
<div id="memory-gauge" class="progress mb-2">
|
||||
<div class="progress-bar bg-warning" role="progressbar" style="width: 0%"></div>
|
||||
</div>
|
||||
<small id="memory-text">0%</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="text-center">
|
||||
<h5>Disk Usage</h5>
|
||||
<div id="disk-gauge" class="progress mb-2">
|
||||
<div class="progress-bar bg-danger" role="progressbar" style="width: 0%"></div>
|
||||
</div>
|
||||
<small id="disk-text">0%</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<button id="toggle-monitor" class="btn btn-primary">Start Monitoring</button>
|
||||
<button id="reset-stats" class="btn btn-secondary">Reset Stats</button>
|
||||
<span id="monitor-status" class="ms-3 text-muted">Monitoring stopped</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<h6>Performance Log:</h6>
|
||||
<div id="perf-log" style="height: 100px; overflow-y: scroll; background-color: #f8f9fa; padding: 10px; border-radius: 5px; font-family: monospace; font-size: 12px;">
|
||||
<div class="text-muted">Performance monitoring ready...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">Back to Dashboard</a>
|
||||
</div>
|
||||
@@ -227,6 +280,116 @@
|
||||
popup.style.display = 'none';
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Performance monitoring functionality
|
||||
let monitoringInterval = null;
|
||||
let isMonitoring = false;
|
||||
let maxCpu = 0, maxMemory = 0;
|
||||
|
||||
function updateGauge(elementId, textId, value, color) {
|
||||
const gauge = document.querySelector(`#${elementId} .progress-bar`);
|
||||
const text = document.getElementById(textId);
|
||||
gauge.style.width = `${value}%`;
|
||||
gauge.className = `progress-bar ${color}`;
|
||||
text.textContent = `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function logPerformance(message) {
|
||||
const log = document.getElementById('perf-log');
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const logEntry = document.createElement('div');
|
||||
logEntry.innerHTML = `<span class="text-muted">[${timestamp}]</span> ${message}`;
|
||||
log.appendChild(logEntry);
|
||||
log.scrollTop = log.scrollHeight;
|
||||
|
||||
// Keep only last 50 entries
|
||||
if (log.children.length > 50) {
|
||||
log.removeChild(log.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
function updatePerformanceStats() {
|
||||
fetch('/api/performance')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
logPerformance(`Error: ${data.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update gauges
|
||||
updateGauge('cpu-gauge', 'cpu-text', data.cpu_percent, 'bg-info');
|
||||
updateGauge('memory-gauge', 'memory-text', data.memory_percent, 'bg-warning');
|
||||
updateGauge('disk-gauge', 'disk-text', data.disk_percent, 'bg-danger');
|
||||
|
||||
// Track maximum values
|
||||
if (data.cpu_percent > maxCpu) {
|
||||
maxCpu = data.cpu_percent;
|
||||
logPerformance(`New CPU peak: ${maxCpu.toFixed(1)}%`);
|
||||
}
|
||||
if (data.memory_percent > maxMemory) {
|
||||
maxMemory = data.memory_percent;
|
||||
logPerformance(`New Memory peak: ${maxMemory.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
// Log significant changes
|
||||
if (data.cpu_percent > 80) {
|
||||
logPerformance(`<span class="text-danger">High CPU usage: ${data.cpu_percent.toFixed(1)}%</span>`);
|
||||
}
|
||||
if (data.memory_percent > 80) {
|
||||
logPerformance(`<span class="text-danger">High Memory usage: ${data.memory_percent.toFixed(1)}%</span>`);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
logPerformance(`Fetch error: ${error.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleMonitoring() {
|
||||
const toggleButton = document.getElementById('toggle-monitor');
|
||||
const statusSpan = document.getElementById('monitor-status');
|
||||
|
||||
if (isMonitoring) {
|
||||
// Stop monitoring
|
||||
clearInterval(monitoringInterval);
|
||||
isMonitoring = false;
|
||||
toggleButton.textContent = 'Start Monitoring';
|
||||
toggleButton.className = 'btn btn-primary';
|
||||
statusSpan.textContent = 'Monitoring stopped';
|
||||
statusSpan.className = 'ms-3 text-muted';
|
||||
logPerformance('Monitoring stopped');
|
||||
} else {
|
||||
// Start monitoring
|
||||
isMonitoring = true;
|
||||
toggleButton.textContent = 'Stop Monitoring';
|
||||
toggleButton.className = 'btn btn-danger';
|
||||
statusSpan.textContent = 'Monitoring active';
|
||||
statusSpan.className = 'ms-3 text-success';
|
||||
logPerformance('Monitoring started');
|
||||
|
||||
// Update immediately and then every 2 seconds
|
||||
updatePerformanceStats();
|
||||
monitoringInterval = setInterval(updatePerformanceStats, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function resetStats() {
|
||||
maxCpu = 0;
|
||||
maxMemory = 0;
|
||||
const log = document.getElementById('perf-log');
|
||||
log.innerHTML = '<div class="text-muted">Performance log reset...</div>';
|
||||
logPerformance('Stats reset');
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
document.getElementById('toggle-monitor').addEventListener('click', toggleMonitoring);
|
||||
document.getElementById('reset-stats').addEventListener('click', resetStats);
|
||||
|
||||
// Auto-start monitoring when page loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initial stats load
|
||||
updatePerformanceStats();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -91,12 +91,31 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if content %}
|
||||
<!-- Bulk Actions Controls -->
|
||||
<div class="mb-3 d-flex flex-wrap align-items-center gap-2">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="selectAllGroup">
|
||||
<label class="form-check-label" for="selectAllGroup">
|
||||
Select All
|
||||
</label>
|
||||
</div>
|
||||
<button id="deleteSelectedGroup" class="btn btn-danger" style="display: none;">
|
||||
<i class="bi bi-trash"></i> Delete Selected (<span id="selectedCountGroup">0</span>)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul class="list-group sortable-list" id="groupMediaList">
|
||||
{% for media in content %}
|
||||
<li class="list-group-item d-flex align-items-center {{ 'dark-mode' if theme == 'dark' else '' }}"
|
||||
draggable="true"
|
||||
data-id="{{ media.id }}"
|
||||
data-position="{{ loop.index0 }}">
|
||||
<!-- Checkbox for bulk selection -->
|
||||
<div class="me-2">
|
||||
<input type="checkbox" class="form-check-input group-media-checkbox"
|
||||
value="{{ media.id }}">
|
||||
</div>
|
||||
|
||||
<!-- Drag handle -->
|
||||
<div class="drag-handle me-2" title="Drag to reorder">
|
||||
<i class="bi bi-grip-vertical"></i>
|
||||
@@ -219,6 +238,70 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
item.dataset.position = index;
|
||||
});
|
||||
}
|
||||
|
||||
// Bulk delete functionality
|
||||
const selectAllGroup = document.getElementById('selectAllGroup');
|
||||
const deleteSelectedGroup = document.getElementById('deleteSelectedGroup');
|
||||
const selectedCountGroup = document.getElementById('selectedCountGroup');
|
||||
const groupMediaCheckboxes = document.querySelectorAll('.group-media-checkbox');
|
||||
|
||||
// Update selected count and toggle delete button visibility
|
||||
function updateSelectedCount() {
|
||||
const selectedCount = document.querySelectorAll('.group-media-checkbox:checked').length;
|
||||
selectedCountGroup.textContent = selectedCount;
|
||||
deleteSelectedGroup.style.display = selectedCount > 0 ? 'inline-block' : 'none';
|
||||
}
|
||||
|
||||
// Select/Deselect all checkboxes
|
||||
selectAllGroup.addEventListener('change', function() {
|
||||
const isChecked = selectAllGroup.checked;
|
||||
groupMediaCheckboxes.forEach(checkbox => {
|
||||
checkbox.checked = isChecked;
|
||||
});
|
||||
updateSelectedCount();
|
||||
});
|
||||
|
||||
// Individual checkbox change
|
||||
groupMediaCheckboxes.forEach(checkbox => {
|
||||
checkbox.addEventListener('change', updateSelectedCount);
|
||||
});
|
||||
|
||||
// Delete selected button click
|
||||
deleteSelectedGroup.addEventListener('click', function() {
|
||||
const selectedIds = Array.from(groupMediaCheckboxes)
|
||||
.filter(checkbox => checkbox.checked)
|
||||
.map(checkbox => checkbox.value);
|
||||
|
||||
if (selectedIds.length === 0) {
|
||||
alert('No media selected for deletion.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm(`Are you sure you want to delete ${selectedIds.length} selected media items?`)) {
|
||||
// Send bulk delete request
|
||||
fetch('{{ url_for("bulk_delete_group_content", group_id=group.id) }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token() if csrf_token else "" }}'
|
||||
},
|
||||
body: JSON.stringify({content_ids: selectedIds})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert(`Successfully deleted ${data.deleted_count} media items.`);
|
||||
location.reload(); // Reload the page to update the media list
|
||||
} else {
|
||||
alert('Error deleting media: ' + (data.error || 'Unknown error'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('An error occurred while deleting the media.');
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -93,6 +93,19 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if content %}
|
||||
<!-- Bulk Actions Controls -->
|
||||
<div class="mb-3 d-flex flex-wrap align-items-center gap-2">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="selectAll" {% if player.groups %}disabled{% endif %}>
|
||||
<label class="form-check-label" for="selectAll">
|
||||
Select All
|
||||
</label>
|
||||
</div>
|
||||
<button id="deleteSelected" class="btn btn-danger" {% if player.groups %}disabled{% endif %} style="display: none;">
|
||||
<i class="bi bi-trash"></i> Delete Selected (<span id="selectedCount">0</span>)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul class="list-group sortable-list" id="mediaList">
|
||||
{% for media in content %}
|
||||
<li class="list-group-item {% if theme == 'dark' %}dark-mode{% endif %}"
|
||||
@@ -100,6 +113,13 @@
|
||||
data-id="{{ media.id }}"
|
||||
data-position="{{ loop.index0 }}">
|
||||
<div class="d-flex flex-column flex-md-row align-items-md-center">
|
||||
<!-- Checkbox for bulk selection -->
|
||||
<div class="me-2">
|
||||
<input type="checkbox" class="form-check-input media-checkbox"
|
||||
value="{{ media.id }}"
|
||||
{% if player.groups %}disabled{% endif %}>
|
||||
</div>
|
||||
|
||||
<!-- Drag handle -->
|
||||
<div class="drag-handle me-2" title="Drag to reorder">
|
||||
<i class="bi bi-grip-vertical"></i>
|
||||
@@ -235,6 +255,75 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk delete functionality
|
||||
const selectAllCheckbox = document.getElementById('selectAll');
|
||||
const mediaCheckboxes = document.querySelectorAll('.media-checkbox');
|
||||
const deleteSelectedButton = document.getElementById('deleteSelected');
|
||||
const selectedCountSpan = document.getElementById('selectedCount');
|
||||
|
||||
// Update selected count and toggle delete button visibility
|
||||
function updateSelectedCount() {
|
||||
const selectedCount = document.querySelectorAll('.media-checkbox:checked').length;
|
||||
selectedCountSpan.textContent = selectedCount;
|
||||
deleteSelectedButton.style.display = selectedCount > 0 ? 'inline-block' : 'none';
|
||||
}
|
||||
|
||||
// Select/Deselect all checkboxes
|
||||
selectAllCheckbox.addEventListener('change', function() {
|
||||
mediaCheckboxes.forEach(checkbox => {
|
||||
checkbox.checked = selectAllCheckbox.checked;
|
||||
});
|
||||
updateSelectedCount();
|
||||
});
|
||||
|
||||
// Individual checkbox change
|
||||
mediaCheckboxes.forEach(checkbox => {
|
||||
checkbox.addEventListener('change', function() {
|
||||
// Uncheck "Select All" if any checkbox is unchecked
|
||||
if (!this.checked) {
|
||||
selectAllCheckbox.checked = false;
|
||||
}
|
||||
updateSelectedCount();
|
||||
});
|
||||
});
|
||||
|
||||
// Delete selected media
|
||||
deleteSelectedButton.addEventListener('click', function() {
|
||||
const selectedIds = Array.from(mediaCheckboxes)
|
||||
.filter(checkbox => checkbox.checked)
|
||||
.map(checkbox => checkbox.value);
|
||||
|
||||
if (selectedIds.length === 0) {
|
||||
alert('No media selected for deletion.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm(`Are you sure you want to delete ${selectedIds.length} selected media items?`)) {
|
||||
// Send bulk delete request
|
||||
fetch('{{ url_for("bulk_delete_player_content", player_id=player.id) }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token() if csrf_token else "" }}'
|
||||
},
|
||||
body: JSON.stringify({content_ids: selectedIds})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert('Selected media deleted successfully.');
|
||||
location.reload(); // Reload the page to update the media list
|
||||
} else {
|
||||
alert('Error deleting media: ' + (data.error || 'Unknown error'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('An error occurred while deleting the media.');
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -129,16 +129,44 @@
|
||||
|
||||
<!-- Modal for Status Updates -->
|
||||
<div class="modal fade" id="statusModal" tabindex="-1" aria-labelledby="statusModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content {{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||
<div class="modal-header {{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||
<h5 class="modal-title" id="statusModalLabel">Processing Files</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="status-message">Uploading and processing your files. Please wait...</p>
|
||||
<div class="progress">
|
||||
<div class="progress mb-3">
|
||||
<div id="progress-bar" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
|
||||
<!-- Real-time performance monitoring during upload -->
|
||||
<div class="performance-monitor mt-4">
|
||||
<h6>System Load During Upload:</h6>
|
||||
<div class="row">
|
||||
<div class="col-4">
|
||||
<small>CPU Usage</small>
|
||||
<div class="progress mb-1" style="height: 20px;">
|
||||
<div id="modal-cpu-bar" class="progress-bar bg-info" style="width: 0%;">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<small>Memory Usage</small>
|
||||
<div class="progress mb-1" style="height: 20px;">
|
||||
<div id="modal-memory-bar" class="progress-bar bg-warning" style="width: 0%;">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<small>Disk Usage</small>
|
||||
<div class="progress mb-1" style="height: 20px;">
|
||||
<div id="modal-disk-bar" class="progress-bar bg-danger" style="width: 0%;">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<small id="perf-stats" class="text-muted">Waiting for performance data...</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer {{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" disabled>Close</button>
|
||||
@@ -226,6 +254,9 @@
|
||||
statusMessage.textContent = 'Uploading and processing your files. Please wait...';
|
||||
}
|
||||
|
||||
// Start performance monitoring during upload
|
||||
startUploadMonitoring();
|
||||
|
||||
// Simulate progress updates
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
let progress = 0;
|
||||
@@ -236,6 +267,7 @@
|
||||
|
||||
if (progress >= 100) {
|
||||
clearInterval(interval);
|
||||
stopUploadMonitoring();
|
||||
statusMessage.textContent = 'Files uploaded and processed successfully!';
|
||||
|
||||
// Enable the close button
|
||||
@@ -246,6 +278,82 @@
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Performance monitoring during upload
|
||||
let uploadMonitoringInterval = null;
|
||||
let startCpu = 0, startMemory = 0;
|
||||
let maxUploadCpu = 0, maxUploadMemory = 0;
|
||||
|
||||
function updateModalPerformance(data) {
|
||||
// Update CPU bar
|
||||
const cpuBar = document.getElementById('modal-cpu-bar');
|
||||
cpuBar.style.width = `${data.cpu_percent}%`;
|
||||
cpuBar.textContent = `${data.cpu_percent.toFixed(1)}%`;
|
||||
if (data.cpu_percent > 75) cpuBar.className = 'progress-bar bg-danger';
|
||||
else if (data.cpu_percent > 50) cpuBar.className = 'progress-bar bg-warning';
|
||||
else cpuBar.className = 'progress-bar bg-info';
|
||||
|
||||
// Update Memory bar
|
||||
const memoryBar = document.getElementById('modal-memory-bar');
|
||||
memoryBar.style.width = `${data.memory_percent}%`;
|
||||
memoryBar.textContent = `${data.memory_percent.toFixed(1)}%`;
|
||||
if (data.memory_percent > 75) memoryBar.className = 'progress-bar bg-danger';
|
||||
else if (data.memory_percent > 50) memoryBar.className = 'progress-bar bg-warning';
|
||||
else memoryBar.className = 'progress-bar bg-warning';
|
||||
|
||||
// Update Disk bar
|
||||
const diskBar = document.getElementById('modal-disk-bar');
|
||||
diskBar.style.width = `${data.disk_percent}%`;
|
||||
diskBar.textContent = `${data.disk_percent.toFixed(1)}%`;
|
||||
if (data.disk_percent > 85) diskBar.className = 'progress-bar bg-danger';
|
||||
else diskBar.className = 'progress-bar bg-danger';
|
||||
|
||||
// Track peaks
|
||||
if (data.cpu_percent > maxUploadCpu) maxUploadCpu = data.cpu_percent;
|
||||
if (data.memory_percent > maxUploadMemory) maxUploadMemory = data.memory_percent;
|
||||
|
||||
// Update stats text
|
||||
const perfStats = document.getElementById('perf-stats');
|
||||
const cpuChange = startCpu ? (data.cpu_percent - startCpu).toFixed(1) : '0.0';
|
||||
const memChange = startMemory ? (data.memory_percent - startMemory).toFixed(1) : '0.0';
|
||||
perfStats.innerHTML = `CPU: ${cpuChange > 0 ? '+' : ''}${cpuChange}% | Memory: ${memChange > 0 ? '+' : ''}${memChange}% | Peak CPU: ${maxUploadCpu.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function startUploadMonitoring() {
|
||||
// Get baseline performance
|
||||
fetch('/api/performance')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (!data.error) {
|
||||
startCpu = data.cpu_percent;
|
||||
startMemory = data.memory_percent;
|
||||
maxUploadCpu = data.cpu_percent;
|
||||
maxUploadMemory = data.memory_percent;
|
||||
updateModalPerformance(data);
|
||||
}
|
||||
});
|
||||
|
||||
// Start monitoring every 1 second during upload
|
||||
uploadMonitoringInterval = setInterval(() => {
|
||||
fetch('/api/performance')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (!data.error) {
|
||||
updateModalPerformance(data);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Performance monitoring error:', error);
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopUploadMonitoring() {
|
||||
if (uploadMonitoringInterval) {
|
||||
clearInterval(uploadMonitoringInterval);
|
||||
uploadMonitoringInterval = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
186
utils/uploads.py
186
utils/uploads.py
@@ -1,12 +1,25 @@
|
||||
import os
|
||||
import subprocess
|
||||
import signal
|
||||
import psutil
|
||||
import time
|
||||
from flask import Flask
|
||||
from werkzeug.utils import secure_filename
|
||||
from pdf2image import convert_from_path
|
||||
from pptx import Presentation
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import io
|
||||
from extensions import db
|
||||
from models import Content, Player, Group
|
||||
from utils.logger import log_content_added, log_upload, log_process
|
||||
|
||||
# Add timeout handling class
|
||||
class TimeoutError(Exception):
|
||||
pass
|
||||
|
||||
def timeout_handler(signum, frame):
|
||||
raise TimeoutError("Operation timed out")
|
||||
|
||||
# Function to add image to playlist
|
||||
def add_image_to_playlist(app, file, filename, duration, target_type, target_id):
|
||||
"""
|
||||
@@ -206,9 +219,114 @@ def process_pdf(input_file, output_folder, duration, target_type, target_id):
|
||||
return update_playlist_with_files(image_filenames, duration, target_type, target_id)
|
||||
return False
|
||||
|
||||
def process_pptx(input_file, output_folder, duration, target_type, target_id):
|
||||
def convert_pptx_to_images_direct(pptx_file, output_folder, delete_pptx=True, dpi=300):
|
||||
"""
|
||||
Process a PPTX file: convert to PDF, then to images, and update playlist in sequential order.
|
||||
Convert a PPTX file directly to images using python-pptx library.
|
||||
This eliminates the need for LibreOffice and provides more reliable conversion.
|
||||
|
||||
Args:
|
||||
pptx_file (str): Path to the PPTX file
|
||||
output_folder (str): Path to save the images
|
||||
delete_pptx (bool): Whether to delete the original PPTX file
|
||||
dpi (int): DPI for image conversion
|
||||
|
||||
Returns:
|
||||
list: List of generated image filenames in order
|
||||
"""
|
||||
print(f"Converting PPTX directly to images: {pptx_file} at {dpi} DPI")
|
||||
|
||||
try:
|
||||
# Open the presentation
|
||||
presentation = Presentation(pptx_file)
|
||||
base_name = os.path.splitext(os.path.basename(pptx_file))[0]
|
||||
image_filenames = []
|
||||
|
||||
print(f"PPTX has {len(presentation.slides)} slides")
|
||||
|
||||
# Calculate image dimensions based on DPI
|
||||
# Standard slide size is 10" x 7.5" (25.4cm x 19.05cm)
|
||||
width_px = int(10 * dpi) # 10 inches * DPI
|
||||
height_px = int(7.5 * dpi) # 7.5 inches * DPI
|
||||
|
||||
for i, slide in enumerate(presentation.slides):
|
||||
try:
|
||||
# Use zero-padded page numbers for proper sorting
|
||||
page_num = str(i + 1).zfill(3)
|
||||
image_filename = f"{base_name}_page_{page_num}.jpg"
|
||||
image_path = os.path.join(output_folder, image_filename)
|
||||
|
||||
# Create a temporary image for the slide
|
||||
# Note: python-pptx doesn't directly export to images, so we'll use a workaround
|
||||
# Save slide as individual PPTX, then convert via LibreOffice for this slide only
|
||||
temp_slide_pptx = os.path.join(output_folder, f"temp_slide_{i+1}.pptx")
|
||||
temp_slide_pdf = os.path.join(output_folder, f"temp_slide_{i+1}.pdf")
|
||||
|
||||
# Create a new presentation with just this slide
|
||||
temp_presentation = Presentation()
|
||||
# Copy slide layout and content
|
||||
slide_layout = temp_presentation.slide_layouts[0] # Use blank layout
|
||||
temp_slide = temp_presentation.slides.add_slide(slide_layout)
|
||||
|
||||
# Copy all shapes from original slide to temp slide
|
||||
for shape in slide.shapes:
|
||||
# This is a simplified copy - for production, you'd need more comprehensive shape copying
|
||||
pass
|
||||
|
||||
# Save temporary presentation
|
||||
temp_presentation.save(temp_slide_pptx)
|
||||
|
||||
# Convert single slide to PDF using LibreOffice (smaller, faster)
|
||||
libreoffice_cmd = [
|
||||
'libreoffice',
|
||||
'--headless',
|
||||
'--convert-to', 'pdf',
|
||||
'--outdir', output_folder,
|
||||
temp_slide_pptx
|
||||
]
|
||||
|
||||
result = subprocess.run(libreoffice_cmd, capture_output=True, text=True, timeout=60)
|
||||
|
||||
if result.returncode == 0 and os.path.exists(temp_slide_pdf):
|
||||
# Convert PDF to image
|
||||
images = convert_from_path(temp_slide_pdf, dpi=dpi)
|
||||
if images:
|
||||
images[0].save(image_path, 'JPEG', quality=85, optimize=True)
|
||||
image_filenames.append(image_filename)
|
||||
print(f"Saved slide {i + 1}/{len(presentation.slides)} as: {image_filename}")
|
||||
|
||||
# Clean up temporary files
|
||||
if os.path.exists(temp_slide_pdf):
|
||||
os.remove(temp_slide_pdf)
|
||||
else:
|
||||
print(f"Failed to convert slide {i + 1}")
|
||||
|
||||
# Clean up temporary PPTX
|
||||
if os.path.exists(temp_slide_pptx):
|
||||
os.remove(temp_slide_pptx)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing slide {i + 1}: {e}")
|
||||
continue
|
||||
|
||||
print(f"PPTX conversion complete. Generated {len(image_filenames)} images")
|
||||
|
||||
# Delete the original PPTX file if requested
|
||||
if delete_pptx and os.path.exists(pptx_file):
|
||||
pptx_size = os.path.getsize(pptx_file) / (1024*1024)
|
||||
os.remove(pptx_file)
|
||||
print(f"Original PPTX file deleted: {pptx_file} ({pptx_size:.2f} MB freed)")
|
||||
|
||||
return image_filenames
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error converting PPTX to images: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
def process_pptx_improved(input_file, output_folder, duration, target_type, target_id):
|
||||
"""
|
||||
Improved PPTX processing function that's more reliable and faster.
|
||||
|
||||
Args:
|
||||
input_file (str): Path to the PPTX file
|
||||
@@ -220,51 +338,49 @@ def process_pptx(input_file, output_folder, duration, target_type, target_id):
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
# Ensure output folder exists
|
||||
if not os.path.exists(output_folder):
|
||||
os.makedirs(output_folder)
|
||||
print(f"=== Starting Improved PPTX Processing ===")
|
||||
print(f"Input file: {input_file}")
|
||||
|
||||
# Step 1: Convert PPTX to PDF using LibreOffice
|
||||
pdf_file = os.path.join(output_folder, os.path.splitext(os.path.basename(input_file))[0] + ".pdf")
|
||||
command = [
|
||||
'libreoffice',
|
||||
'--headless',
|
||||
'--convert-to', 'pdf',
|
||||
'--outdir', output_folder,
|
||||
'--printer-resolution', '600',
|
||||
input_file
|
||||
]
|
||||
|
||||
print(f"Running LibreOffice command: {' '.join(command)}")
|
||||
try:
|
||||
result = subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
print(f"PPTX file converted to PDF: {pdf_file}")
|
||||
print(f"LibreOffice output: {result.stdout.decode()}")
|
||||
print(f"LibreOffice errors (if any): {result.stderr.decode()}")
|
||||
file_size = os.path.getsize(input_file) / (1024*1024)
|
||||
print(f"File size: {file_size:.2f} MB")
|
||||
|
||||
# Step 2: Convert PDF to images and update playlist
|
||||
image_filenames = convert_pdf_to_images(pdf_file, output_folder, True, dpi=600)
|
||||
# Ensure output folder exists
|
||||
if not os.path.exists(output_folder):
|
||||
os.makedirs(output_folder)
|
||||
|
||||
# Check if LibreOffice is available
|
||||
try:
|
||||
result = subprocess.run(['libreoffice', '--version'], capture_output=True, text=True, timeout=10)
|
||||
if result.returncode != 0:
|
||||
print("LibreOffice not available, falling back to basic conversion")
|
||||
return False
|
||||
except:
|
||||
print("LibreOffice not available, falling back to basic conversion")
|
||||
return False
|
||||
|
||||
# Convert PPTX directly to images
|
||||
image_filenames = convert_pptx_to_images_direct(input_file, output_folder, True, dpi=300)
|
||||
|
||||
# Verify we got images
|
||||
if not image_filenames:
|
||||
print("Error: No images were generated from the PDF")
|
||||
print("Error: No images were generated from the PPTX")
|
||||
return False
|
||||
|
||||
print(f"Generated {len(image_filenames)} images for PPTX")
|
||||
|
||||
# Step 3: Delete the original PPTX file
|
||||
if os.path.exists(input_file):
|
||||
os.remove(input_file)
|
||||
print(f"Original PPTX file deleted: {input_file}")
|
||||
|
||||
# Step 4: Update playlist with generated images in sequential order
|
||||
return update_playlist_with_files(image_filenames, duration, target_type, target_id)
|
||||
# Update playlist with generated images in sequential order
|
||||
success = update_playlist_with_files(image_filenames, duration, target_type, target_id)
|
||||
|
||||
print(f"=== PPTX Processing Complete ===")
|
||||
print(f"Successfully processed {len(image_filenames)} slides")
|
||||
|
||||
return success
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error converting PPTX to PDF: {e.stderr.decode() if hasattr(e, 'stderr') else str(e)}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Error processing PPTX file: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
def process_uploaded_files(app, files, media_type, duration, target_type, target_id):
|
||||
@@ -336,7 +452,7 @@ def process_uploaded_files(app, files, media_type, duration, target_type, target
|
||||
|
||||
elif media_type == 'ppt':
|
||||
# For PPT/PPTX, convert to PDF, then to images, and update playlist
|
||||
success = process_pptx(file_path, app.config['UPLOAD_FOLDER'],
|
||||
success = process_pptx_improved(file_path, app.config['UPLOAD_FOLDER'],
|
||||
duration, target_type, target_id)
|
||||
if success:
|
||||
result['message'] = f"PowerPoint {filename} processed successfully"
|
||||
|
||||
Reference in New Issue
Block a user