feat: v1.1.0 - Production-Ready Docker Deployment

🚀 Major Release: DigiServer v1.1.0 Production Deployment

## 📁 Project Restructure
- Moved all application code to app/ directory for Docker containerization
- Centralized persistent data in data/ directory with volume mounting
- Removed development artifacts and cleaned up project structure

## 🐳 Docker Integration
- Added production-ready Dockerfile with LibreOffice and poppler-utils
- Updated docker-compose.yml for production deployment
- Added .dockerignore for optimized build context
- Created automated deployment script (deploy-docker.sh)
- Added cleanup script (cleanup-docker.sh)

## 📄 Document Processing Enhancements
- Integrated LibreOffice for professional PPTX to PDF conversion
- Implemented PPTX → PDF → 4K JPG workflow for optimal quality
- Added poppler-utils for enhanced PDF processing
- Simplified PDF conversion to 300 DPI for reliability

## 🔧 File Management Improvements
- Fixed absolute path resolution for containerized deployment
- Updated all file deletion functions with proper path handling
- Enhanced bulk delete functions for players and groups
- Improved file upload workflow with consistent path management

## 🛠️ Code Quality & Stability
- Cleaned up pptx_converter.py from 442 to 86 lines
- Removed all Python cache files (__pycache__/, *.pyc)
- Updated file operations for production reliability
- Enhanced error handling and logging

## 📚 Documentation Updates
- Updated README.md with Docker deployment instructions
- Added comprehensive DEPLOYMENT.md guide
- Included production deployment best practices
- Added automated deployment workflow documentation

## 🔐 Security & Production Features
- Environment-based configuration
- Health checks and container monitoring
- Automated admin user creation
- Volume-mounted persistent data
- Production logging and error handling

##  Ready for Production
- Clean project structure optimized for Docker
- Automated deployment with ./deploy-docker.sh
- Professional document processing pipeline
- Reliable file management system
- Complete documentation and deployment guides

Access: http://localhost:8880 | Admin: admin/Initial01!
This commit is contained in:
2025-08-05 18:04:02 -04:00
parent 4e5aff1c02
commit 1eb0aa3658
71 changed files with 2017 additions and 379 deletions

859
app/app.py Executable file
View File

@@ -0,0 +1,859 @@
import os
import click
import psutil
import shutil
import zipfile
import tempfile
from flask import Flask, render_template, request, redirect, url_for, session, flash, jsonify, send_from_directory, send_file
from flask_migrate import Migrate
import subprocess
from werkzeug.utils import secure_filename
from functools import wraps
from extensions import db, bcrypt, login_manager
from sqlalchemy import text
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
# First import models
from models import User, Player, Group, Content, ServerLog, group_player
# Then import utilities that use the models
from flask_login import login_user, logout_user, login_required, current_user
from utils.logger import get_recent_logs, log_action, log_upload, log_process, log_user_deleted, log_user_created
from utils.group_player_management import (
create_group as create_group_util,
edit_group as edit_group_util,
delete_group as delete_group_util,
add_player as add_player_util,
edit_player as edit_player_util,
delete_player as delete_player_util,
get_group_content,
get_player_content,
update_player_content_order,
update_group_content_order,
edit_group_media,
delete_group_media
)
# Finally, import modules that depend on both models and logger
from utils.uploads import (
add_image_to_playlist,
convert_video_and_update_playlist,
process_pdf,
process_pptx,
process_uploaded_files
)
# Define global variables for server version and build date
SERVER_VERSION = "1.1.0"
BUILD_DATE = "2025-06-29"
# Get the absolute path of the app directory
app_dir = os.path.dirname(os.path.abspath(__file__))
template_dir = os.path.join(app_dir, 'templates')
static_dir = os.path.join(app_dir, 'static')
app = Flask(__name__,
instance_relative_config=True,
template_folder=template_dir,
static_folder=static_dir)
# Set the secret key from environment variable or use a default value
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'Ana_Are_Multe_Mere-Si_Nu_Are_Pere')
instance_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), 'instance'))
os.makedirs(instance_dir, exist_ok=True)
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
# Ensure the instance folder exists
os.makedirs(app.instance_path, exist_ok=True)
os.makedirs(instance_dir, exist_ok=True)
db.init_app(app)
bcrypt.init_app(app)
login_manager.init_app(app)
UPLOAD_FOLDER = 'static/uploads'
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
UPLOAD_FOLDERLOGO = 'static/resurse'
app.config['UPLOAD_FOLDERLOGO'] = UPLOAD_FOLDERLOGO
# Ensure the upload folder exists
if not os.path.exists(UPLOAD_FOLDER):
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
if not os.path.exists(UPLOAD_FOLDERLOGO):
os.makedirs(UPLOAD_FOLDERLOGO, exist_ok=True)
login_manager.login_view = 'login'
migrate = Migrate(app, db)
@login_manager.user_loader
def load_user(user_id):
return db.session.get(User, int(user_id))
def admin_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if current_user.role != 'admin':
return redirect(url_for('dashboard'))
return f(*args, **kwargs)
return decorated_function
def get_system_info():
"""Get system monitoring information"""
try:
# CPU information
cpu_percent = psutil.cpu_percent(interval=1)
cpu_count = psutil.cpu_count()
# Memory information
memory = psutil.virtual_memory()
memory_percent = memory.percent
memory_used = round(memory.used / (1024**3), 2) # GB
memory_total = round(memory.total / (1024**3), 2) # GB
# Disk information
disk = psutil.disk_usage('/')
disk_percent = round((disk.used / disk.total) * 100, 1)
disk_used = round(disk.used / (1024**3), 2) # GB
disk_total = round(disk.total / (1024**3), 2) # GB
disk_free = round(disk.free / (1024**3), 2) # GB
# Upload folder size
upload_folder_size = 0
if os.path.exists(UPLOAD_FOLDER):
for dirpath, dirnames, filenames in os.walk(UPLOAD_FOLDER):
for filename in filenames:
filepath = os.path.join(dirpath, filename)
if os.path.exists(filepath):
upload_folder_size += os.path.getsize(filepath)
upload_folder_size_gb = round(upload_folder_size / (1024**3), 2)
return {
'cpu_percent': cpu_percent,
'cpu_count': cpu_count,
'memory_percent': memory_percent,
'memory_used': memory_used,
'memory_total': memory_total,
'disk_percent': disk_percent,
'disk_used': disk_used,
'disk_total': disk_total,
'disk_free': disk_free,
'upload_folder_size': upload_folder_size_gb
}
except Exception as e:
print(f"Error getting system info: {e}")
return None
@app.route('/')
@login_required
def dashboard():
players = Player.query.all()
groups = Group.query.all()
logo_exists = os.path.exists(os.path.join(app.config['UPLOAD_FOLDERLOGO'], 'logo.png'))
server_logs = get_recent_logs(20) # Get the 20 most recent logs
return render_template('dashboard.html', players=players, groups=groups, logo_exists=logo_exists, server_logs=server_logs)
@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
new_user = User(username=username, password=hashed_password, role='user')
db.session.add(new_user)
db.session.commit()
return redirect(url_for('login'))
return render_template('register.html')
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
user = User.query.filter_by(username=username).first()
if user and bcrypt.check_password_hash(user.password, password):
login_user(user)
return redirect(url_for('dashboard'))
else:
flash('Login Unsuccessful. Please check username and password', 'danger')
login_picture_exists = os.path.exists(os.path.join(app.config['UPLOAD_FOLDERLOGO'], 'login_picture.png'))
return render_template('login.html', login_picture_exists=login_picture_exists)
@app.route('/logout')
@login_required
def logout():
logout_user()
return redirect(url_for('login'))
@app.route('/upload_content', methods=['GET', 'POST'])
@login_required
@admin_required
def upload_content():
if request.method == 'POST':
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"Target Type: {target_type}, Target ID: {target_id}, Media Type: {media_type}")
if not target_type or not target_id:
flash('Please select a target type and target ID.', 'danger')
return redirect(url_for('upload_content'))
# Process uploaded files and get results
results = process_uploaded_files(app, files, media_type, duration, target_type, target_id)
return redirect(return_url)
# Handle GET request
target_type = request.args.get('target_type')
target_id = request.args.get('target_id')
return_url = request.args.get('return_url', url_for('dashboard'))
players = [{'id': player.id, 'username': player.username} for player in Player.query.all()]
groups = [{'id': group.id, 'name': group.name} for group in Group.query.all()]
# Get system information for monitoring
system_info = get_system_info()
return render_template('upload_content.html', target_type=target_type, target_id=target_id,
players=players, groups=groups, return_url=return_url, system_info=system_info)
@app.route('/admin')
@login_required
@admin_required
def admin():
logo_exists = os.path.exists(os.path.join(app.config['UPLOAD_FOLDERLOGO'], 'logo.png'))
login_picture_exists = os.path.exists(os.path.join(app.config['UPLOAD_FOLDERLOGO'], 'login_picture.png'))
users = User.query.all()
# Get system information for monitoring
system_info = get_system_info()
return render_template(
'admin.html',
users=users,
logo_exists=logo_exists,
login_picture_exists=login_picture_exists,
server_version=SERVER_VERSION,
build_date=BUILD_DATE,
system_info=system_info
)
@app.route('/admin/change_role/<int:user_id>', methods=['POST'])
@login_required
@admin_required
def change_role(user_id):
user = User.query.get_or_404(user_id)
new_role = request.form['role']
user.role = new_role
db.session.commit()
return redirect(url_for('admin'))
@app.route('/admin/delete_user/<int:user_id>', methods=['POST'])
@login_required
@admin_required
def delete_user(user_id):
user = User.query.get_or_404(user_id)
username = user.username # Store username before deletion for logging
db.session.delete(user)
db.session.commit()
# Add log entry for user deletion
log_user_deleted(username)
return redirect(url_for('admin'))
@app.route('/admin/create_user', methods=['POST'])
@login_required
@admin_required
def create_user():
username = request.form['username']
password = request.form['password']
role = request.form['role']
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
new_user = User(username=username, password=hashed_password, role=role)
db.session.add(new_user)
db.session.commit()
# Add log entry for user creation
log_user_created(username, role)
return redirect(url_for('admin'))
@app.route('/player/<int:player_id>')
@login_required
def player_page(player_id):
player = db.session.get(Player, player_id)
content = get_player_content(player_id)
return render_template('player_page.html', player=player, content=content)
@app.route('/player/<int:player_id>/upload', methods=['POST'])
@login_required
def upload_content_to_player(player_id):
player = Player.query.get_or_404(player_id)
files = request.files.getlist('files')
duration = int(request.form['duration'])
for file in files:
filename = secure_filename(file.filename)
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(file_path)
new_content = Content(file_name=filename, duration=duration, player_id=player_id)
db.session.add(new_content)
db.session.commit()
return redirect(url_for('player_page', player_id=player_id))
@app.route('/content/<int:content_id>/edit', methods=['POST'])
@login_required
def edit_content(content_id):
content = Content.query.get_or_404(content_id)
new_duration = int(request.form['duration'])
content.duration = new_duration
db.session.commit()
return redirect(url_for('player_page', player_id=content.player_id))
@app.route('/content/<int:content_id>/delete', methods=['POST'])
@login_required
def delete_content(content_id):
content = Content.query.get_or_404(content_id)
player_id = content.player_id
db.session.delete(content)
db.session.commit()
return redirect(url_for('player_page', player_id=player_id))
@app.route('/player/<int:player_id>/bulk_delete', methods=['POST'])
@login_required
def bulk_delete_player_content(player_id):
"""Bulk delete selected media files from player"""
player = Player.query.get_or_404(player_id)
# Check if player is in a group (should be managed at group level)
if player.groups:
flash('Cannot delete media from players that are in groups. Manage media at the group level.', 'warning')
return redirect(url_for('player_page', player_id=player_id))
selected_content_ids = request.form.getlist('selected_content')
if not selected_content_ids:
flash('No media files selected for deletion.', 'warning')
return redirect(url_for('player_page', player_id=player_id))
try:
deleted_files = []
deleted_count = 0
for content_id in selected_content_ids:
content = Content.query.filter_by(id=content_id, player_id=player_id).first()
if content:
# Delete file from filesystem using absolute path
upload_folder = app.config['UPLOAD_FOLDER']
if not os.path.isabs(upload_folder):
upload_folder = os.path.abspath(upload_folder)
file_path = os.path.join(upload_folder, content.file_name)
if os.path.exists(file_path):
try:
os.remove(file_path)
deleted_files.append(content.file_name)
print(f"Deleted file: {file_path}")
except OSError as e:
print(f"Error deleting 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()
flash(f'Successfully deleted {deleted_count} media file(s). Playlist updated to version {player.playlist_version}.', 'success')
except Exception as e:
db.session.rollback()
print(f"Error in bulk delete: {e}")
flash('An error occurred while deleting media files.', 'danger')
return redirect(url_for('player_page', player_id=player_id))
@app.route('/player/<int:player_id>/fullscreen', methods=['GET', 'POST'])
def player_fullscreen(player_id):
player = Player.query.get_or_404(player_id)
if request.method == 'POST':
hostname = request.form['hostname']
password = request.form['password']
quickconnect_password = request.form.get('quickconnect_password')
if quickconnect_password:
if player.hostname == hostname and bcrypt.check_password_hash(player.quickconnect_password, quickconnect_password):
authenticated = True
else:
authenticated = False
else:
if player.hostname == hostname and bcrypt.check_password_hash(player.password, password):
authenticated = True
else:
authenticated = False
else:
authenticated = False
if authenticated or current_user.is_authenticated:
content = Content.query.filter_by(player_id=player_id).all()
return render_template('player_fullscreen.html', player=player, content=content)
else:
return render_template('player_auth.html', player_id=player_id)
@app.route('/player/<int:player_id>/delete', methods=['POST'])
@login_required
@admin_required
def delete_player(player_id):
delete_player_util(player_id)
return redirect(url_for('dashboard'))
# Update the add_player function
@app.route('/player/add', methods=['GET', 'POST'])
@login_required
@admin_required
def add_player():
if request.method == 'POST':
username = request.form['username']
hostname = request.form['hostname']
password = bcrypt.generate_password_hash(request.form['password']).decode('utf-8')
quickconnect_password = bcrypt.generate_password_hash(request.form['quickconnect_password']).decode('utf-8')
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')
return redirect(url_for('dashboard'))
return render_template('add_player.html')
@app.route('/player/<int:player_id>/edit', methods=['GET', 'POST'])
@login_required
@admin_required
def edit_player(player_id):
player = Player.query.get_or_404(player_id)
if request.method == 'POST':
username = request.form['username']
hostname = request.form['hostname']
password = request.form['password'] if request.form['password'] else None
quickconnect_password = request.form['quickconnect_password'] if request.form['quickconnect_password'] else None
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')
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))
return render_template('edit_player.html', player=player, return_url=return_url)
@app.route('/change_theme', methods=['POST'])
@login_required
@admin_required
def change_theme():
theme = request.form['theme']
current_user.theme = theme
db.session.commit()
return redirect(url_for('admin'))
@app.route('/upload_logo', methods=['POST'])
@login_required
@admin_required
def upload_logo():
if 'logo' not in request.files:
return redirect(url_for('admin'))
file = request.files['logo']
if file.filename == '':
return redirect(url_for('admin'))
if file:
filename = secure_filename(file.filename)
file_path = os.path.join(app.config['UPLOAD_FOLDERLOGO'], 'logo.png')
file.save(file_path)
return redirect(url_for('admin'))
@app.route('/upload_personalization_pictures', methods=['POST'])
@login_required
@admin_required
def upload_personalization_pictures():
logo_file = request.files.get('logo')
login_picture_file = request.files.get('login_picture')
if logo_file and logo_file.filename != '':
logo_filename = secure_filename(logo_file.filename)
logo_file_path = os.path.join(app.config['UPLOAD_FOLDERLOGO'], 'logo.png')
logo_file.save(logo_file_path)
if login_picture_file and login_picture_file.filename != '':
login_picture_filename = secure_filename(login_picture_file.filename)
login_picture_file_path = os.path.join(app.config['UPLOAD_FOLDERLOGO'], 'login_picture.png')
login_picture_file.save(login_picture_file_path)
return redirect(url_for('admin'))
@app.route('/clean_unused_files', methods=['POST'])
@login_required
@admin_required
def clean_unused_files():
# Get all file names from the database
content_files = {content.file_name for content in Content.query.all()}
logo_file = 'resurse/logo.png'
login_picture_file = 'resurse/login_picture.png'
# Debugging: Print the content files from the database
print("Content files from database:", content_files)
# Get all files in the upload folder
all_files = set(os.listdir(app.config['UPLOAD_FOLDER']))
# Determine unused files
used_files = content_files | {logo_file, login_picture_file}
unused_files = all_files - used_files
# Debugging: Print the lists of files
print("All files:", all_files)
print("Used files:", used_files)
print("Unused files:", unused_files)
# Delete unused files using absolute path
upload_folder = app.config['UPLOAD_FOLDER']
if not os.path.isabs(upload_folder):
upload_folder = os.path.abspath(upload_folder)
for file_name in unused_files:
file_path = os.path.join(upload_folder, file_name)
if os.path.isfile(file_path):
print(f"Deleting unused file: {file_path}")
os.remove(file_path)
flash('Unused files have been cleaned.', 'success')
return redirect(url_for('admin'))
@app.route('/api/playlists', methods=['GET'])
def get_playlists():
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
# 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]
# 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)
)
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
})
@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))
@app.route('/group/<int:group_id>/bulk_delete', methods=['POST'])
@login_required
@admin_required
def bulk_delete_group_content(group_id):
"""Bulk delete selected media files from group"""
group = Group.query.get_or_404(group_id)
selected_content_ids = request.form.getlist('selected_content')
if not selected_content_ids:
flash('No media files selected for deletion.', 'warning')
return redirect(url_for('manage_group', group_id=group_id))
try:
deleted_files = []
deleted_count = 0
player_ids = [player.id for player in group.players]
for content_id in selected_content_ids:
content = Content.query.filter(
Content.id == content_id,
Content.player_id.in_(player_ids)
).first()
if content:
# Delete file from filesystem using absolute path
upload_folder = app.config['UPLOAD_FOLDER']
if not os.path.isabs(upload_folder):
upload_folder = os.path.abspath(upload_folder)
file_path = os.path.join(upload_folder, content.file_name)
if os.path.exists(file_path):
try:
os.remove(file_path)
deleted_files.append(content.file_name)
print(f"Deleted file: {file_path}")
except OSError as e:
print(f"Error deleting 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()
flash(f'Successfully deleted {deleted_count} media file(s) from group. All player playlists updated.', 'success')
except Exception as e:
db.session.rollback()
print(f"Error in group bulk delete: {e}")
flash('An error occurred while deleting media files.', 'danger')
return redirect(url_for('manage_group', group_id=group_id))
@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
# Return the playlist version and hashed quickconnect code
return jsonify({
'playlist_version': player.playlist_version,
'hashed_quickconnect': player.quickconnect_password
})
@app.route('/api/system_info', methods=['GET'])
@login_required
@admin_required
def api_system_info():
"""API endpoint to get real-time system information"""
system_info = get_system_info()
if system_info:
return jsonify(system_info)
else:
return jsonify({'error': 'Could not retrieve system information'}), 500
@app.route('/player/<int:player_id>/update_order', methods=['POST'])
@login_required
def update_content_order(player_id):
if not request.is_json:
return jsonify({'success': False, 'error': 'Invalid request format'}), 400
player = Player.query.get_or_404(player_id)
if player.groups and current_user.role != 'admin':
return jsonify({'success': False, 'error': 'Cannot reorder playlist for players in groups'}), 403
items = request.json.get('items', [])
success, error, new_version = update_player_content_order(player_id, items)
if success:
return jsonify({'success': True, 'new_version': new_version})
else:
return jsonify({'success': False, 'error': error}), 500
@app.route('/group/<int:group_id>/update_order', methods=['POST'])
@login_required
@admin_required
def update_group_content_order_route(group_id):
if not request.is_json:
return jsonify({'success': False, 'error': 'Invalid request format'}), 400
items = request.json.get('items', [])
success, error = update_group_content_order(group_id, items)
if success:
return jsonify({'success': True})
else:
return jsonify({'success': False, 'error': error}), 500
@app.route('/debug/content_positions/<int:group_id>')
@login_required
@admin_required
def debug_content_positions(group_id):
group = Group.query.get_or_404(group_id)
player_ids = [p.id for p in group.players]
# Query directly with SQL to see positions
sql = text("SELECT id, file_name, position, player_id FROM content WHERE player_id IN :player_ids ORDER BY position")
result = db.session.execute(sql, {"player_ids": tuple(player_ids)})
content_data = [{"id": row.id, "file_name": row.file_name, "position": row.position, "player_id": row.player_id} for row in result]
return jsonify(content_data)
@app.cli.command("create-admin")
@click.option("--username", default="admin", help="Admin username")
@click.option("--password", help="Admin password")
def create_admin(username, password):
"""Create an admin user."""
from models import User
from extensions import bcrypt
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
user = User(username=username, password=hashed_password, role='admin')
db.session.add(user)
db.session.commit()
print(f"Admin user '{username}' created successfully.")
from models.create_default_user import create_default_user
if not app.debug or os.environ.get('WERKZEUG_RUN_MAIN') == 'true':
with app.app_context():
try:
db.session.execute(db.select(User).limit(1))
except Exception as e:
print("Database not initialized or missing tables. Re-initializing...")
db.create_all()
create_default_user(db, User, bcrypt)
# Add this at the end of app.py
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=5000)

