updated app start

This commit is contained in:
2025-08-05 16:50:46 +03:00
parent 318f783de3
commit 2e719fc029
37 changed files with 1028 additions and 198 deletions

2
.gitignore vendored
View File

@@ -1,4 +1,4 @@
digiscreen/
venv/
.env

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

555
app.py
View File

@@ -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)

View File

@@ -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

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 794 KiB

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"