29
app/entrypoint.sh Executable file
View File

@@ -0,0 +1,29 @@
#!/bin/bash
set -e
# Create necessary directories
mkdir -p static/uploads static/resurse
mkdir -p instance
# Check if database exists
if [ ! -f instance/dashboard.db ]; then
echo "No database found, creating fresh database..."
# Create admin user if environment variables are set
if [ -n "$ADMIN_USER" ] && [ -n "$ADMIN_PASSWORD" ]; then
echo "Creating admin user: $ADMIN_USER"
flask create-admin --username "$ADMIN_USER" --password "$ADMIN_PASSWORD"
else
echo "Warning: ADMIN_USER or ADMIN_PASSWORD not set, skipping admin creation"
fi
else
echo "Existing database found, skipping initialization..."
echo "Creating admin user if needed..."
if [ -n "$ADMIN_USER" ] && [ -n "$ADMIN_PASSWORD" ]; then
flask create-admin --username "$ADMIN_USER" --password "$ADMIN_PASSWORD" 2>/dev/null || echo "Default user '$ADMIN_USER' already exists."
fi
fi
echo "Starting DigiServer..."
# Start the application
exec flask run --host=0.0.0.0

8
app/extensions.py Normal file
View File

@@ -0,0 +1,8 @@
# extensions.py
from flask_sqlalchemy import SQLAlchemy
from flask_bcrypt import Bcrypt
from flask_login import LoginManager
db = SQLAlchemy()
bcrypt = Bcrypt()
login_manager = LoginManager()

5
app/models/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
from .user import User
from .player import Player
from .group import Group, group_player
from .content import Content
from .server_log import ServerLog

21
app/models/clear_db.py Normal file
View File

@@ -0,0 +1,21 @@
import os
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
# Ensure the instance directory exists (relative to project root)
instance_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'instance'))
os.makedirs(instance_dir, exist_ok=True)
# Set the correct database URI
db_path = os.path.join(instance_dir, 'dashboard.db')
print(f"Using database at: {db_path}")
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)
with app.app_context():
db.reflect() # This loads all tables from the database
db.drop_all()
print("Dropped all tables successfully.")

8
app/models/content.py Normal file
View File

@@ -0,0 +1,8 @@
from extensions import db
class Content(db.Model):
id = db.Column(db.Integer, primary_key=True)
file_name = db.Column(db.String(255), nullable=False)
duration = db.Column(db.Integer, nullable=False)
player_id = db.Column(db.Integer, db.ForeignKey('player.id'), nullable=False)
position = db.Column(db.Integer, default=0)

View File

@@ -0,0 +1,18 @@
#from app import app, db, User, bcrypt
import os
def create_default_user(db, User, bcrypt):
username = os.getenv('DEFAULT_USER', 'admin')
password = os.getenv('DEFAULT_PASSWORD', '1234')
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
existing_user = User.query.filter_by(username=username).first()
if not existing_user:
default_user = User(username=username, password=hashed_password, role='admin')
db.session.add(default_user)
db.session.commit()
print(f"Default user '{username}' created with password '{password}'")
else:
print(f"Default user '{username}' already exists.")
#with app.app_context():
# create_default_user(db, User, bcrypt)

13
app/models/group.py Normal file
View File

@@ -0,0 +1,13 @@
from extensions import db
class Group(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False, unique=True)
orientation = db.Column(db.String(16), nullable=False, default='Landscape') # <-- Add this line
players = db.relationship('Player', secondary='group_player', backref='groups')
playlist_version = db.Column(db.Integer, default=0)
group_player = db.Table('group_player',
db.Column('group_id', db.Integer, db.ForeignKey('group.id'), primary_key=True),
db.Column('player_id', db.Integer, db.ForeignKey('player.id'), primary_key=True)
)

24
app/models/init_db.py Normal file
View File

@@ -0,0 +1,24 @@
import os
from app import app
from extensions import db, bcrypt
from models import User, ServerLog # Import from models.py instead of app.py
def create_admin_user():
admin_username = os.getenv('ADMIN_USER', 'admin')
admin_password = os.getenv('ADMIN_PASSWORD', 'admin')
hashed_password = bcrypt.generate_password_hash(admin_password).decode('utf-8')
if not User.query.filter_by(username=admin_username).first():
admin_user = User(username=admin_username, password=hashed_password, role='admin')
db.session.add(admin_user)
db.session.commit()
print(f"Admin user '{admin_username}' created with password '{admin_password}'")
else:
print(f"Admin user '{admin_username}' already exists")
if __name__ == '__main__':
with app.app_context():
db.create_all()
create_admin_user()
print("Database initialized with all models including ServerLog")

18
app/models/player.py Normal file
View File

@@ -0,0 +1,18 @@
from extensions import db
from flask_bcrypt import Bcrypt
bcrypt = Bcrypt()
class Player(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(255), nullable=False)
hostname = db.Column(db.String(255), nullable=False)
password = db.Column(db.String(255), nullable=False)
quickconnect_password = db.Column(db.String(255), nullable=True)
playlist_version = db.Column(db.Integer, default=1)
locked_to_group_id = db.Column(db.Integer, db.ForeignKey('group.id'), nullable=True)
locked_to_group = db.relationship('Group', foreign_keys=[locked_to_group_id], backref='locked_players')
orientation = db.Column(db.String(16), nullable=False, default='Landscape') # <-- Add this line
def verify_quickconnect_code(self, code):
return bcrypt.check_password_hash(self.quickconnect_password, code)

10
app/models/server_log.py Normal file
View File

@@ -0,0 +1,10 @@
from extensions import db
import datetime
class ServerLog(db.Model):
id = db.Column(db.Integer, primary_key=True)
action = db.Column(db.String(255), nullable=False)
timestamp = db.Column(db.DateTime, default=datetime.datetime.utcnow)
def __repr__(self):
return f"<ServerLog {self.action}>"

33
app/models/user.py Normal file
View File

@@ -0,0 +1,33 @@
from extensions import db
from flask_bcrypt import Bcrypt
from flask_login import UserMixin
bcrypt = Bcrypt()
class User(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
password = db.Column(db.String(120), nullable=False)
role = db.Column(db.String(80), nullable=False)
theme = db.Column(db.String(80), default='light')
def set_password(self, password):
self.password = bcrypt.generate_password_hash(password).decode('utf-8')
def check_password(self, password):
return bcrypt.check_password_hash(self.password, password)
@property
def is_active(self):
return True
@property
def is_authenticated(self):
return True
@property
def is_anonymous(self):
return False
def get_id(self):
return str(self.id)

46
app/requirements.txt Executable file
View File

@@ -0,0 +1,46 @@
# Core Flask
Flask==3.1.0
Werkzeug==3.1.3
Jinja2==3.1.5
itsdangerous==2.2.0
click==8.1.8
blinker==1.9.0
# Flask Extensions
Flask-SQLAlchemy==3.1.1
Flask-Migrate==4.1.0
Flask-Bcrypt==1.0.1
Flask-Login==0.6.3
# Database
SQLAlchemy==2.0.37
alembic==1.14.1
Mako==1.3.8
greenlet==3.1.1
# File Processing
pdf2image==1.17.0
PyPDF2==3.0.1
Pillow==10.0.1
cairosvg==2.7.0
ffmpeg-python==0.2.0
python-magic==0.4.27
# Security
bcrypt==4.2.1
Flask-Talisman==1.1.0
Flask-Cors==4.0.0
# Production Server
gunicorn==20.1.0
gevent==23.9.1
# Monitoring & Performance
prometheus-flask-exporter==0.22.4
sentry-sdk[flask]==1.40.0
psutil==6.1.0
# Utilities
typing_extensions==4.12.2
MarkupSafe==3.0.2
python-dotenv==1.0.1

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

BIN
app/static/resurse/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Add Player</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body.dark-mode {
background-color: #121212;
color: #ffffff;
}
.card.dark-mode {
background-color: #1e1e1e;
color: #ffffff;
}
.dark-mode label, .dark-mode th, .dark-mode td {
color: #ffffff;
}
@media (max-width: 768px) {
h1 {
font-size: 1.5rem;
}
.btn {
font-size: 0.9rem;
padding: 0.5rem 1rem;
}
}
</style>
</head>
<body class="{{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="container py-5">
<h1 class="text-center mb-4">Add Player</h1>
<form method="POST" action="{{ url_for('add_player') }}">
<div class="row">
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="username" class="form-label">Player Name</label>
<input type="text" class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="username" name="username" required>
</div>
</div>
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="hostname" class="form-label">Hostname</label>
<input type="text" class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="hostname" name="hostname" required>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="password" name="password" required>
</div>
</div>
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="quickconnect_password" class="form-label">Quick Connect Password</label>
<input type="password" class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="quickconnect_password" name="quickconnect_password" required>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="orientation" class="form-label">Orientation</label>
<select class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="orientation" name="orientation" required>
<option value="Landscape" selected>Landscape</option>
<option value="Portret">Portret</option>
</select>
</div>
</div>
</div>
<div class="text-center">
<button type="submit" class="btn btn-primary">Add Player</button>
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary mt-3">Back to Dashboard</a>
</div>
</form>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

391
app/templates/admin.html Normal file
View File

@@ -0,0 +1,391 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Panel</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body.dark-mode {
background-color: #121212;
color: #ffffff;
}
.card.dark-mode {
background-color: #1e1e1e;
color: #ffffff;
}
.dark-mode label, .dark-mode th, .dark-mode td {
color: #ffffff;
}
.img-preview {
max-width: 100px;
max-height: 100px;
}
.popup-message {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(0, 0, 0, 0.8);
color: white;
padding: 20px;
border-radius: 10px;
display: none;
z-index: 1000;
}
.logo {
max-height: 100px;
margin-right: 20px;
}
@media (max-width: 768px) {
.logo {
max-height: 50px;
margin-right: 10px;
}
h1 {
font-size: 1.5rem;
}
.btn {
font-size: 0.9rem;
padding: 0.5rem 1rem;
}
.card {
margin-bottom: 1rem;
}
}
</style>
</head>
<body class="{{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="container py-5">
<div class="d-flex justify-content-start align-items-center mb-4">
{% if logo_exists %}
<img src="{{ url_for('static', filename='resurse/logo.png') }}" alt="Logo" class="logo">
{% endif %}
<h1 class="mb-0">Admin Panel</h1>
</div>
<!-- Manage Users Card -->
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="card-header">
<h2>Manage Users</h2>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-5">
<h3>Manage User Roles</h3>
<table class="table">
<thead>
<tr>
<th>Username</th>
<th>Role</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.username }}</td>
<td>{{ user.role }}</td>
<td>
<form action="{{ url_for('change_role', user_id=user.id) }}" method="post" class="d-inline">
<select name="role" class="form-select d-inline-block" style="width: auto;">
<option value="user" {% if user.role == 'user' %}selected{% endif %}>User</option>
<option value="admin" {% if user.role == 'admin' %}selected{% endif %}>Admin</option>
</select>
<button type="submit" class="btn btn-sm btn-primary">Change Role</button>
</form>
<form action="{{ url_for('delete_user', user_id=user.id) }}" method="post" class="d-inline">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Are you sure you want to delete this user?');">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="col-md-1 d-flex justify-content-center">
<div class="vr"></div>
</div>
<div class="col-md-6">
<h3>Add User</h3>
<form action="{{ url_for('create_user') }}" method="post">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="password" name="password" required>
</div>
<div class="mb-3">
<label for="role" class="form-label">Role</label>
<select class="form-select {{ 'dark-mode' if theme == 'dark' else '' }}" id="role" name="role" required>
<option value="admin">Admin</option>
<option value="user">User</option>
</select>
</div>
<button type="submit" class="btn btn-primary">Add User</button>
</form>
</div>
</div>
</div>
</div>
<!-- Upload Personalization Photos Card -->
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="card-header">
<h2>Upload Personalization Photos</h2>
</div>
<div class="card-body">
<form action="{{ url_for('upload_personalization_pictures') }}" method="post" enctype="multipart/form-data">
<div class="row mb-3">
<div class="col-md-6">
<label for="logo" class="form-label">Current Logo</label>
{% if logo_exists %}
<img src="{{ url_for('static', filename='resurse/logo.png') }}" alt="Current Logo" class="img-thumbnail img-preview mb-3">
{% endif %}
<input type="file" class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="logo" name="logo">
</div>
<div class="col-md-6">
<label for="login_picture" class="form-label">Current Login Page Picture</label>
{% if login_picture_exists %}
<img src="{{ url_for('static', filename='resurse/login_picture.png') }}" alt="Current Login Picture" class="img-thumbnail img-preview mb-3">
{% endif %}
<input type="file" class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="login_picture" name="login_picture">
</div>
</div>
<button type="submit" class="btn btn-primary">Upload Pictures</button>
</form>
</div>
</div>
<div class="row">
<div class="col-lg-6 col-12">
<!-- Change Theme Card -->
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="card-header">
<h2>Change Theme</h2>
</div>
<div class="card-body">
<form action="{{ url_for('change_theme') }}" method="post" onsubmit="showPopupMessage('Theme changed successfully!')">
<div class="mb-3">
<label for="theme" class="form-label">Select Theme</label>
<select class="form-select" id="theme" name="theme" required>
<option value="light" {% if theme == 'light' %}selected{% endif %}>Light</option>
<option value="dark" {% if theme == 'dark' %}selected{% endif %}>Dark</option>
</select>
</div>
<button type="submit" class="btn btn-primary">Change Theme</button>
</form>
</div>
</div>
</div>
<div class="col-lg-6 col-12">
<!-- Clean Unused Files Card -->
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="card-header">
<h2>Clean Unused Files</h2>
</div>
<div class="card-body">
<form action="{{ url_for('clean_unused_files') }}" method="post" onsubmit="showPopupMessage('Clean script executed successfully!')">
<button type="submit" class="btn btn-danger">Run Clean Script</button>
</form>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-6 col-12">
<!-- Server Info Card -->
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="card-header">
<h2>Server Info</h2>
</div>
<div class="card-body">
<p><strong>Server Version:</strong> {{ server_version }}</p>
<p><strong>Date of Build:</strong> {{ build_date }}</p>
</div>
</div>
<!-- System Monitoring Card -->
{% if system_info %}
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="card-header">
<h2>📊 System Monitoring</h2>
</div>
<div class="card-body">
<div class="row">
<!-- CPU Information -->
<div class="col-md-3 col-6 text-center mb-3">
<div class="h6">CPU Usage</div>
<div class="progress mb-2" style="height: 25px;">
<div class="progress-bar
{% if system_info.cpu_percent < 50 %}bg-success
{% elif system_info.cpu_percent < 80 %}bg-warning
{% else %}bg-danger{% endif %}"
role="progressbar"
style="width: {{ system_info.cpu_percent }}%;">
{{ system_info.cpu_percent }}%
</div>
</div>
<small class="text-muted">{{ system_info.cpu_count }} cores available</small>
</div>
<!-- Memory Information -->
<div class="col-md-3 col-6 text-center mb-3">
<div class="h6">Memory Usage</div>
<div class="progress mb-2" style="height: 25px;">
<div class="progress-bar
{% if system_info.memory_percent < 60 %}bg-success
{% elif system_info.memory_percent < 85 %}bg-warning
{% else %}bg-danger{% endif %}"
role="progressbar"
style="width: {{ system_info.memory_percent }}%;">
{{ system_info.memory_percent }}%
</div>
</div>
<small class="text-muted">{{ system_info.memory_used }}GB / {{ system_info.memory_total }}GB</small>
</div>
<!-- Disk Information -->
<div class="col-md-3 col-6 text-center mb-3">
<div class="h6">Disk Usage</div>
<div class="progress mb-2" style="height: 25px;">
<div class="progress-bar
{% if system_info.disk_percent < 70 %}bg-success
{% elif system_info.disk_percent < 90 %}bg-warning
{% else %}bg-danger{% endif %}"
role="progressbar"
style="width: {{ system_info.disk_percent }}%;">
{{ system_info.disk_percent }}%
</div>
</div>
<small class="text-muted">{{ system_info.disk_used }}GB / {{ system_info.disk_total }}GB</small>
</div>
<!-- Upload Folder Size -->
<div class="col-md-3 col-6 text-center mb-3">
<div class="h6">Media Storage</div>
<div class="text-primary display-6">{{ system_info.upload_folder_size }}GB</div>
<small class="text-muted">Total media files</small>
</div>
</div>
<!-- System Details -->
<div class="row mt-3">
<div class="col-12">
<hr>
<div class="row text-center">
<div class="col-md-4 col-12 mb-2">
<strong>Available Disk Space:</strong><br>
<span class="text-success">{{ system_info.disk_free }}GB free</span>
</div>
<div class="col-md-4 col-12 mb-2">
<strong>Total Disk Space:</strong><br>
<span class="text-info">{{ system_info.disk_total }}GB total</span>
</div>
<div class="col-md-4 col-12 mb-2">
<strong>Last Updated:</strong><br>
<span class="text-muted" id="last-update-admin">Just now</span>
</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
<div class="mt-4">
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">Back to Dashboard</a>
</div>
</div>
<div id="popup-message" class="popup-message"></div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
<script>
function showPopupMessage(message) {
const popup = document.getElementById('popup-message');
popup.textContent = message;
popup.style.display = 'block';
setTimeout(() => {
popup.style.display = 'none';
}, 5000);
}
// Auto-refresh system monitoring every 15 seconds
{% if system_info %}
function updateAdminSystemInfo() {
fetch('/api/system_info')
.then(response => response.json())
.then(data => {
if (data.error) {
console.warn('Could not fetch system info:', data.error);
return;
}
// Update progress bars and their colors
const progressBars = document.querySelectorAll('.progress-bar');
if (progressBars.length >= 3) {
// CPU Bar
progressBars[0].style.width = data.cpu_percent + '%';
progressBars[0].textContent = data.cpu_percent + '%';
progressBars[0].className = 'progress-bar ' +
(data.cpu_percent < 50 ? 'bg-success' :
data.cpu_percent < 80 ? 'bg-warning' : 'bg-danger');
// Memory Bar
progressBars[1].style.width = data.memory_percent + '%';
progressBars[1].textContent = data.memory_percent + '%';
progressBars[1].className = 'progress-bar ' +
(data.memory_percent < 60 ? 'bg-success' :
data.memory_percent < 85 ? 'bg-warning' : 'bg-danger');
// Disk Bar
progressBars[2].style.width = data.disk_percent + '%';
progressBars[2].textContent = data.disk_percent + '%';
progressBars[2].className = 'progress-bar ' +
(data.disk_percent < 70 ? 'bg-success' :
data.disk_percent < 90 ? 'bg-warning' : 'bg-danger');
}
// Update text values
const smallTexts = document.querySelectorAll('.text-muted');
smallTexts.forEach((text, index) => {
if (index === 1) text.textContent = data.memory_used + 'GB / ' + data.memory_total + 'GB';
if (index === 2) text.textContent = data.disk_used + 'GB / ' + data.disk_total + 'GB';
});
// Update storage size
const storageDisplay = document.querySelector('.display-6');
if (storageDisplay) {
storageDisplay.textContent = data.upload_folder_size + 'GB';
}
// Update disk space info
const diskFree = document.querySelector('.text-success');
const diskTotal = document.querySelector('.text-info');
if (diskFree) diskFree.textContent = data.disk_free + 'GB free';
if (diskTotal) diskTotal.textContent = data.disk_total + 'GB total';
// Update timestamp
const lastUpdate = document.getElementById('last-update-admin');
if (lastUpdate) {
lastUpdate.textContent = new Date().toLocaleTimeString();
}
})
.catch(error => {
console.warn('Admin system monitoring update failed:', error);
});
}
// Update every 15 seconds
setInterval(updateAdminSystemInfo, 15000);
{% endif %}
</script>
</body>
</html>

View File

@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Create Group</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body.dark-mode {
background-color: #121212;
color: #ffffff;
}
.card.dark-mode {
background-color: #1e1e1e;
color: #ffffff;
}
.dark-mode label, .dark-mode th, .dark-mode td {
color: #ffffff;
}
@media (max-width: 768px) {
h1 {
font-size: 1.5rem;
}
.btn {
font-size: 0.9rem;
padding: 0.5rem 1rem;
}
}
</style>
</head>
<body class="{{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="container py-5">
<h1 class="text-center mb-4">Create Group</h1>
<form method="POST" action="{{ url_for('create_group') }}">
<div class="row">
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="name" class="form-label">Group Name</label>
<input type="text" class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="name" name="name" required>
</div>
</div>
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="players" class="form-label">Select Players</label>
<select multiple class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="players" name="players">
{% for player in players %}
<option value="{{ player.id }}">{{ player.username }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="orientation" class="form-label">Group Orientation</label>
<select class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="orientation" name="orientation" required>
<option value="Landscape" selected>Landscape</option>
<option value="Portret">Portret</option>
</select>
</div>
</div>
</div>
<div class="alert alert-warning" role="alert">
<strong>Warning:</strong> Adding players to a group will delete their individual playlists.
All players in a group will share the same content.
</div>
<div id="orientation-warning" class="alert alert-danger d-none" role="alert">
No players with the selected orientation are available.
</div>
<div class="text-center">
<button type="submit" class="btn btn-primary">Create Group</button>
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary mt-3">Back to Dashboard</a>
</div>
</form>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Get all players and their orientations from the backend
const players = [
{% for player in players %}
{id: {{ player.id }}, username: "{{ player.username }}", orientation: "{{ player.orientation }}"},
{% endfor %}
];
const orientationSelect = document.getElementById('orientation');
const playersSelect = document.getElementById('players');
const orientationWarning = document.getElementById('orientation-warning');
function filterPlayers() {
const selectedOrientation = orientationSelect.value;
playersSelect.innerHTML = '';
let compatibleCount = 0;
players.forEach(player => {
if (player.orientation === selectedOrientation) {
const option = document.createElement('option');
option.value = player.id;
option.textContent = player.username;
playersSelect.appendChild(option);
compatibleCount++;
}
});
document.getElementById('orientation-warning').classList.toggle('d-none', compatibleCount > 0);
}
orientationSelect.addEventListener('change', filterPlayers);
// Initial filter on page load
filterPlayers();
</script>
</body>
</html>

View File

@@ -0,0 +1,173 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body.dark-mode {
background-color: #121212;
color: #ffffff;
}
.card.dark-mode {
background-color: #1e1e1e;
color: #ffffff;
}
.logo {
max-height: 100px;
margin-right: 20px;
}
@media (max-width: 768px) {
.logo {
max-height: 50px;
margin-right: 10px;
}
h1 {
font-size: 1.5rem;
}
.btn {
font-size: 0.9rem;
padding: 0.5rem 1rem;
}
.card {
margin-bottom: 1rem;
}
}
</style>
</head>
<body class="{{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="container py-5">
<div class="d-flex justify-content-start align-items-center mb-4">
{% if logo_exists %}
<img src="{{ url_for('static', filename='resurse/logo.png') }}" alt="Logo" class="logo">
{% endif %}
<h1 class="mb-0">Dashboard</h1>
</div>
<!-- Sign Out Button -->
<div class="text-end mb-4">
<a href="{{ url_for('logout') }}" class="btn btn-danger">Sign Out</a>
</div>
<div class="row">
<!-- Main Content: Players, Groups, Upload -->
<div class="col-lg-9 col-12">
<!-- Players Section -->
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="card-header bg-primary text-white">
<h2>Players</h2>
</div>
<div class="card-body">
<ul class="list-group">
{% for player in players %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong>{{ player.username }}</strong>
</div>
<div>
<a href="{{ url_for('player_page', player_id=player.id) }}" class="btn btn-sm btn-secondary">Manage Player</a>
<a href="{{ url_for('edit_player', player_id=player.id, return_url=url_for('dashboard')) }}" class="btn btn-sm btn-secondary">Edit Player</a>
<a href="{{ url_for('player_fullscreen', player_id=player.id) }}" class="btn btn-sm btn-primary">Full Screen</a>
{% if current_user.role == 'admin' %}
<form action="{{ url_for('delete_player', player_id=player.id) }}" method="post" style="display:inline;">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Are you sure you want to delete this player?');">Delete</button>
</form>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
{% if current_user.role == 'admin' %}
<div class="mt-3">
<a href="{{ url_for('add_player') }}" class="btn btn-success">Add Player</a>
</div>
{% endif %}
</div>
</div>
<!-- Group of Players Section -->
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="card-header bg-secondary text-white">
<h2>Group of Players</h2>
</div>
<div class="card-body">
<ul class="list-group">
{% for group in groups %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong>{{ group.name }}</strong>
</div>
<div>
<a href="{{ url_for('manage_group', group_id=group.id) }}" class="btn btn-sm btn-secondary">Manage Group</a>
<a href="{{ url_for('edit_group', group_id=group.id) }}" class="btn btn-sm btn-secondary">Edit Group</a>
<a href="{{ url_for('group_fullscreen', group_id=group.id) }}" class="btn btn-sm btn-primary">Full Screen</a>
<form action="{{ url_for('delete_group', group_id=group.id) }}" method="post" style="display:inline;">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Are you sure you want to delete this group?');">Delete</button>
</form>
</div>
</li>
{% endfor %}
</ul>
<div class="mt-3">
<a href="{{ url_for('create_group') }}" class="btn btn-success">Create Group</a>
</div>
</div>
</div>
<!-- Content Upload Section -->
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="card-header bg-warning text-dark">
<h2>Content Upload</h2>
</div>
<div class="card-body text-center">
<a href="{{ url_for('upload_content') }}" class="btn btn-warning btn-lg">Upload Content</a>
</div>
</div>
</div>
<!-- App Settings Section: Top right on desktop -->
{% if current_user.role == 'admin' %}
<div class="col-lg-3 col-12">
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="card-header bg-info text-white">
<h2>App Settings</h2>
</div>
<div class="card-body text-center">
<a href="{{ url_for('admin') }}" class="btn btn-info btn-lg">Go to Settings</a>
</div>
</div>
</div>
{% endif %}
</div>
<!-- Server Activity Log Section -->
{% if current_user.role == 'admin' %}
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="card-header bg-secondary text-white">
<h2>Server Activity Log</h2>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped {{ 'table-dark' if theme == 'dark' else '' }}">
<thead>
<tr>
<th>Time</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% for log in server_logs %}
<tr>
<td>{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</td>
<td>{{ log.action }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edit Group</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body.dark-mode {
background-color: #121212;
color: #ffffff;
}
.card.dark-mode {
background-color: #1e1e1e;
color: #ffffff;
}
.dark-mode label, .dark-mode th, .dark-mode td {
color: #ffffff;
}
@media (max-width: 768px) {
h1 {
font-size: 1.5rem;
}
.btn {
font-size: 0.9rem;
padding: 0.5rem 1rem;
}
}
</style>
</head>
<body class="{{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="container py-5">
<h1 class="text-center mb-4">Edit Group</h1>
<form method="POST">
<div class="row">
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="name" class="form-label">Group Name</label>
<input type="text" class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="name" name="name" value="{{ group.name }}" required>
</div>
</div>
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="players" class="form-label">Select Players</label>
<select multiple class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="players" name="players">
{% for player in players %}
<option value="{{ player.id }}" {% if player in group.players %}selected{% endif %}>{{ player.username }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="orientation" class="form-label">Group Orientation</label>
<select class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="orientation" name="orientation" required>
<option value="Landscape" {% if group.orientation == 'Landscape' %}selected{% endif %}>Landscape</option>
<option value="Portret" {% if group.orientation == 'Portret' %}selected{% endif %}>Portret</option>
</select>
</div>
</div>
</div>
<!-- Add this above the player selection -->
<div class="alert alert-warning" role="alert">
<strong>Warning:</strong> Adding new players to this group will delete their individual playlists.
Removing players from the group will allow them to have their own playlists again.
</div>
<div id="orientation-warning" class="alert alert-danger d-none" role="alert">
No players with the selected orientation are available.
</div>
<div class="text-center">
<button type="submit" class="btn btn-primary">Save Changes</button>
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary mt-3">Back to Dashboard</a>
</div>
</form>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Get all players and their orientations from the backend
const players = [
{% for player in players %}
{id: {{ player.id }}, username: "{{ player.username }}", orientation: "{{ player.orientation }}", inGroup: {% if player in group.players %}true{% else %}false{% endif %}},
{% endfor %}
];
const orientationSelect = document.getElementById('orientation');
const playersSelect = document.getElementById('players');
const orientationWarning = document.getElementById('orientation-warning');
function filterPlayers() {
const selectedOrientation = orientationSelect.value;
const currentSelection = Array.from(playersSelect.selectedOptions).map(option => option.value);
playersSelect.innerHTML = '';
let compatibleCount = 0;
players.forEach(player => {
if (player.orientation === selectedOrientation) {
const option = document.createElement('option');
option.value = player.id;
option.textContent = player.username;
// Re-select if it was previously selected
if (currentSelection.includes(player.id.toString()) || player.inGroup) {
option.selected = true;
}
playersSelect.appendChild(option);
compatibleCount++;
}
});
orientationWarning.classList.toggle('d-none', compatibleCount > 0);
}
orientationSelect.addEventListener('change', filterPlayers);
// Initial filter on page load
filterPlayers();
</script>
</body>
</html>

View File

@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edit Player</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body.dark-mode {
background-color: #121212;
color: #ffffff;
}
.card.dark-mode {
background-color: #1e1e1e;
color: #ffffff;
}
.dark-mode label, .dark-mode th, .dark-mode td {
color: #ffffff;
}
@media (max-width: 768px) {
h1 {
font-size: 1.5rem;
}
.btn {
font-size: 0.9rem;
padding: 0.5rem 1rem;
}
}
</style>
</head>
<body class="{% if theme == 'dark' %}dark-mode{% endif %}">
<div class="container py-5">
<h1 class="text-center mb-4">Edit Player</h1>
<form action="{{ url_for('edit_player', player_id=player.id) }}" method="post">
<div class="row">
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="username" class="form-label">Player Name</label>
<input type="text" class="form-control {% if theme == 'dark' %}dark-mode{% endif %}" id="username" name="username" value="{{ player.username }}" required>
</div>
</div>
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="hostname" class="form-label">Hostname</label>
<input type="text" class="form-control {% if theme == 'dark' %}dark-mode{% endif %}" id="hostname" name="hostname" value="{{ player.hostname }}" required>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="password" class="form-label">Password (leave blank to keep current password)</label>
<input type="password" class="form-control {% if theme == 'dark' %}dark-mode{% endif %}" id="password" name="password">
</div>
</div>
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="quickconnect_password" class="form-label">Quick Connect Password (leave blank to keep current password)</label>
<input type="password" class="form-control {% if theme == 'dark' %}dark-mode{% endif %}" id="quickconnect_password" name="quickconnect_password">
</div>
</div>
</div>
<div class="mb-3">
<label for="orientation" class="form-label">Orientation</label>
<select class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="orientation" name="orientation" required>
<option value="Landscape" {% if player.orientation == 'Landscape' %}selected{% endif %}>Landscape</option>
<option value="Portret" {% if player.orientation == 'Portret' %}selected{% endif %}>Portret</option>
</select>
</div>
<div class="text-center">
<button type="submit" class="btn btn-primary">Update Player</button>
<a href="{{ return_url }}" class="btn btn-secondary mt-3">Back to Player Page</a>
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary mt-3">Back to Dashboard</a>
</div>
</form>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,69 @@
<!DOCTYPE html>
<html>
<head>
<title>Group Fullscreen</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
background-color: black;
margin: 0;
overflow: hidden;
}
.content-item {
display: none;
width: 100vw;
height: 100vh;
object-fit: cover;
}
.content-item.active {
display: block;
}
</style>
</head>
<body>
<div id="content">
{% for item in content %}
{% if item.file_name.endswith('.mp4') %}
<video class="content-item" data-duration="{{ item.duration }}" controls>
<source src="{{ url_for('static', filename='uploads/' ~ item.file_name) }}" type="video/mp4">
Your browser does not support the video tag.
</video>
{% elif item.file_name.endswith('.pdf') %}
<object data="{{ url_for('static', filename='uploads/' ~ item.file_name) }}" type="application/pdf" class="content-item" data-duration="{{ item.duration }}"></object>
{% else %}
<img src="{{ url_for('static', filename='uploads/' ~ item.file_name) }}" class="content-item" data-duration="{{ item.duration }}" alt="{{ item.file_name }}">
{% endif %}
{% endfor %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const items = document.querySelectorAll('.content-item');
let currentIndex = 0;
function showNextItem() {
items.forEach(item => item.classList.remove('active'));
const currentItem = items[currentIndex];
currentItem.classList.add('active');
const duration = parseInt(currentItem.getAttribute('data-duration'), 10) * 1000;
if (currentItem.tagName === 'VIDEO') {
currentItem.play();
currentItem.onended = () => {
currentIndex = (currentIndex + 1) % items.length;
showNextItem();
};
} else {
setTimeout(() => {
currentIndex = (currentIndex + 1) % items.length;
showNextItem();
}, duration);
}
}
showNextItem();
});
</script>
</body>
</html>

59
app/templates/login.html Normal file
View File

@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body.dark-mode {
background-color: #121212;
color: #ffffff;
}
.card.dark-mode {
background-color: #1e1e1e;
color: #ffffff;
}
.login-picture {
max-width: 100%;
height: auto;
margin-bottom: 20px;
}
@media (max-width: 768px) {
h1 {
font-size: 1.5rem;
}
.btn {
font-size: 0.9rem;
padding: 0.5rem 1rem;
}
}
</style>
</head>
<body class="{{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-md-6 col-12 text-center">
{% if login_picture_exists %}
<img src="{{ url_for('static', filename='resurse/login_picture.png') }}" alt="Login Picture" class="login-picture">
{% endif %}
</div>
<div class="col-md-6 col-12">
<h1 class="text-center mb-4">Login</h1>
<form action="{{ url_for('login') }}" method="post">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary w-100">Login</button>
</form>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,318 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Manage Group</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body.dark-mode {
background-color: #121212;
color: #ffffff;
}
.card.dark-mode {
background-color: #1e1e1e;
color: #ffffff;
}
.dark-mode label, .dark-mode th, .dark-mode td {
color: #ffffff;
}
@media (max-width: 768px) {
h1 {
font-size: 1.5rem;
}
.btn {
font-size: 0.9rem;
padding: 0.5rem 1rem;
}
.card {
margin-bottom: 1rem;
}
}
.sortable-list li {
cursor: move;
transition: background-color 0.2s ease;
}
.sortable-list li.dragging {
opacity: 0.5;
background-color: #f8f9fa;
}
.drag-handle {
cursor: grab;
color: #aaa;
font-size: 1.2rem;
}
.drag-over {
border-top: 2px solid #0d6efd;
}
</style>
</head>
<body class="{{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="container py-5">
<h1 class="text-center mb-4">Manage Group: {{ group.name }}</h1>
<!-- Group Information Card -->
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="card-header bg-info text-white">
<h2>Group Info</h2>
</div>
<div class="card-body">
<p><strong>Group Name:</strong> {{ group.name }}</p>
<p><strong>Number of Players:</strong> {{ group.players|length }}</p>
</div>
</div>
<!-- List of Players in the Group -->
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="card-header bg-secondary text-white">
<h2>Players in Group</h2>
</div>
<div class="card-body">
<ul class="list-group">
{% for player in group.players %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong>{{ player.username }}</strong> ({{ player.hostname }})
</div>
</li>
{% endfor %}
</ul>
</div>
</div>
<!-- Manage Media Section -->
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="card-header bg-info text-white">
<h2>Manage Media</h2>
</div>
<div class="card-body">
{% if content %}
<!-- Bulk Actions Controls -->
<div class="row mb-3">
<div class="col-md-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="selectAll">
<label class="form-check-label" for="selectAll">
Select All
</label>
</div>
</div>
<div class="col-md-6 text-end">
<button type="button" class="btn btn-danger" id="bulkDeleteBtn" style="display:none;" onclick="confirmBulkDelete()">
<i class="bi bi-trash"></i> Delete Selected
</button>
</div>
</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 class="form-check-input media-checkbox"
type="checkbox"
name="selected_content"
value="{{ media.id }}">
</div>
<!-- Drag handle -->
<div class="drag-handle me-2" title="Drag to reorder">
<i class="bi bi-grip-vertical"></i>
&#9776;
</div>
<div class="flex-grow-1">
<p class="mb-0"><strong>Media Name:</strong> {{ media.file_name }}</p>
</div>
<form action="{{ url_for('edit_group_media', group_id=group.id, content_id=media.id) }}" method="post" class="d-flex align-items-center">
<div class="input-group me-2">
<span class="input-group-text">seconds</span>
<input type="number" class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" name="duration" value="{{ media.duration }}" required>
</div>
<button type="submit" class="btn btn-warning me-2">Edit</button>
</form>
<form action="{{ url_for('delete_group_media', group_id=group.id, content_id=media.id) }}" method="post" style="display:inline;">
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete this media?');">Delete</button>
</form>
</li>
{% endfor %}
</ul>
<!-- Add a save button for the reordering -->
<button id="saveGroupOrder" class="btn btn-success mt-3">Save Playlist Order</button>
{% else %}
<p class="text-center">No media uploaded for this group.</p>
{% endif %}
</div>
</div>
<!-- Upload Media Button -->
<div class="text-center mb-4">
<a href="{{ url_for('upload_content', target_type='group', target_id=group.id, return_url=url_for('manage_group', group_id=group.id)) }}" class="btn btn-primary btn-lg">Go to Upload Media</a>
</div>
<!-- Back to Dashboard Button -->
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">Back to Dashboard</a>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const groupMediaList = document.getElementById('groupMediaList');
let draggedItem = null;
// Initialize drag events for all items
const items = groupMediaList.querySelectorAll('li');
items.forEach(item => {
// Drag start
item.addEventListener('dragstart', function(e) {
draggedItem = item;
setTimeout(() => {
item.classList.add('dragging');
}, 0);
});
// Drag end
item.addEventListener('dragend', function() {
item.classList.remove('dragging');
draggedItem = null;
updatePositions();
});
// Drag over
item.addEventListener('dragover', function(e) {
e.preventDefault();
if (item !== draggedItem) {
const rect = item.getBoundingClientRect();
const y = e.clientY - rect.top;
const height = rect.height;
if (y < height / 2) {
groupMediaList.insertBefore(draggedItem, item);
} else {
groupMediaList.insertBefore(draggedItem, item.nextSibling);
}
}
});
});
// Save button click handler
document.getElementById('saveGroupOrder').addEventListener('click', function() {
// Collect new order
const newOrder = [];
groupMediaList.querySelectorAll('li').forEach((item, index) => {
newOrder.push({
id: item.dataset.id,
position: index
});
});
// Send to server
fetch('{{ url_for("update_group_content_order_route", group_id=group.id) }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() if csrf_token else "" }}'
},
body: JSON.stringify({items: newOrder})
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Playlist order updated successfully!');
console.log('Group playlist update successful:', data);
} else {
alert('Error updating playlist order: ' + (data.error || 'Unknown error'));
console.error('Failed to update group playlist:', data);
}
})
.catch(error => {
console.error('Error:', error);
alert('An error occurred while updating the playlist order.');
});
});
// Update positions in the UI
function updatePositions() {
groupMediaList.querySelectorAll('li').forEach((item, index) => {
item.dataset.position = index;
});
}
// Bulk selection functionality
const selectAllCheckbox = document.getElementById('selectAll');
const mediaCheckboxes = document.querySelectorAll('.media-checkbox');
const bulkDeleteBtn = document.getElementById('bulkDeleteBtn');
// Select all functionality
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', function() {
mediaCheckboxes.forEach(checkbox => {
checkbox.checked = this.checked;
});
updateBulkDeleteButton();
});
}
// Individual checkbox change
mediaCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
updateSelectAllState();
updateBulkDeleteButton();
});
});
function updateSelectAllState() {
const checkedBoxes = Array.from(mediaCheckboxes).filter(cb => cb.checked);
if (selectAllCheckbox) {
selectAllCheckbox.checked = checkedBoxes.length === mediaCheckboxes.length && mediaCheckboxes.length > 0;
selectAllCheckbox.indeterminate = checkedBoxes.length > 0 && checkedBoxes.length < mediaCheckboxes.length;
}
}
function updateBulkDeleteButton() {
const checkedBoxes = Array.from(mediaCheckboxes).filter(cb => cb.checked);
if (bulkDeleteBtn) {
bulkDeleteBtn.style.display = checkedBoxes.length > 0 ? 'inline-block' : 'none';
}
}
});
function confirmBulkDelete() {
const checkedBoxes = Array.from(document.querySelectorAll('.media-checkbox:checked'));
if (checkedBoxes.length === 0) {
alert('No media files selected.');
return;
}
const count = checkedBoxes.length;
const message = `Are you sure you want to delete ${count} selected media file${count > 1 ? 's' : ''}? This action cannot be undone.`;
if (confirm(message)) {
// Create a form with selected IDs
const form = document.createElement('form');
form.method = 'POST';
form.action = '{{ url_for("bulk_delete_group_content", group_id=group.id) }}';
checkedBoxes.forEach(checkbox => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'selected_content';
input.value = checkbox.value;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html>
<head>
<title>Player Authentication</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body.dark-mode {
background-color: #121212;
color: #ffffff;
}
.card.dark-mode {
background-color: #1e1e1e;
color: #ffffff;
}
.dark-mode label, .dark-mode th, .dark-mode td {
color: #ffffff;
}
</style>
</head>
<body class="{{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="container py-5">
<h1 class="text-center mb-4">Player Authentication</h1>
<form action="{{ url_for('player_fullscreen', player_id=player_id) }}" method="post">
<div class="mb-3">
<label for="hostname" class="form-label">Hostname</label>
<input type="text" class="form-control" id="hostname" name="hostname" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="mb-3">
<label for="quickconnect_password" class="form-label">Quick Connect Password</label>
<input type="password" class="form-control" id="quickconnect_password" name="quickconnect_password">
</div>
<button type="submit" class="btn btn-primary">Authenticate</button>
</form>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html>
<head>
<title>Player Fullscreen</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
background-color: black;
margin: 0;
overflow: hidden;
}
.content-item {
display: none;
width: 100vw;
height: 100vh;
object-fit: cover;
}
.content-item.active {
display: block;
}
</style>
</head>
<body>
<div id="content">
{% for item in content %}
{% if item.file_name.endswith('.mp4') %}
<video class="content-item" data-duration="{{ item.duration }}" controls>
<source src="{{ url_for('static', filename='uploads/' ~ item.file_name) }}" type="video/mp4">
Your browser does not support the video tag.
</video>
{% elif item.file_name.endswith('.pdf') %}
<object data="{{ url_for('static', filename='uploads/' ~ item.file_name) }}" type="application/pdf" class="content-item" data-duration="{{ item.duration }}"></object>
{% else %}
<img src="{{ url_for('static', filename='uploads/' ~ item.file_name) }}" class="content-item" data-duration="{{ item.duration }}" alt="{{ item.file_name }}">
{% endif %}
{% endfor %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const items = document.querySelectorAll('.content-item');
let currentIndex = 0;
function showNextItem() {
items.forEach(item => item.classList.remove('active'));
const currentItem = items[currentIndex];
currentItem.classList.add('active');
const duration = parseInt(currentItem.getAttribute('data-duration'), 10) * 1000;
if (currentItem.tagName === 'VIDEO') {
currentItem.play();
currentItem.onended = () => {
currentIndex = (currentIndex + 1) % items.length;
showNextItem();
};
} else {
setTimeout(() => {
currentIndex = (currentIndex + 1) % items.length;
showNextItem();
}, duration);
}
}
showNextItem();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,343 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Player Schedule</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body.dark-mode {
background-color: #121212;
color: #ffffff;
}
.card.dark-mode {
background-color: #1e1e1e;
color: #ffffff;
}
.dark-mode label, .dark-mode th, .dark-mode td {
color: #ffffff;
}
@media (max-width: 768px) {
h1 {
font-size: 1.5rem;
}
.btn {
font-size: 0.9rem;
padding: 0.5rem 1rem;
}
.card {
margin-bottom: 1rem;
}
}
.sortable-list li {
cursor: move;
transition: background-color 0.2s ease;
}
.sortable-list li.dragging {
opacity: 0.5;
background-color: #f8f9fa;
}
.drag-handle {
cursor: grab;
color: #aaa;
font-size: 1.2rem;
}
.drag-over {
border-top: 2px solid #0d6efd;
}
</style>
</head>
<body class="{% if theme == 'dark' %}dark-mode{% endif %}">
<div class="container py-5">
<h1 class="text-center mb-4">Player Schedule for {{ player.username }}</h1>
<!-- Player Info Section -->
<div class="card mb-4 {% if theme == 'dark' %}dark-mode{% endif %}">
<div class="card-header bg-info text-white">
<h2>Player Info</h2>
</div>
<div class="card-body">
<p><strong>Player Name:</strong> {{ player.username }}</p>
<p><strong>Hostname:</strong> {{ player.hostname }}</p>
{% if current_user.role == 'admin' %}
<a href="{{ url_for('edit_player', player_id=player.id, return_url=url_for('player_page', player_id=player.id)) }}" class="btn btn-warning">Update</a>
<form action="{{ url_for('delete_player', player_id=player.id) }}" method="post" style="display:inline;">
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete this player?');">Delete</button>
</form>
{% endif %}
</div>
</div>
<!-- Group Membership Section -->
<div class="mb-4">
{% if player.groups %}
<h4 class="text-center">Member of Group(s):</h4>
<ul class="list-group">
{% for group in player.groups %}
<li class="list-group-item {% if theme == 'dark' %}dark-mode{% endif %}">{{ group.name }}</li>
{% endfor %}
</ul>
{% else %}
<p class="text-center">This player is not a member of any groups.</p>
{% endif %}
</div>
<!-- Media Management Section -->
{% if current_user.role == 'admin' %}
<div class="card mb-4 {% if theme == 'dark' %}dark-mode{% endif %}">
<div class="card-header bg-info text-white">
<h2>Manage Media</h2>
</div>
<div class="card-body">
{% if content %}
<!-- Bulk Actions Controls -->
<div class="row mb-3">
<div class="col-md-6">
<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>
</div>
<div class="col-md-6 text-end">
<button type="button" class="btn btn-danger" id="bulkDeleteBtn" {% if player.groups %}disabled{% endif %} style="display:none;" onclick="confirmBulkDelete()">
<i class="bi bi-trash"></i> Delete Selected
</button>
</div>
</div>
<!-- Bulk Delete Form -->
<form id="bulkDeleteForm" action="{{ url_for('bulk_delete_player_content', player_id=player.id) }}" method="post" style="display:none;">
<input type="hidden" name="selected_content_ids" id="selectedContentIds">
</form>
<ul class="list-group sortable-list" id="mediaList">
{% for media in content %}
<li class="list-group-item {% if theme == 'dark' %}dark-mode{% endif %}"
draggable="true"
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 class="form-check-input media-checkbox"
type="checkbox"
name="selected_content"
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>
&#9776;
</div>
<!-- Media Thumbnail and Name -->
<div class="flex-grow-1 mb-2 mb-md-0 d-flex align-items-center">
<img src="{{ url_for('static', filename='uploads/' ~ media.file_name) }}"
alt="thumbnail"
style="width: 48px; height: 48px; object-fit: cover; margin-right: 10px; border-radius: 4px;"
onerror="this.style.display='none';">
<p class="mb-0"><strong>Media Name:</strong> {{ media.file_name }}</p>
</div>
<!-- Actions -->
<div class="d-flex flex-wrap justify-content-start">
<form action="{{ url_for('edit_content', content_id=media.id) }}" method="post" class="d-flex align-items-center me-2 mb-2">
<div class="input-group">
<span class="input-group-text">seconds</span>
<input type="number" class="form-control {% if theme == 'dark' %}dark-mode{% endif %}" name="duration" value="{{ media.duration }}" {% if player.groups %}disabled{% endif %} required>
</div>
<button type="submit" class="btn btn-warning ms-2" {% if player.groups %}disabled{% endif %}>Edit</button>
</form>
<form action="{{ url_for('delete_content', content_id=media.id) }}" method="post" class="mb-2">
<button type="submit" class="btn btn-danger" {% if player.groups %}disabled{% endif %} onclick="return confirm('Are you sure you want to delete this media?');">Delete</button>
</form>
</div>
</div>
</li>
{% endfor %}
</ul>
<!-- Add a save button for the reordering -->
<button id="saveOrder" class="btn btn-success mt-3" {% if player.groups %}disabled{% endif %}>Save Playlist Order</button>
{% else %}
<p class="text-center">No media uploaded for this player.</p>
{% endif %}
</div>
</div>
{% endif %}
<!-- Action Buttons -->
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">Back to Dashboard</a>
<a href="{{ url_for('player_fullscreen', player_id=player.id) }}" class="btn btn-primary">Full Screen</a>
<a href="{{ url_for('upload_content', target_type='player', target_id=player.id, return_url=url_for('player_page', player_id=player.id)) }}"
class="btn btn-success"
{% if player.groups %}disabled onclick="return false;"{% endif %}>
{% if player.groups %}Manage Media by Group{% else %}Upload Media{% endif %}
</a>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Only enable if the player is not in a group (if the buttons are not disabled)
if (!document.querySelector('#saveOrder').hasAttribute('disabled')) {
const mediaList = document.getElementById('mediaList');
let draggedItem = null;
// Initialize drag events for all items
const items = mediaList.querySelectorAll('li');
items.forEach(item => {
// Drag start
item.addEventListener('dragstart', function(e) {
draggedItem = item;
setTimeout(() => {
item.classList.add('dragging');
}, 0);
});
// Drag end
item.addEventListener('dragend', function() {
item.classList.remove('dragging');
draggedItem = null;
updatePositions();
});
// Drag over
item.addEventListener('dragover', function(e) {
e.preventDefault();
if (item !== draggedItem) {
const rect = item.getBoundingClientRect();
const y = e.clientY - rect.top;
const height = rect.height;
if (y < height / 2) {
mediaList.insertBefore(draggedItem, item);
} else {
mediaList.insertBefore(draggedItem, item.nextSibling);
}
}
});
});
// Save button click handler
document.getElementById('saveOrder').addEventListener('click', function() {
// Collect new order
const newOrder = [];
mediaList.querySelectorAll('li').forEach((item, index) => {
newOrder.push({
id: item.dataset.id,
position: index
});
});
// Send to server
fetch('{{ url_for("update_content_order", player_id=player.id) }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() if csrf_token else "" }}'
},
body: JSON.stringify({items: newOrder})
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Playlist order updated successfully!');
console.log('Playlist version updated to:', data.new_version);
} else {
alert('Error updating playlist order: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
console.error('Error:', error);
alert('An error occurred while updating the playlist order.');
});
});
// Update positions in the UI
function updatePositions() {
mediaList.querySelectorAll('li').forEach((item, index) => {
item.dataset.position = index;
});
}
}
// Bulk selection functionality
const selectAllCheckbox = document.getElementById('selectAll');
const mediaCheckboxes = document.querySelectorAll('.media-checkbox');
const bulkDeleteBtn = document.getElementById('bulkDeleteBtn');
// Select all functionality
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', function() {
mediaCheckboxes.forEach(checkbox => {
if (!checkbox.disabled) {
checkbox.checked = this.checked;
}
});
updateBulkDeleteButton();
});
}
// Individual checkbox change
mediaCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
updateSelectAllState();
updateBulkDeleteButton();
});
});
function updateSelectAllState() {
const enabledCheckboxes = Array.from(mediaCheckboxes).filter(cb => !cb.disabled);
const checkedBoxes = enabledCheckboxes.filter(cb => cb.checked);
if (selectAllCheckbox) {
selectAllCheckbox.checked = checkedBoxes.length === enabledCheckboxes.length && enabledCheckboxes.length > 0;
selectAllCheckbox.indeterminate = checkedBoxes.length > 0 && checkedBoxes.length < enabledCheckboxes.length;
}
}
function updateBulkDeleteButton() {
const checkedBoxes = Array.from(mediaCheckboxes).filter(cb => cb.checked);
if (bulkDeleteBtn) {
bulkDeleteBtn.style.display = checkedBoxes.length > 0 ? 'inline-block' : 'none';
}
}
});
function confirmBulkDelete() {
const checkedBoxes = Array.from(document.querySelectorAll('.media-checkbox:checked'));
if (checkedBoxes.length === 0) {
alert('No media files selected.');
return;
}
const count = checkedBoxes.length;
const message = `Are you sure you want to delete ${count} selected media file${count > 1 ? 's' : ''}? This action cannot be undone.`;
if (confirm(message)) {
// Create a form with selected IDs
const form = document.createElement('form');
form.method = 'POST';
form.action = '{{ url_for("bulk_delete_player_content", player_id=player.id) }}';
checkedBoxes.forEach(checkbox => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'selected_content';
input.value = checkbox.value;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Register</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body.dark-mode {
background-color: #121212;
color: #ffffff;
}
.card.dark-mode {
background-color: #1e1e1e;
color: #ffffff;
}
.dark-mode label, .dark-mode th, .dark-mode td {
color: #ffffff;
}
@media (max-width: 768px) {
h1 {
font-size: 1.5rem;
}
.btn {
font-size: 0.9rem;
padding: 0.5rem 1rem;
}
}
</style>
</head>
<body class="{{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="container py-5">
<h1 class="text-center mb-4">Register</h1>
<form action="{{ url_for('register') }}" method="post">
<div class="row">
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="username" name="username" required>
</div>
</div>
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="password" name="password" required>
</div>
</div>
</div>
<div class="text-center">
<button type="submit" class="btn btn-primary">Register</button>
<a href="{{ url_for('login') }}" class="btn btn-secondary mt-3">Back to Login</a>
</div>
</form>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,463 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Upload Content</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body.dark-mode {
background-color: #121212;
color: #ffffff;
}
.card.dark-mode {
background-color: #1e1e1e;
color: #ffffff;
}
.dark-mode label, .dark-mode th, .dark-mode td {
color: #ffffff;
}
.logo {
max-height: 100px;
margin-right: 20px;
}
/* Modal styling for dark mode */
.modal-content.dark-mode {
background-color: #1e1e1e;
color: #ffffff;
}
.modal-header.dark-mode {
border-bottom: 1px solid #444;
}
.modal-footer.dark-mode {
border-top: 1px solid #444;
}
.progress-bar {
background-color: #007bff;
}
@media (max-width: 768px) {
h1 {
font-size: 1.5rem;
}
.btn {
font-size: 0.9rem;
padding: 0.5rem 1rem;
}
.card {
margin-bottom: 1rem;
}
}
</style>
</head>
<body class="{{ 'dark-mode' if theme == 'dark' else '' }}">
<div class="container py-5">
<div class="d-flex justify-content-start align-items-center mb-4">
{% if logo_exists %}
<img src="{{ url_for('static', filename='uploads/logo.png') }}" alt="Logo" class="logo">
{% endif %}
<h1 class="mb-0">Upload Content</h1>
</div>
<form id="upload-form" action="{{ url_for('upload_content') }}" method="post" enctype="multipart/form-data" onsubmit="showStatusModal()">
<input type="hidden" name="return_url" value="{{ return_url }}">
<div class="row">
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="target_type" class="form-label">Target Type:</label>
<select name="target_type" id="target_type" class="form-select" required onchange="updateTargetIdOptions()">
<option value="" disabled selected>Select Target Type</option>
<option value="player" {% if target_type == 'player' %}selected{% endif %}>Player</option>
<option value="group" {% if target_type == 'group' %}selected{% endif %}>Group</option>
</select>
</div>
</div>
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="target_id" class="form-label">Target ID:</label>
<select name="target_id" id="target_id" class="form-select" required>
{% if target_type == 'player' %}
<optgroup label="Players">
{% for player in players %}
<option value="{{ player.id }}" {% if target_id == player.id %}selected{% endif %}>{{ player.username }}</option>
{% endfor %}
</optgroup>
{% elif target_type == 'group' %}
<optgroup label="Groups">
{% for group in groups %}
<option value="{{ group.id }}" {% if target_id == group.id %}selected{% endif %}>{{ group.name }}</option>
{% endfor %}
</optgroup>
{% else %}
<option value="" disabled selected>Select a Target ID</option>
{% endif %}
</select>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="media_type" class="form-label">Media Type:</label>
<select name="media_type" id="media_type" class="form-select" required>
<option value="image">Image</option>
<option value="video">Video</option>
<option value="pdf">PDF</option>
<option value="ppt">PPT/PPTX</option>
</select>
</div>
</div>
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="files" class="form-label">Files:</label>
<input type="file" name="files" id="files" class="form-control" multiple required onchange="handleFileChange()">
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 col-12">
<div class="mb-3">
<label for="duration" class="form-label">Duration (seconds):</label>
<input type="number" name="duration" id="duration" class="form-control" required>
</div>
</div>
</div>
<div class="text-center">
<button type="submit" id="submit-button" class="btn btn-primary">Upload</button>
<a href="{{ return_url }}" class="btn btn-secondary mt-3">Back</a>
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary mt-3">Back to Dashboard</a>
</div>
</form>
<!-- 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 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>
<!-- File Processing Progress -->
<div class="mb-3">
<label class="form-label fw-bold">File Processing Progress</label>
<div class="progress" style="height: 25px;">
<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>
</div>
<!-- System Monitoring in Modal -->
{% if system_info %}
<div class="mt-4">
<h6 class="mb-3">📊 Server Performance During Upload</h6>
<div class="row">
<!-- CPU Usage -->
<div class="col-md-4 col-12 mb-3">
<label class="form-label">CPU Usage</label>
<div class="progress" style="height: 20px;">
<div id="cpu-progress" class="progress-bar
{% if system_info.cpu_percent < 50 %}bg-success
{% elif system_info.cpu_percent < 80 %}bg-warning
{% else %}bg-danger{% endif %}"
role="progressbar"
style="width: {{ system_info.cpu_percent }}%;">
{{ system_info.cpu_percent }}%
</div>
</div>
<small class="text-muted">{{ system_info.cpu_count }} cores available</small>
</div>
<!-- Memory Usage -->
<div class="col-md-4 col-12 mb-3">
<label class="form-label">Memory Usage</label>
<div class="progress" style="height: 20px;">
<div id="memory-progress" class="progress-bar
{% if system_info.memory_percent < 60 %}bg-success
{% elif system_info.memory_percent < 85 %}bg-warning
{% else %}bg-danger{% endif %}"
role="progressbar"
style="width: {{ system_info.memory_percent }}%;">
{{ system_info.memory_percent }}%
</div>
</div>
<small class="text-muted" id="memory-text">{{ system_info.memory_used }}GB / {{ system_info.memory_total }}GB</small>
</div>
<!-- Disk Usage -->
<div class="col-md-4 col-12 mb-3">
<label class="form-label">Disk Space</label>
<div class="progress" style="height: 20px;">
<div id="disk-progress" class="progress-bar
{% if system_info.disk_percent < 70 %}bg-success
{% elif system_info.disk_percent < 90 %}bg-warning
{% else %}bg-danger{% endif %}"
role="progressbar"
style="width: {{ system_info.disk_percent }}%;">
{{ system_info.disk_percent }}%
</div>
</div>
<small class="text-muted" id="disk-text">{{ system_info.disk_free }}GB free</small>
</div>
</div>
<!-- Storage Summary -->
<div class="row mt-2">
<div class="col-md-6 col-12 text-center">
<strong>Current Media Storage:</strong>
<span class="text-primary" id="storage-size">{{ system_info.upload_folder_size }}GB</span>
</div>
<div class="col-md-6 col-12 text-center">
<strong>Last Updated:</strong>
<span class="text-muted" id="modal-last-update">Just now</span>
</div>
</div>
</div>
{% endif %}
</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>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
<script>
function showStatusModal() {
console.log("Processing popup triggered");
const statusModal = new bootstrap.Modal(document.getElementById('statusModal'));
statusModal.show();
// Update status message based on media type
const mediaType = document.getElementById('media_type').value;
const statusMessage = document.getElementById('status-message');
switch(mediaType) {
case 'image':
statusMessage.textContent = 'Uploading images...';
break;
case 'video':
statusMessage.textContent = 'Uploading and processing video. This may take a while...';
break;
case 'pdf':
statusMessage.textContent = 'Converting PDF to 4K images. This may take a while...';
break;
case 'ppt':
statusMessage.textContent = 'Converting PowerPoint to 4K images. This may take a while...';
break;
default:
statusMessage.textContent = 'Uploading and processing your files. Please wait...';
}
// Start system monitoring updates in modal
{% if system_info %}
startModalSystemMonitoring();
{% endif %}
// Simulate progress updates
const progressBar = document.getElementById('progress-bar');
let progress = 0;
const interval = setInterval(() => {
// For slow processes, increment more slowly
const increment = (mediaType === 'image') ? 20 : 5;
progress += increment;
if (progress >= 100) {
clearInterval(interval);
statusMessage.textContent = 'Files uploaded and processed successfully!';
// Stop system monitoring updates
{% if system_info %}
stopModalSystemMonitoring();
{% endif %}
// Enable the close button
document.querySelector('[data-bs-dismiss="modal"]').disabled = false;
} else {
progressBar.style.width = `${progress}%`;
progressBar.setAttribute('aria-valuenow', progress);
}
}, 500);
}
{% if system_info %}
let modalSystemInterval;
function updateModalSystemInfo() {
fetch('/api/system_info')
.then(response => response.json())
.then(data => {
if (data.error) {
console.warn('Could not fetch system info:', data.error);
return;
}
// Update CPU
const cpuProgress = document.getElementById('cpu-progress');
if (cpuProgress) {
cpuProgress.style.width = data.cpu_percent + '%';
cpuProgress.textContent = data.cpu_percent + '%';
cpuProgress.className = 'progress-bar ' +
(data.cpu_percent < 50 ? 'bg-success' :
data.cpu_percent < 80 ? 'bg-warning' : 'bg-danger');
}
// Update Memory
const memoryProgress = document.getElementById('memory-progress');
const memoryText = document.getElementById('memory-text');
if (memoryProgress) {
memoryProgress.style.width = data.memory_percent + '%';
memoryProgress.textContent = data.memory_percent + '%';
memoryProgress.className = 'progress-bar ' +
(data.memory_percent < 60 ? 'bg-success' :
data.memory_percent < 85 ? 'bg-warning' : 'bg-danger');
}
if (memoryText) {
memoryText.textContent = data.memory_used + 'GB / ' + data.memory_total + 'GB';
}
// Update Disk
const diskProgress = document.getElementById('disk-progress');
const diskText = document.getElementById('disk-text');
if (diskProgress) {
diskProgress.style.width = data.disk_percent + '%';
diskProgress.textContent = data.disk_percent + '%';
diskProgress.className = 'progress-bar ' +
(data.disk_percent < 70 ? 'bg-success' :
data.disk_percent < 90 ? 'bg-warning' : 'bg-danger');
}
if (diskText) {
diskText.textContent = data.disk_free + 'GB free';
}
// Update storage size
const storageSize = document.getElementById('storage-size');
if (storageSize) {
storageSize.textContent = data.upload_folder_size + 'GB';
}
// Update timestamp
const lastUpdate = document.getElementById('modal-last-update');
if (lastUpdate) {
lastUpdate.textContent = new Date().toLocaleTimeString();
}
})
.catch(error => {
console.warn('Modal system monitoring update failed:', error);
});
}
function startModalSystemMonitoring() {
// Update immediately
updateModalSystemInfo();
// Then update every 3 seconds for real-time monitoring during upload
modalSystemInterval = setInterval(updateModalSystemInfo, 3000);
}
function stopModalSystemMonitoring() {
if (modalSystemInterval) {
clearInterval(modalSystemInterval);
modalSystemInterval = null;
}
}
{% endif %}
function updateTargetIdOptions() {
const targetType = document.getElementById('target_type').value;
const targetIdSelect = document.getElementById('target_id');
targetIdSelect.innerHTML = ''; // Clear existing options
if (targetType === 'player') {
const players = {{ players|tojson }};
const optgroup = document.createElement('optgroup');
optgroup.label = 'Players';
players.forEach(player => {
const option = document.createElement('option');
option.value = player.id;
option.textContent = player.username;
optgroup.appendChild(option);
});
targetIdSelect.appendChild(optgroup);
} else if (targetType === 'group') {
const groups = {{ groups|tojson }};
const optgroup = document.createElement('optgroup');
optgroup.label = 'Groups';
groups.forEach(group => {
const option = document.createElement('option');
option.value = group.id;
option.textContent = group.name;
optgroup.appendChild(option);
});
targetIdSelect.appendChild(optgroup);
}
}
function handleFileChange() {
const mediaType = document.getElementById('media_type').value;
const filesInput = document.getElementById('files');
const durationInput = document.getElementById('duration');
if (mediaType === 'video' && filesInput.files.length > 0) {
const file = filesInput.files[0];
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = function () {
window.URL.revokeObjectURL(video.src);
const duration = Math.round(video.duration);
durationInput.value = duration; // Set the duration in the input field
};
video.src = URL.createObjectURL(file);
}
}
function showStatusModal() {
console.log("Processing popup triggered");
const statusModal = new bootstrap.Modal(document.getElementById('statusModal'));
statusModal.show();
// Update status message based on media type
const mediaType = document.getElementById('media_type').value;
const statusMessage = document.getElementById('status-message');
switch(mediaType) {
case 'image':
statusMessage.textContent = 'Uploading images...';
break;
case 'video':
statusMessage.textContent = 'Uploading and processing video. This may take a while...';
break;
case 'pdf':
statusMessage.textContent = 'Converting PDF to images. This may take a while...';
break;
case 'ppt':
statusMessage.textContent = 'Converting PowerPoint to images. This may take a while...';
break;
default:
statusMessage.textContent = 'Uploading and processing your files. Please wait...';
}
// Simulate progress updates
const progressBar = document.getElementById('progress-bar');
let progress = 0;
const interval = setInterval(() => {
// For slow processes, increment more slowly
const increment = (mediaType === 'image') ? 20 : 5;
progress += increment;
if (progress >= 100) {
clearInterval(interval);
statusMessage.textContent = 'Files uploaded and processed successfully!';
// Enable the close button
document.querySelector('[data-bs-dismiss="modal"]').disabled = false;
} else {
progressBar.style.width = `${progress}%`;
progressBar.setAttribute('aria-valuenow', progress);
}
}, 500);
}
</script>
</body>
</html>

0
app/utils/__init__.py Normal file
View File

View File

@@ -0,0 +1,385 @@
from models import Player, Group, Content
from extensions import db
from utils.logger import (
log_group_created, log_group_edited, log_group_deleted,
log_player_created, log_player_edited, log_player_deleted,
log_player_added_to_group, log_player_removed_from_group,
log_player_unlocked, log_content_reordered,
log_content_duration_changed, log_content_added
)
def create_group(name, player_ids, orientation='Landscape'):
"""
Create a new group with the given name, orientation, and add selected players.
Only players with the same orientation can be added.
"""
# Check all players have the same orientation
for player_id in player_ids:
player = Player.query.get(player_id)
if player and player.orientation != orientation:
raise ValueError(f"Player '{player.username}' has orientation '{player.orientation}', which does not match group orientation '{orientation}'.")
new_group = Group(name=name, orientation=orientation)
db.session.add(new_group)
db.session.flush() # Get the group ID
# Add players to the group and lock them
for player_id in player_ids:
player = Player.query.get(player_id)
if player:
new_group.players.append(player)
Content.query.filter_by(player_id=player.id).delete()
player.locked_to_group_id = new_group.id
db.session.commit()
log_group_created(name)
return new_group
def edit_group(group_id, name, player_ids, orientation=None):
"""
Edit an existing group, updating its name, orientation, and players.
Handles locking/unlocking players appropriately.
"""
group = Group.query.get_or_404(group_id)
old_name = group.name # Store old name in case it changes
group.name = name
# Update orientation if provided
if orientation:
group.orientation = orientation
# Validate that all selected players have the matching orientation
for player_id in player_ids:
player = Player.query.get(player_id)
if player and player.orientation != orientation:
raise ValueError(f"Player '{player.username}' has orientation '{player.orientation}', which does not match group orientation '{orientation}'.")
# Get current players in the group
current_player_ids = [player.id for player in group.players]
# Determine players to add and remove
players_to_add = [pid for pid in player_ids if pid not in current_player_ids]
players_to_remove = [pid for pid in current_player_ids if pid not in player_ids]
# Handle players to add
for player_id in players_to_add:
player = Player.query.get(player_id)
if player:
# Add to group
group.players.append(player)
# Delete individual playlist
Content.query.filter_by(player_id=player.id).delete()
# Lock to group
player.locked_to_group_id = group.id
# Log this action
log_player_added_to_group(player.username, name)
# Handle players to remove
for player_id in players_to_remove:
player = Player.query.get(player_id)
if player:
# Remove from group
group.players.remove(player)
# Unlock from group
player.locked_to_group_id = None
# Log this action
log_player_removed_from_group(player.username, name)
log_player_unlocked(player.username)
db.session.commit()
# Log the group edit
if old_name != name:
log_group_edited(f"{old_name}{name}")
else:
log_group_edited(name)
return group
def delete_group(group_id):
"""
Delete a group and unlock all associated players.
"""
group = Group.query.get_or_404(group_id)
group_name = group.name
# Unlock all players in the group
for player in group.players:
player.locked_to_group_id = None
log_player_unlocked(player.username)
db.session.delete(group)
db.session.commit()
log_group_deleted(group_name)
def add_player(username, hostname, password, quickconnect_password, orientation='Landscape'):
"""
Add a new player with the given details.
"""
from flask_bcrypt import Bcrypt
bcrypt = Bcrypt()
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
hashed_quickconnect = bcrypt.generate_password_hash(quickconnect_password).decode('utf-8')
new_player = Player(
username=username,
hostname=hostname,
password=hashed_password,
quickconnect_password=hashed_quickconnect,
orientation=orientation
)
db.session.add(new_player)
db.session.commit()
log_player_created(username, hostname)
return new_player
def edit_player(player_id, username, hostname, password=None, quickconnect_password=None, orientation=None):
"""
Edit an existing player's details.
"""
from flask_bcrypt import Bcrypt
bcrypt = Bcrypt()
player = Player.query.get_or_404(player_id)
player.username = username
player.hostname = hostname
if password:
player.password = bcrypt.generate_password_hash(password).decode('utf-8')
if quickconnect_password:
player.quickconnect_password = bcrypt.generate_password_hash(quickconnect_password).decode('utf-8')
if orientation:
player.orientation = orientation
db.session.commit()
log_player_edited(username)
return player
def delete_player(player_id):
"""
Delete a player and all its content.
"""
player = Player.query.get_or_404(player_id)
username = player.username
# Delete all media related to the player
Content.query.filter_by(player_id=player_id).delete()
# Delete the player
db.session.delete(player)
db.session.commit()
log_player_deleted(username)
def get_group_content(group_id):
"""
Get content for all players in a group, ordered by position.
"""
from models import Group, Content
group = Group.query.get_or_404(group_id)
# Get all player IDs in the group
player_ids = [player.id for player in group.players]
# Get unique content based on file_name, preserving position
unique_content = {}
# For each player, get their content
for player_id in player_ids:
# Get content for this player, ordered by position
player_content = Content.query.filter_by(player_id=player_id).order_by(Content.position).all()
for content in player_content:
if content.file_name not in unique_content:
unique_content[content.file_name] = content
# Sort the unique content by position
return sorted(unique_content.values(), key=lambda c: c.position)
def get_player_content(player_id):
"""
Get content for a specific player, ordered by position.
"""
from models import Content
return Content.query.filter_by(player_id=player_id).order_by(Content.position).all()
def update_player_content_order(player_id, items):
"""
Update the order of content items for a player.
Args:
player_id (int): ID of the player
items (list): List of items with id and position
Returns:
tuple: (success, error_message, new_version)
"""
from models import Player, Content
from extensions import db
player = Player.query.get_or_404(player_id)
try:
# Update the position field for each content item
for item in items:
content_id = int(item['id'])
position = int(item['position'])
content = Content.query.get_or_404(content_id)
if content.player_id != player_id:
continue # Skip if not for this player
content.position = position
# Force increment the playlist version to trigger client refresh
player.playlist_version = (player.playlist_version or 0) + 1
db.session.commit()
# Log the reordering action
log_content_reordered("player", player.username)
return True, None, player.playlist_version
except Exception as e:
db.session.rollback()
return False, str(e), None
def update_group_content_order(group_id, items):
"""
Update the order of content items for all players in a group.
Args:
group_id (int): ID of the group
items (list): List of items with id and position
Returns:
tuple: (success, error_message)
"""
from models import Group, Content
from extensions import db
group = Group.query.get_or_404(group_id)
try:
# Get file names corresponding to the content IDs
content_files = {}
for item in items:
content_id = int(item['id'])
position = int(item['position'])
content = Content.query.get_or_404(content_id)
content_files[content.file_name] = position
# Update all content items for all players in this group
for player in group.players:
for content in Content.query.filter_by(player_id=player.id).all():
if content.file_name in content_files:
content.position = content_files[content.file_name]
# Force increment the playlist version to trigger client refresh
player.playlist_version = (player.playlist_version or 0) + 1
db.session.commit()
# Log the reordering action
log_content_reordered("group", group.name)
return True, None
except Exception as e:
db.session.rollback()
return False, str(e)
def edit_group_media(group_id, content_id, new_duration):
"""
Update the duration for all instances of a media item across all players in a group.
Args:
group_id (int): ID of the group
content_id (int): ID of the content item
new_duration (int): New duration in seconds
Returns:
bool: Success or failure
"""
from models import Group, Content
from extensions import db
group = Group.query.get_or_404(group_id)
content = Content.query.get(content_id)
file_name = content.file_name
old_duration = content.duration
try:
# Update the duration for all players in the group
for player in group.players:
content = Content.query.filter_by(player_id=player.id, file_name=file_name).first()
if content:
content.duration = new_duration
db.session.commit()
# Log the duration change
log_content_duration_changed(file_name, old_duration, new_duration, "group", group.name)
return True
except Exception as e:
db.session.rollback()
return False
def delete_group_media(group_id, content_id):
"""
Delete a media item from all players in a group and remove the physical file.
Args:
group_id (int): ID of the group
content_id (int): ID of the content item
Returns:
bool: Success or failure
"""
from models import Group, Content
from extensions import db
from flask import current_app
import os
group = Group.query.get_or_404(group_id)
content = Content.query.get(content_id)
file_name = content.file_name
try:
# Delete the media for all players in the group
count = 0
for player in group.players:
content = Content.query.filter_by(player_id=player.id, file_name=file_name).first()
if content:
db.session.delete(content)
count += 1
# Delete the physical file using absolute path
upload_folder = current_app.config['UPLOAD_FOLDER']
if not os.path.isabs(upload_folder):
upload_folder = os.path.abspath(upload_folder)
file_path = os.path.join(upload_folder, file_name)
if os.path.exists(file_path):
try:
os.remove(file_path)
print(f"Deleted physical file: {file_path}")
except OSError as e:
print(f"Error deleting file {file_path}: {e}")
db.session.commit()
# Log the content deletion
log_content_deleted(file_name, "group", group.name)
return True
except Exception as e:
db.session.rollback()
print(f"Error in delete_group_media: {e}")
return False

84
app/utils/logger.py Normal file
View File

@@ -0,0 +1,84 @@
import datetime
from extensions import db
from models import ServerLog
def log_action(action):
"""
Log an action to the server log database
"""
try:
new_log = ServerLog(action=action)
db.session.add(new_log)
db.session.commit()
print(f"Logged action: {action}")
except Exception as e:
print(f"Error logging action: {e}")
db.session.rollback()
def get_recent_logs(limit=20):
"""
Get the most recent log entries
"""
return ServerLog.query.order_by(ServerLog.timestamp.desc()).limit(limit).all()
# Helper functions for common log actions
def log_upload(file_type, file_name, target_type, target_name):
log_action(f"{file_type.upper()} file '{file_name}' uploaded for {target_type} '{target_name}'")
def log_process(file_type, file_name, target_type, target_name):
log_action(f"{file_type.upper()} file '{file_name}' processed for {target_type} '{target_name}'")
def log_player_created(username, hostname):
log_action(f"Player '{username}' with hostname '{hostname}' was created")
def log_player_edited(username):
log_action(f"Player '{username}' was edited")
def log_player_deleted(username):
log_action(f"Player '{username}' was deleted")
def log_group_created(name):
log_action(f"Group '{name}' was created")
def log_group_edited(name):
log_action(f"Group '{name}' was edited")
def log_group_deleted(name):
log_action(f"Group '{name}' was deleted")
def log_user_created(username, role):
log_action(f"User '{username}' with role '{role}' was created")
def log_user_role_changed(username, new_role):
log_action(f"User '{username}' role changed to '{new_role}'")
def log_user_deleted(username):
log_action(f"User '{username}' was deleted")
def log_content_deleted(content_name, target_type, target_name):
log_action(f"Content '{content_name}' removed from {target_type} '{target_name}'")
def log_settings_changed(setting_name):
log_action(f"Setting '{setting_name}' was changed")
def log_files_cleaned(count):
log_action(f"{count} unused files were cleaned from storage")
# New logging functions for more detailed activities
def log_player_added_to_group(player_name, group_name):
log_action(f"Player '{player_name}' was added to group '{group_name}'")
def log_player_removed_from_group(player_name, group_name):
log_action(f"Player '{player_name}' was removed from group '{group_name}'")
def log_player_unlocked(player_name):
log_action(f"Player '{player_name}' was unlocked from its group")
def log_content_reordered(target_type, target_name):
log_action(f"Content for {target_type} '{target_name}' was reordered")
def log_content_duration_changed(content_name, old_duration, new_duration, target_type, target_name):
log_action(f"Duration for '{content_name}' changed from {old_duration}s to {new_duration}s in {target_type} '{target_name}'")
def log_content_added(content_name, target_type, target_name):
log_action(f"Content '{content_name}' added to {target_type} '{target_name}'")

View File

@@ -0,0 +1,86 @@
"""
PPTX to PDF converter using LibreOffice for high-quality conversion
This module provides the essential function to convert PowerPoint presentations to PDF
using LibreOffice headless mode for professional-grade quality.
The converted PDF is then processed by the main upload workflow for 4K image generation.
"""
import os
import subprocess
import logging
# Set up logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def pptx_to_pdf_libreoffice(pptx_path, output_dir):
"""
Convert PPTX to PDF using LibreOffice for highest quality.
This function is the core component of the PPTX processing workflow:
PPTX → PDF (this function) → 4K JPG images (handled in uploads.py)
Args:
pptx_path (str): Path to the PPTX file
output_dir (str): Directory to save the PDF
Returns:
str: Path to the generated PDF file, or None if conversion failed
"""
try:
# Ensure output directory exists
os.makedirs(output_dir, exist_ok=True)
# Use LibreOffice to convert PPTX to PDF
cmd = [
'libreoffice',
'--headless',
'--convert-to', 'pdf',
'--outdir', output_dir,
pptx_path
]
logger.info(f"Converting PPTX to PDF using LibreOffice: {pptx_path}")
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
logger.error(f"LibreOffice conversion failed: {result.stderr}")
return None
# Find the generated PDF file
base_name = os.path.splitext(os.path.basename(pptx_path))[0]
pdf_path = os.path.join(output_dir, f"{base_name}.pdf")
if os.path.exists(pdf_path):
logger.info(f"PDF conversion successful: {pdf_path}")
return pdf_path
else:
logger.error(f"PDF file not found after conversion: {pdf_path}")
return None
except subprocess.TimeoutExpired:
logger.error("LibreOffice conversion timed out (120s)")
return None
except Exception as e:
logger.error(f"Error in PPTX to PDF conversion: {e}")
return None
if __name__ == "__main__":
# Test the converter
import sys
if len(sys.argv) > 1:
test_pptx = sys.argv[1]
if os.path.exists(test_pptx):
output_dir = "test_output"
pdf_result = pptx_to_pdf_libreoffice(test_pptx, output_dir)
if pdf_result:
print(f"Successfully converted PPTX to PDF: {pdf_result}")
else:
print("PPTX to PDF conversion failed")
else:
print(f"File not found: {test_pptx}")
else:
print("Usage: python pptx_converter.py <pptx_file>")

457
app/utils/uploads.py Normal file
View File

@@ -0,0 +1,457 @@
import os
import subprocess
from flask import Flask
from werkzeug.utils import secure_filename
from pdf2image import convert_from_path
from extensions import db
from models import Content, Player, Group
from utils.logger import log_content_added, log_upload, log_process
# Function to add image to playlist
def add_image_to_playlist(app, file, filename, duration, target_type, target_id):
"""
Save the image file and add it to the playlist database.
"""
# Ensure we use absolute path for upload folder
upload_folder = app.config['UPLOAD_FOLDER']
if not os.path.isabs(upload_folder):
upload_folder = os.path.abspath(upload_folder)
# Ensure upload folder exists
if not os.path.exists(upload_folder):
os.makedirs(upload_folder, exist_ok=True)
print(f"Created upload folder: {upload_folder}")
file_path = os.path.join(upload_folder, filename)
print(f"Saving image to: {file_path}")
# Only save if file does not already exist
if not os.path.exists(file_path):
file.save(file_path)
print(f"Image saved successfully: {file_path}")
else:
print(f"File already exists: {file_path}")
print(f"Adding image to playlist: {filename}, Target Type: {target_type}, Target ID: {target_id}")
if target_type == 'group':
group = Group.query.get_or_404(target_id)
for player in group.players:
new_content = Content(file_name=filename, duration=duration, player_id=player.id)
db.session.add(new_content)
log_content_added(filename, target_type, group.name)
elif target_type == 'player':
player = Player.query.get_or_404(target_id)
new_content = Content(file_name=filename, duration=duration, player_id=target_id)
db.session.add(new_content)
log_content_added(filename, target_type, player.username)
db.session.commit()
log_upload('image', filename, target_type, target_id)
return True
# Video conversion functions
def convert_video(input_file, output_folder):
"""
Converts a video file to MP4 format with H.264 codec.
"""
# Ensure we use absolute path for output folder
if not os.path.isabs(output_folder):
output_folder = os.path.abspath(output_folder)
print(f"Converted output folder to absolute path: {output_folder}")
if not os.path.exists(output_folder):
os.makedirs(output_folder, exist_ok=True)
print(f"Created output folder: {output_folder}")
# Generate the output file path
base_name = os.path.splitext(os.path.basename(input_file))[0]
output_file = os.path.join(output_folder, f"{base_name}.mp4")
print(f"Converting video: {input_file} -> {output_file}")
# FFmpeg command to convert the video
command = [
"ffmpeg",
"-i", input_file, # Input file
"-c:v", "libx264", # Video codec: H.264
"-preset", "fast", # Encoding speed/quality tradeoff
"-crf", "23", # Constant Rate Factor (quality, lower is better)
"-vf", "scale=-1:1080", # Scale video to 1080p (preserve aspect ratio)
"-r", "30", # Frame rate: 30 FPS
"-c:a", "aac", # Audio codec: AAC
"-b:a", "128k", # Audio bitrate
output_file # Output file
]
try:
# Run the FFmpeg command
subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
print(f"Video converted successfully: {output_file}")
return output_file
except subprocess.CalledProcessError as e:
print(f"Error converting video: {e.stderr.decode()}")
return None
def convert_video_and_update_playlist(app, file_path, original_filename, target_type, target_id, duration):
"""
Converts a video and updates the playlist database.
"""
print(f"Starting video conversion for: {file_path}")
# Ensure we use absolute path for upload folder
upload_folder = app.config['UPLOAD_FOLDER']
if not os.path.isabs(upload_folder):
upload_folder = os.path.abspath(upload_folder)
print(f"Converted upload folder to absolute path: {upload_folder}")
converted_file = convert_video(file_path, upload_folder)
if converted_file:
converted_filename = os.path.basename(converted_file)
print(f"Video converted successfully: {converted_filename}")
# Use the application context to interact with the database
with app.app_context():
# Update the database with the converted filename
if target_type == 'group':
group = Group.query.get_or_404(target_id)
for player in group.players:
content = Content.query.filter_by(player_id=player.id, file_name=original_filename).first()
if content:
content.file_name = converted_filename
elif target_type == 'player':
content = Content.query.filter_by(player_id=target_id, file_name=original_filename).first()
if content:
content.file_name = converted_filename
db.session.commit()
print(f"Database updated with converted video: {converted_filename}")
# Delete the original file only if it exists
if os.path.exists(file_path):
os.remove(file_path)
print(f"Original file deleted: {file_path}")
else:
print(f"Video conversion failed for: {file_path}")
# PDF conversion functions
def convert_pdf_to_images(pdf_file, output_folder, delete_pdf=True, dpi=300):
"""
Convert a PDF file to high-quality JPG images in sequential order.
Uses standard 300 DPI for reliable conversion.
"""
print(f"Converting PDF to JPG images: {pdf_file} at {dpi} DPI")
print(f"Original output folder: {output_folder}")
# Force absolute path resolution to ensure we use the app directory
if not os.path.isabs(output_folder):
# If relative path, resolve from the current working directory
output_folder = os.path.abspath(output_folder)
print(f"Converted relative path to absolute: {output_folder}")
else:
print(f"Using provided absolute path: {output_folder}")
# Ensure we're using the app static folder, not workspace root
if output_folder.endswith('static/uploads'):
# Check if we're accidentally using workspace root instead of app folder
expected_app_path = '/opt/digiserver/app/static/uploads'
if output_folder != expected_app_path:
print(f"WARNING: Correcting path from {output_folder} to {expected_app_path}")
output_folder = expected_app_path
print(f"Final output folder: {output_folder}")
try:
# Ensure output folder exists
if not os.path.exists(output_folder):
os.makedirs(output_folder, exist_ok=True)
print(f"Created output folder: {output_folder}")
# Convert PDF to images using pdf2image
print("Starting PDF conversion...")
images = convert_from_path(pdf_file, dpi=dpi)
print(f"PDF converted to {len(images)} page(s)")
if not images:
print("ERROR: No images generated from PDF")
return []
base_name = os.path.splitext(os.path.basename(pdf_file))[0]
image_filenames = []
# Save each page as JPG image
for i, image in enumerate(images):
# Convert to RGB if necessary
if image.mode != 'RGB':
image = image.convert('RGB')
# Simple naming with page numbers
page_num = str(i + 1).zfill(3) # e.g., 001, 002, etc.
image_filename = f"{base_name}_page_{page_num}.jpg"
image_path = os.path.join(output_folder, image_filename)
# Save as JPG
image.save(image_path, 'JPEG', quality=85, optimize=True)
image_filenames.append(image_filename)
print(f"Saved page {i + 1} to: {image_path}")
print(f"PDF conversion complete. {len(image_filenames)} JPG images saved to {output_folder}")
# Delete the PDF file if requested and conversion was successful
if delete_pdf and os.path.exists(pdf_file) and image_filenames:
os.remove(pdf_file)
print(f"PDF file deleted: {pdf_file}")
return image_filenames
except Exception as e:
print(f"Error converting PDF to JPG images: {e}")
import traceback
traceback.print_exc()
return []
def update_playlist_with_files(image_filenames, duration, target_type, target_id):
"""
Add files to a player or group playlist and update version numbers.
Args:
image_filenames (list): List of filenames to add to playlist
duration (int): Duration in seconds for each file
target_type (str): 'player' or 'group'
target_id (int): ID of the player or group
Returns:
bool: True if successful, False otherwise
"""
try:
if target_type == 'group':
group = Group.query.get_or_404(target_id)
for player in group.players:
for image_filename in image_filenames:
new_content = Content(file_name=image_filename, duration=duration, player_id=player.id)
db.session.add(new_content)
player.playlist_version += 1
group.playlist_version += 1
elif target_type == 'player':
player = Player.query.get_or_404(target_id)
for image_filename in image_filenames:
new_content = Content(file_name=image_filename, duration=duration, player_id=target_id)
db.session.add(new_content)
player.playlist_version += 1
else:
print(f"Invalid target type: {target_type}")
return False
db.session.commit()
print(f"Added {len(image_filenames)} files to playlist")
return True
except Exception as e:
db.session.rollback()
print(f"Error updating playlist: {e}")
return False
def process_pdf(input_file, output_folder, duration, target_type, target_id):
"""
Process a PDF file: convert to images and update playlist.
Args:
input_file (str): Path to the PDF file
output_folder (str): Path to save the images
duration (int): Duration in seconds for each image
target_type (str): 'player' or 'group'
target_id (int): ID of the player or group
Returns:
bool: True if successful, False otherwise
"""
print(f"Processing PDF file: {input_file}")
print(f"Output folder: {output_folder}")
# Ensure we have absolute path for output folder
if not os.path.isabs(output_folder):
output_folder = os.path.abspath(output_folder)
print(f"Converted output folder to absolute path: {output_folder}")
# Ensure output folder exists
if not os.path.exists(output_folder):
os.makedirs(output_folder, exist_ok=True)
print(f"Created output folder: {output_folder}")
# Convert PDF to images using standard quality (delete PDF after successful conversion)
image_filenames = convert_pdf_to_images(input_file, output_folder, delete_pdf=True, dpi=300)
# Update playlist with generated images
if image_filenames:
success = update_playlist_with_files(image_filenames, duration, target_type, target_id)
if success:
print(f"Successfully processed PDF: {len(image_filenames)} images added to playlist")
return success
else:
print("Failed to convert PDF to images")
return False
def process_pptx(input_file, output_folder, duration, target_type, target_id):
"""
Process a PPTX file: convert to PDF first, then to JPG images (same workflow as PDF).
Args:
input_file (str): Path to the PPTX file
output_folder (str): Path to save the images
duration (int): Duration in seconds for each image
target_type (str): 'player' or 'group'
target_id (int): ID of the player or group
Returns:
bool: True if successful, False otherwise
"""
print(f"Processing PPTX file using PDF workflow: {input_file}")
print(f"Output folder: {output_folder}")
# Ensure we have absolute path for output folder
if not os.path.isabs(output_folder):
output_folder = os.path.abspath(output_folder)
print(f"Converted output folder to absolute path: {output_folder}")
# Ensure output folder exists
if not os.path.exists(output_folder):
os.makedirs(output_folder, exist_ok=True)
print(f"Created output folder: {output_folder}")
try:
# Step 1: Convert PPTX to PDF using LibreOffice for vector quality
from utils.pptx_converter import pptx_to_pdf_libreoffice
pdf_file = pptx_to_pdf_libreoffice(input_file, output_folder)
if not pdf_file:
print("Error: Failed to convert PPTX to PDF")
return False
print(f"PPTX successfully converted to PDF: {pdf_file}")
# Step 2: Use the same PDF to images workflow as direct PDF uploads
# Convert PDF to JPG images (300 DPI, same as PDF workflow)
image_filenames = convert_pdf_to_images(pdf_file, output_folder, delete_pdf=True, dpi=300)
if not image_filenames:
print("Error: Failed to convert PDF to images")
return False
print(f"Generated {len(image_filenames)} JPG images from PPTX → PDF")
# Step 3: Delete the original PPTX file after successful conversion
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
success = update_playlist_with_files(image_filenames, duration, target_type, target_id)
if success:
print(f"Successfully processed PPTX: {len(image_filenames)} images added to playlist")
return success
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):
"""
Process uploaded files based on media type and add them to playlists.
Returns:
list: List of result dictionaries with success status and messages
"""
results = []
# Get target name for logging
target_name = ""
if target_type == 'group':
group = Group.query.get_or_404(target_id)
target_name = group.name
elif target_type == 'player':
player = Player.query.get_or_404(target_id)
target_name = player.username
for file in files:
try:
# Generate a secure filename and save the file
filename = secure_filename(file.filename)
# Ensure we use absolute path for upload folder
upload_folder = app.config['UPLOAD_FOLDER']
if not os.path.isabs(upload_folder):
upload_folder = os.path.abspath(upload_folder)
# Ensure upload folder exists
if not os.path.exists(upload_folder):
os.makedirs(upload_folder, exist_ok=True)
print(f"Created upload folder: {upload_folder}")
file_path = os.path.join(upload_folder, filename)
file.save(file_path)
print(f"File saved to: {file_path}")
print(f"Processing file: {filename}, Media Type: {media_type}")
result = {'filename': filename, 'success': True, 'message': ''}
if media_type == 'image':
add_image_to_playlist(app, file, filename, duration, target_type, target_id)
result['message'] = f"Image {filename} added to playlist"
log_upload('image', filename, target_type, target_id)
elif media_type == 'video':
# For videos, add to playlist then start conversion in background
if target_type == 'group':
group = Group.query.get_or_404(target_id)
for player in group.players:
new_content = Content(file_name=filename, duration=duration, player_id=player.id)
db.session.add(new_content)
player.playlist_version += 1
group.playlist_version += 1
elif target_type == 'player':
player = Player.query.get_or_404(target_id)
new_content = Content(file_name=filename, duration=duration, player_id=target_id)
db.session.add(new_content)
player.playlist_version += 1
db.session.commit()
# Start background conversion using absolute path
import threading
threading.Thread(target=convert_video_and_update_playlist,
args=(app, file_path, filename, target_type, target_id, duration)).start()
result['message'] = f"Video {filename} added to playlist and being processed"
log_upload('video', filename, target_type, target_id)
elif media_type == 'pdf':
# For PDFs, convert to images and update playlist using absolute path
success = process_pdf(file_path, upload_folder,
duration, target_type, target_id)
if success:
result['message'] = f"PDF {filename} processed successfully"
log_process('pdf', filename, target_type, target_id)
else:
result['success'] = False
result['message'] = f"Error processing PDF file: {filename}"
elif media_type == 'ppt':
# For PPT/PPTX, convert to PDF, then to images, and update playlist using absolute path
success = process_pptx(file_path, upload_folder,
duration, target_type, target_id)
if success:
result['message'] = f"PowerPoint {filename} processed successfully"
log_process('ppt', filename, target_type, target_id)
else:
result['success'] = False
result['message'] = f"Error processing PowerPoint file: {filename}"
results.append(result)
except Exception as e:
print(f"Error processing file {file.filename}: {e}")
results.append({
'filename': file.filename,
'success': False,
'message': f"Error processing file {file.filename}: {str(e)}"
})
return results