Sanitize codebase, reorganize docs, and add missing deploy files

Remove dead code identified in docs/SANITIZATION-REVIEW.md:
- app/blueprints/content_old.py and app/blueprints/playlist.py
- app/models/group.py, app/utils/nginx_config_reader.py
- orphaned templates (content_list, edit_content, upload_content,
  player_page) and the related group/Template references

Result: 6 blueprints, 82 routes, no dead modules or orphan templates.

Add files that deploy.sh and docker-entrypoint.sh already require but
which were never tracked:
- https_manager.py       (referenced by deploy.sh, migrate_network.sh,
                          docker-entrypoint.sh)
- Caddyfile.example      (seeded by deploy.sh; its absence aborts deploy)

Relocate generated Graphify artifacts from graphify-out/ to
docs/graphify-out/ (110 files, no content change) and archive the
superseded docs under docs/.

Ignore hygiene:
- ignore ad-hoc .env backups (.env.bak*) — they contain live secrets
- keep the pre-sanitization snapshots (docs/legacy code/,
  docs/old_code_documentation/) on disk but out of the repo

Fix .env.example: drop a duplicated config block, genericize the
hardcoded host IP, and document HOSTNAME_INTERNAL.
This commit is contained in:
2026-09-11 12:18:34 +03:00
parent 1c5186463a
commit 46602f1933
226 changed files with 3999 additions and 15737 deletions
-2
View File
@@ -94,7 +94,6 @@ def register_blueprints(app):
from app.blueprints.admin import admin_bp
from app.blueprints.players import players_bp
from app.blueprints.content import content_bp
from app.blueprints.playlist import playlist_bp
from app.blueprints.api import api_bp
# Register blueprints (using URL prefixes from blueprint definitions)
@@ -103,7 +102,6 @@ def register_blueprints(app):
app.register_blueprint(admin_bp)
app.register_blueprint(players_bp)
app.register_blueprint(content_bp)
app.register_blueprint(playlist_bp)
app.register_blueprint(api_bp)
+69 -47
View File
@@ -1072,11 +1072,14 @@ def build_player():
@login_required
@admin_required
def build_player_action():
"""Build/refresh the staged player code and/or write its base config."""
from app.utils.player_build import (
build_player_files, write_base_config, get_short_head,
save_build_settings, make_build_record,
)
"""Start building/refreshing the staged player code.
The build runs in a background thread because a full clone of the player
repository takes far longer than gunicorn's worker timeout; running it
in-request would get the worker killed mid-clone and leave a broken
checkout. The page then polls ``admin.build_player_status`` for progress.
"""
from app.utils.player_build import start_background_build, is_build_running
player_code_dir = current_app.config['PLAYER_CODE_DIR']
action = request.form.get('action', 'build_and_config')
@@ -1090,16 +1093,14 @@ def build_player_action():
orientation = request.form.get('orientation', 'Landscape').strip() or 'Landscape'
max_resolution = request.form.get('max_resolution', '1920x1080').strip() or '1920x1080'
# Validation
# Validation (unchanged — fail fast before starting any work)
errors = []
if action in ('build_files', 'build_and_config') and not repo_url:
errors.append('Repository URL is required to build player files.')
if action in ('save_config', 'build_and_config') and not server_ip:
errors.append('Server IP / domain is required for the player configuration.')
try:
port_num = int(port)
if port_num < 1 or port_num > 65535:
errors.append('Port must be between 1 and 65535.')
int(port)
except ValueError:
errors.append('Port must be a valid number.')
@@ -1108,51 +1109,72 @@ def build_player_action():
flash(err, 'warning')
return redirect(url_for('admin.build_player'))
messages = []
success = True
version = None
# Step 1: build/refresh files from the repository.
if action in ('build_files', 'build_and_config'):
result = build_player_files(player_code_dir, repo_url, branch)
version = result.get('version')
messages.append(result['message'])
if not result['success']:
success = False
log_action('error', f'Player build failed by {current_user.username}: {result["message"]}')
# Step 2: write the base config (only if the previous step didn't fail).
if success and action in ('save_config', 'build_and_config'):
# 'save_config' only writes the config file — it touches no network and is
# fast, so it stays synchronous.
if action == 'save_config':
from app.utils.player_build import (
write_base_config, get_short_head, save_build_settings, make_build_record,
)
cfg_result = write_base_config(
player_code_dir=player_code_dir,
server_ip=server_ip,
port=port,
use_https=use_https,
verify_ssl=verify_ssl,
orientation=orientation,
server_ip=server_ip, port=port, use_https=use_https,
verify_ssl=verify_ssl, orientation=orientation,
max_resolution=max_resolution,
)
messages.append(cfg_result['message'])
if not cfg_result['success']:
success = False
# Persist settings so deployment uses the same server address.
if version is None:
version = get_short_head(player_code_dir)
save_build_settings(
_player_build_meta_path(),
make_build_record(
repo_url=repo_url, branch=branch, server_ip=server_ip, port=port,
use_https=use_https, verify_ssl=verify_ssl, orientation=orientation,
max_resolution=max_resolution, version=version, built_by=current_user.username,
),
save_build_settings(
_player_build_meta_path(),
make_build_record(
repo_url=repo_url, branch=branch, server_ip=server_ip, port=port,
use_https=use_https, verify_ssl=verify_ssl, orientation=orientation,
max_resolution=max_resolution, version=version,
built_by=current_user.username,
),
)
if cfg_result['success']:
log_action('info', f'Player config saved by {current_user.username}')
flash(f"{cfg_result['message']}", 'success')
else:
log_action('error', f'Player config write failed: {cfg_result["message"]}')
flash(f"⚠️ {cfg_result['message']}", 'danger')
return redirect(url_for('admin.build_player'))
# build_files / build_and_config → background thread.
if is_build_running():
flash('⚠️ A build is already running — wait for it to finish.', 'warning')
return redirect(url_for('admin.build_player'))
config_payload = None
if action == 'build_and_config':
config_payload = {
'server_ip': server_ip, 'port': port, 'use_https': use_https,
'verify_ssl': verify_ssl, 'orientation': orientation,
'max_resolution': max_resolution,
}
started = start_background_build(
player_code_dir=player_code_dir,
repo_url=repo_url,
branch=branch,
config_payload=config_payload,
meta_path=_player_build_meta_path(),
built_by=current_user.username,
)
summary = ' '.join(messages) if messages else 'No action performed.'
if success:
log_action('info', f'Player files built by {current_user.username} (version {version})')
flash(f'{summary}', 'success')
if not started:
flash('⚠️ A build is already running — wait for it to finish.', 'warning')
else:
flash(f'⚠️ {summary}', 'danger')
log_action('info', f'Player build started by {current_user.username} '
f'({branch} @ {repo_url})')
flash('⏳ Build started — this page will update automatically.', 'info')
return redirect(url_for('admin.build_player'))
@admin_bp.route('/build-player/status', methods=['GET'])
@login_required
@admin_required
def build_player_status():
"""JSON progress for the running/last player build (polled by the page)."""
from app.utils.player_build import get_build_state
return jsonify(get_build_state())
+36 -51
View File
@@ -8,7 +8,9 @@ import bcrypt
from typing import Optional, Dict, List
from app.extensions import db, cache
from app.models import Player, Content, PlayerFeedback, ServerLog
from app.models import (
Player, Playlist, Content, PlayerFeedback, ServerLog,
)
from app.utils.logger import log_action
api_bp = Blueprint('api', __name__, url_prefix='/api')
@@ -86,6 +88,25 @@ def verify_player_auth(f):
return decorated_function
def get_assigned_playlist(player: Player) -> Optional[Playlist]:
"""Return the playlist assigned to *player*, or ``None`` if unassigned.
Centralises playlist lookup so every endpoint reports the same sync
version. Playlist edits bump ``Playlist.version``; players poll that
value to decide whether their cached content is stale. A player with no
assigned playlist has nothing to sync and resolves to version 0.
Args:
player: The player whose assigned playlist should be resolved.
Returns:
The assigned ``Playlist`` instance, or ``None`` when unassigned.
"""
if not player.playlist_id:
return None
return db.session.get(Playlist, player.playlist_id)
@api_bp.route('/health', methods=['GET'])
def health_check():
"""API health check endpoint."""
@@ -113,7 +134,7 @@ def authenticate_player():
quickconnect_code: Quick connect code (optional if using password)
Returns:
JSON with auth_code, player_id, group_id, and configuration
JSON with auth_code, player_id, playlist_id, and configuration
"""
data = request.get_json()
@@ -265,12 +286,8 @@ def get_playlist_by_quickconnect():
db.session.commit()
# Get playlist version from the assigned playlist
playlist_version = 1
if player.playlist_id:
from app.models import Playlist
assigned_playlist = Playlist.query.get(player.playlist_id)
if assigned_playlist:
playlist_version = assigned_playlist.version
assigned_playlist = get_assigned_playlist(player)
playlist_version = assigned_playlist.version if assigned_playlist else 0
# Hash the quickconnect code for validation on client side
hashed_quickconnect = bcrypt.hashpw(
@@ -322,12 +339,8 @@ def get_player_playlist(player_id: int):
db.session.commit()
# Get playlist version from the assigned playlist
playlist_version = 1
if player.playlist_id:
from app.models import Playlist
assigned_playlist = Playlist.query.get(player.playlist_id)
if assigned_playlist:
playlist_version = assigned_playlist.version
assigned_playlist = get_assigned_playlist(player)
playlist_version = assigned_playlist.version if assigned_playlist else 0
return jsonify({
'player_id': player_id,
@@ -363,10 +376,16 @@ def get_playlist_version(player_id: int):
player.last_seen = datetime.utcnow()
db.session.commit()
# Player syncs against the version of its assigned playlist; the
# content count comes from that same playlist (Content has no
# player_id column - it reaches players through the playlist).
assigned_playlist = get_assigned_playlist(player)
return jsonify({
'player_id': player_id,
'playlist_version': player.playlist_version,
'content_count': Content.query.filter_by(player_id=player_id).count()
'playlist_id': player.playlist_id,
'playlist_version': assigned_playlist.version if assigned_playlist else 0,
'content_count': assigned_playlist.contents.count() if assigned_playlist else 0
})
except Exception as e:
@@ -378,7 +397,6 @@ def get_playlist_version(player_id: int):
def get_cached_playlist(player_id: int) -> List[Dict]:
"""Get cached playlist for a player based on assigned playlist."""
from flask import url_for
from app.models import Playlist
player = Player.query.get(player_id)
if not player or not player.playlist_id:
@@ -556,7 +574,6 @@ def get_player_status(player_id: int):
'player_id': player_id,
'name': player.name,
'location': player.location,
'group_id': player.group_id,
'status': player.status,
'is_online': is_online,
'last_seen': player.last_seen.isoformat() if player.last_seen else None,
@@ -593,7 +610,6 @@ def system_info():
try:
# Get counts
total_players = Player.query.count()
total_groups = Group.query.count()
total_content = Content.query.count()
# Count online players (seen in last 5 minutes)
@@ -610,7 +626,6 @@ def system_info():
'total': total_players,
'online': online_players
},
'groups': total_groups,
'content': total_content,
'logs_24h': recent_logs,
'timestamp': datetime.utcnow().isoformat()
@@ -621,35 +636,6 @@ def system_info():
return jsonify({'error': 'Internal server error'}), 500
# DEPRECATED: Groups functionality has been archived
# @api_bp.route('/groups', methods=['GET'])
# @rate_limit(max_requests=60, window=60)
# def list_groups():
# """List all groups with basic information."""
# try:
# groups = Group.query.order_by(Group.name).all()
#
# groups_data = []
# for group in groups:
# groups_data.append({
# 'id': group.id,
# 'name': group.name,
# 'description': group.description,
# 'player_count': group.players.count(),
# 'content_count': group.contents.count()
# })
#
# return jsonify({
# 'groups': groups_data,
# 'count': len(groups_data)
# })
#
# except Exception as e:
# log_action('error', f'Error listing groups: {str(e)}')
# return jsonify({'error': 'Internal server error'}), 500
@api_bp.route('/content', methods=['GET'])
@rate_limit(max_requests=60, window=60)
def list_content():
@@ -665,8 +651,7 @@ def list_content():
'type': content.content_type,
'duration': content.duration,
'size': content.file_size,
'uploaded_at': content.uploaded_at.isoformat(),
'group_count': content.groups.count()
'uploaded_at': content.uploaded_at.isoformat()
})
return jsonify({
-500
View File
@@ -1,500 +0,0 @@
"""Content blueprint for media upload and management."""
from flask import (Blueprint, render_template, request, redirect, url_for,
flash, jsonify, current_app, send_from_directory)
from flask_login import login_required
from werkzeug.utils import secure_filename
import os
from typing import Optional, Dict
import json
from app.extensions import db, cache
from app.models import Content, Group
from app.utils.logger import log_action
from app.utils.uploads import (
save_uploaded_file,
process_video_file,
process_pdf_file,
get_upload_progress,
set_upload_progress
)
content_bp = Blueprint('content', __name__, url_prefix='/content')
# In-memory storage for upload progress (for simple demo; use Redis in production)
upload_progress = {}
@content_bp.route('/')
@login_required
def content_list():
"""Display list of all content."""
try:
# Get all unique content files (by filename)
from sqlalchemy import func
# Get content with player information
contents = Content.query.order_by(Content.filename, Content.uploaded_at.desc()).all()
# Group content by filename to show which players have each file
content_map = {}
for content in contents:
if content.filename not in content_map:
content_map[content.filename] = {
'content': content,
'players': [],
'groups': []
}
# Add player info if assigned to a player
if content.player_id:
from app.models import Player
player = Player.query.get(content.player_id)
if player:
content_map[content.filename]['players'].append({
'id': player.id,
'name': player.name,
'group': player.group.name if player.group else None
})
# Convert to list for template
content_list = []
for filename, data in content_map.items():
content_list.append({
'filename': filename,
'content_type': data['content'].content_type,
'duration': data['content'].duration,
'file_size': data['content'].file_size_mb,
'uploaded_at': data['content'].uploaded_at,
'players': data['players'],
'player_count': len(data['players'])
})
# Sort by upload date
content_list.sort(key=lambda x: x['uploaded_at'], reverse=True)
return render_template('content/content_list.html',
content_list=content_list)
except Exception as e:
log_action('error', f'Error loading content list: {str(e)}')
flash('Error loading content list.', 'danger')
return redirect(url_for('main.dashboard'))
@content_bp.route('/upload', methods=['GET', 'POST'])
@login_required
def upload_content():
"""Upload new content."""
if request.method == 'GET':
# Get parameters for return URL and pre-selection
player_id = request.args.get('player_id', type=int)
return_url = request.args.get('return_url', url_for('content.content_list'))
# Get all players for selection
from app.models import Player
players = Player.query.order_by(Player.name).all()
return render_template('content/upload_content.html',
players=players,
selected_player_id=player_id,
return_url=return_url)
try:
# Get form data
player_id = request.form.get('player_id', type=int)
media_type = request.form.get('media_type', 'image')
duration = request.form.get('duration', type=int, default=10)
session_id = request.form.get('session_id', os.urandom(8).hex())
return_url = request.form.get('return_url', url_for('content.content_list'))
# Get files
files = request.files.getlist('files')
if not files or files[0].filename == '':
flash('No files provided.', 'warning')
return redirect(url_for('content.upload_content'))
if not player_id:
flash('Please select a player.', 'warning')
return redirect(url_for('content.upload_content'))
# Initialize progress tracking using shared utility
set_upload_progress(session_id, 0, 'Starting upload...', 'uploading')
# Process each file
upload_folder = current_app.config['UPLOAD_FOLDER']
os.makedirs(upload_folder, exist_ok=True)
processed_count = 0
total_files = len(files)
for idx, file in enumerate(files):
if file.filename == '':
continue
# Update progress
progress_pct = int((idx / total_files) * 80) # 0-80% for file processing
set_upload_progress(session_id, progress_pct,
f'Processing file {idx + 1} of {total_files}...', 'processing')
filename = secure_filename(file.filename)
filepath = os.path.join(upload_folder, filename)
# Save file
file.save(filepath)
# Determine content type
file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
if file_ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp']:
content_type = 'image'
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm']:
content_type = 'video'
# Process video (convert to Raspberry Pi optimized format)
set_upload_progress(session_id, progress_pct + 5,
f'Optimizing video {idx + 1} for Raspberry Pi (30fps, H.264)...', 'processing')
success, message = process_video_file(filepath, session_id)
if not success:
log_action('error', f'Video optimization failed: {message}')
continue # Skip this file and move to next
elif file_ext == 'pdf':
content_type = 'pdf'
# Process PDF (convert to images)
set_upload_progress(session_id, progress_pct + 5,
f'Converting PDF {idx + 1}...', 'processing')
# process_pdf_file(filepath, session_id)
elif file_ext in ['ppt', 'pptx']:
content_type = 'presentation'
# Process presentation (convert to PDF then images)
set_upload_progress(session_id, progress_pct + 5,
f'Converting PowerPoint {idx + 1}...', 'processing')
# This would call pptx_converter utility
else:
content_type = 'other'
# Create content record linked to player
from app.models import Player
player = Player.query.get(player_id)
if player:
new_content = Content(
filename=filename,
content_type=content_type,
duration=duration,
file_size=os.path.getsize(filepath),
player_id=player_id
)
db.session.add(new_content)
# Increment playlist version
player.playlist_version += 1
log_action('info', f'Content "{filename}" added to player "{player.name}" (version {player.playlist_version})')
processed_count += 1
# Commit all changes
set_upload_progress(session_id, 90, 'Saving to database...', 'processing')
db.session.commit()
# Complete
set_upload_progress(session_id, 100,
f'Successfully uploaded {processed_count} file(s)!', 'complete')
# Clear all playlist caches
cache.clear()
log_action('info', f'{processed_count} files uploaded successfully (Type: {media_type})')
flash(f'{processed_count} file(s) uploaded successfully.', 'success')
return redirect(return_url)
except Exception as e:
db.session.rollback()
# Update progress to error state
if 'session_id' in locals():
set_upload_progress(session_id, 0, f'Upload failed: {str(e)}', 'error')
log_action('error', f'Error uploading content: {str(e)}')
flash('Error uploading content. Please try again.', 'danger')
return redirect(url_for('content.upload_content'))
@content_bp.route('/<int:content_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_content(content_id: int):
"""Edit content metadata."""
content = Content.query.get_or_404(content_id)
if request.method == 'GET':
return render_template('content/edit_content.html', content=content)
try:
duration = request.form.get('duration', type=int)
description = request.form.get('description', '').strip()
# Update content
if duration is not None:
content.duration = duration
content.description = description or None
db.session.commit()
# Clear caches
cache.clear()
log_action('info', f'Content "{content.filename}" (ID: {content_id}) updated')
flash(f'Content "{content.filename}" updated successfully.', 'success')
return redirect(url_for('content.content_list'))
except Exception as e:
db.session.rollback()
log_action('error', f'Error updating content: {str(e)}')
flash('Error updating content. Please try again.', 'danger')
return redirect(url_for('content.edit_content', content_id=content_id))
@content_bp.route('/<int:content_id>/delete', methods=['POST'])
@login_required
def delete_content(content_id: int):
"""Delete content and associated file."""
try:
content = Content.query.get_or_404(content_id)
filename = content.filename
# Delete file from disk
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
if os.path.exists(filepath):
os.remove(filepath)
# Delete from database
db.session.delete(content)
db.session.commit()
# Clear caches
cache.clear()
log_action('info', f'Content "{filename}" (ID: {content_id}) deleted')
flash(f'Content "{filename}" deleted successfully.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error deleting content: {str(e)}')
flash('Error deleting content. Please try again.', 'danger')
return redirect(url_for('content.content_list'))
@content_bp.route('/delete-by-filename', methods=['POST'])
@login_required
def delete_by_filename():
"""Delete all content entries with a specific filename."""
try:
data = request.get_json()
filename = data.get('filename')
if not filename:
return jsonify({'success': False, 'message': 'No filename provided'}), 400
# Find all content entries with this filename
contents = Content.query.filter_by(filename=filename).all()
if not contents:
return jsonify({'success': False, 'message': 'Content not found'}), 404
deleted_count = len(contents)
# Delete file from disk (only once)
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
if os.path.exists(filepath):
os.remove(filepath)
log_action('info', f'Deleted file from disk: {filename}')
# Delete all database entries
for content in contents:
db.session.delete(content)
db.session.commit()
# Clear caches
cache.clear()
log_action('info', f'Content "{filename}" deleted from {deleted_count} playlist(s)')
return jsonify({
'success': True,
'message': f'Content deleted from {deleted_count} playlist(s)',
'deleted_count': deleted_count
})
except Exception as e:
db.session.rollback()
log_action('error', f'Error deleting content by filename: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@content_bp.route('/bulk/delete', methods=['POST'])
@login_required
def bulk_delete_content():
"""Delete multiple content items at once."""
try:
content_ids = request.json.get('content_ids', [])
if not content_ids:
return jsonify({'success': False, 'error': 'No content selected'}), 400
# Delete content
deleted_count = 0
for content_id in content_ids:
content = Content.query.get(content_id)
if content:
# Delete file
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
if os.path.exists(filepath):
os.remove(filepath)
db.session.delete(content)
deleted_count += 1
db.session.commit()
# Clear caches
cache.clear()
log_action('info', f'Bulk deleted {deleted_count} content items')
return jsonify({'success': True, 'deleted': deleted_count})
except Exception as e:
db.session.rollback()
log_action('error', f'Error bulk deleting content: {str(e)}')
return jsonify({'success': False, 'error': str(e)}), 500
@content_bp.route('/upload-progress/<upload_id>')
@login_required
def upload_progress_status(upload_id: str):
"""Get upload progress for a specific upload."""
progress = get_upload_progress(upload_id)
return jsonify(progress)
@content_bp.route('/preview/<int:content_id>')
@login_required
def preview_content(content_id: int):
"""Preview content in browser."""
try:
content = Content.query.get_or_404(content_id)
# Serve file from uploads folder
return send_from_directory(
current_app.config['UPLOAD_FOLDER'],
content.filename,
as_attachment=False
)
except Exception as e:
log_action('error', f'Error previewing content: {str(e)}')
return "Error loading content", 500
@content_bp.route('/<int:content_id>/download')
@login_required
def download_content(content_id: int):
"""Download content file."""
try:
content = Content.query.get_or_404(content_id)
log_action('info', f'Content "{content.filename}" downloaded')
return send_from_directory(
current_app.config['UPLOAD_FOLDER'],
content.filename,
as_attachment=True
)
except Exception as e:
log_action('error', f'Error downloading content: {str(e)}')
return "Error downloading content", 500
@content_bp.route('/statistics')
@login_required
def content_statistics():
"""Get content statistics."""
try:
total_content = Content.query.count()
# Count by type
type_counts = {}
for content_type in ['image', 'video', 'pdf', 'presentation', 'other']:
count = Content.query.filter_by(content_type=content_type).count()
type_counts[content_type] = count
# Calculate total storage
upload_folder = current_app.config['UPLOAD_FOLDER']
total_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):
total_size += os.path.getsize(filepath)
return jsonify({
'total': total_content,
'by_type': type_counts,
'total_size_mb': round(total_size / (1024 * 1024), 2)
})
except Exception as e:
log_action('error', f'Error getting content statistics: {str(e)}')
return jsonify({'error': str(e)}), 500
@content_bp.route('/check-duplicates')
@login_required
def check_duplicates():
"""Check for duplicate filenames."""
try:
# Get all filenames
all_content = Content.query.all()
filename_counts = {}
for content in all_content:
filename_counts[content.filename] = filename_counts.get(content.filename, 0) + 1
# Find duplicates
duplicates = {fname: count for fname, count in filename_counts.items() if count > 1}
return jsonify({
'has_duplicates': len(duplicates) > 0,
'duplicates': duplicates
})
except Exception as e:
log_action('error', f'Error checking duplicates: {str(e)}')
return jsonify({'error': str(e)}), 500
@content_bp.route('/<int:content_id>/groups')
@login_required
def content_groups_info(content_id: int):
"""Get groups that contain this content."""
try:
content = Content.query.get_or_404(content_id)
groups_data = []
for group in content.groups:
groups_data.append({
'id': group.id,
'name': group.name,
'description': group.description,
'player_count': group.players.count()
})
return jsonify({
'content_id': content_id,
'filename': content.filename,
'groups': groups_data
})
except Exception as e:
log_action('error', f'Error getting content groups: {str(e)}')
return jsonify({'error': str(e)}), 500
-102
View File
@@ -585,14 +585,6 @@ def get_player_playlist(player_id: int) -> List[dict]:
return playlist
@players_bp.route('/<int:player_id>/reorder', methods=['POST'])
@login_required
def reorder_content(player_id: int):
"""Legacy endpoint - Content reordering now handled in playlist management."""
return jsonify({
'success': False,
'error': 'Content reordering is now managed through playlists. Use the Playlists page to reorder content.'
}), 400
@players_bp.route('/bulk/delete', methods=['POST'])
@@ -686,97 +678,3 @@ def deployment_status():
except Exception as e:
log_action('error', f'Error fetching deployment status: {str(e)}')
return jsonify({'error': str(e)}), 500
@players_bp.route('/<int:player_id>/playlist/reorder', methods=['POST'])
@login_required
def reorder_playlist(player_id: int):
"""Reorder items in player's playlist."""
try:
data = request.get_json()
content_id = data.get('content_id')
direction = data.get('direction') # 'up' or 'down'
if not content_id or not direction:
return jsonify({'success': False, 'message': 'Missing parameters'}), 400
# Get the content item
content = Content.query.filter_by(id=content_id, player_id=player_id).first()
if not content:
return jsonify({'success': False, 'message': 'Content not found'}), 404
# Get all content for this player, ordered by position
all_content = Content.query.filter_by(player_id=player_id)\
.order_by(Content.position, Content.uploaded_at).all()
# Find current index
current_index = None
for idx, item in enumerate(all_content):
if item.id == content_id:
current_index = idx
break
if current_index is None:
return jsonify({'success': False, 'message': 'Content not in playlist'}), 404
# Swap positions
if direction == 'up' and current_index > 0:
# Swap with previous item
all_content[current_index].position, all_content[current_index - 1].position = \
all_content[current_index - 1].position, all_content[current_index].position
elif direction == 'down' and current_index < len(all_content) - 1:
# Swap with next item
all_content[current_index].position, all_content[current_index + 1].position = \
all_content[current_index + 1].position, all_content[current_index].position
db.session.commit()
cache.delete_memoized(get_player_playlist, player_id)
log_action('info', f'Reordered playlist for player {player_id}')
return jsonify({'success': True})
except Exception as e:
db.session.rollback()
log_action('error', f'Error reordering playlist: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@players_bp.route('/<int:player_id>/playlist/remove', methods=['POST'])
@login_required
def remove_from_playlist(player_id: int):
"""Remove content from player's playlist."""
try:
data = request.get_json()
content_id = data.get('content_id')
if not content_id:
return jsonify({'success': False, 'message': 'Missing content_id'}), 400
# Get the content item
content = Content.query.filter_by(id=content_id, player_id=player_id).first()
if not content:
return jsonify({'success': False, 'message': 'Content not found'}), 404
filename = content.filename
# Delete from database
db.session.delete(content)
# Increment playlist version
player = Player.query.get(player_id)
if player:
player.playlist_version += 1
db.session.commit()
# Clear cache
cache.delete_memoized(get_player_playlist, player_id)
log_action('info', f'Removed "{filename}" from player {player_id} playlist (version {player.playlist_version})')
return jsonify({'success': True, 'message': f'Removed "{filename}" from playlist'})
except Exception as e:
db.session.rollback()
log_action('error', f'Error removing from playlist: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
-310
View File
@@ -1,310 +0,0 @@
"""Playlist blueprint for managing player playlists."""
from flask import (Blueprint, render_template, request, redirect, url_for,
flash, jsonify, current_app)
from flask_login import login_required
from sqlalchemy import desc, update
import os
from app.extensions import db, cache
from app.models import Player, Content, Playlist
from app.models.playlist import playlist_content
from app.utils.logger import log_action
playlist_bp = Blueprint('playlist', __name__, url_prefix='/playlist')
@playlist_bp.route('/<int:player_id>')
@login_required
def manage_playlist(player_id: int):
"""Legacy route - redirect to new content management area."""
player = Player.query.get_or_404(player_id)
if player.playlist_id:
# Redirect to the new content management interface
return redirect(url_for('content.manage_playlist_content', playlist_id=player.playlist_id))
else:
# Player has no playlist assigned
flash('This player has no playlist assigned.', 'warning')
return redirect(url_for('players.manage_player', player_id=player_id))
@playlist_bp.route('/<int:player_id>/add', methods=['POST'])
@login_required
def add_to_playlist(player_id: int):
"""Add content to player's playlist."""
player = Player.query.get_or_404(player_id)
if not player.playlist_id:
flash('Player has no playlist assigned.', 'warning')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
try:
content_id = request.form.get('content_id', type=int)
duration = request.form.get('duration', type=int, default=10)
if not content_id:
flash('Please select content.', 'warning')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
content = Content.query.get_or_404(content_id)
playlist = Playlist.query.get(player.playlist_id)
# Get max position
from sqlalchemy import select, func
max_pos = db.session.execute(
select(func.max(playlist_content.c.position)).where(
playlist_content.c.playlist_id == playlist.id
)
).scalar() or 0
# Add to playlist_content association table
stmt = playlist_content.insert().values(
playlist_id=playlist.id,
content_id=content.id,
position=max_pos + 1,
duration=duration
)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Added "{content.filename}" to playlist for player "{player.name}"')
flash(f'Added "{content.filename}" to playlist.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error adding to playlist: {str(e)}')
flash('Error adding to playlist.', 'danger')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
@playlist_bp.route('/<int:player_id>/remove/<int:content_id>', methods=['POST'])
@login_required
def remove_from_playlist(player_id: int, content_id: int):
"""Remove content from player's playlist."""
player = Player.query.get_or_404(player_id)
if not player.playlist_id:
flash('Player has no playlist assigned.', 'danger')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
try:
content = Content.query.get_or_404(content_id)
playlist = Playlist.query.get(player.playlist_id)
filename = content.filename
# Remove from playlist_content association table
from sqlalchemy import delete
stmt = delete(playlist_content).where(
(playlist_content.c.playlist_id == playlist.id) &
(playlist_content.c.content_id == content_id)
)
db.session.execute(stmt)
# Reorder remaining content
from sqlalchemy import select
remaining = db.session.execute(
select(playlist_content.c.content_id, playlist_content.c.position).where(
playlist_content.c.playlist_id == playlist.id
).order_by(playlist_content.c.position)
).fetchall()
for idx, row in enumerate(remaining, start=1):
stmt = update(playlist_content).where(
(playlist_content.c.playlist_id == playlist.id) &
(playlist_content.c.content_id == row.content_id)
).values(position=idx)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Removed "{filename}" from playlist for player "{player.name}"')
flash(f'Removed "{filename}" from playlist.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error removing from playlist: {str(e)}')
flash('Error removing from playlist.', 'danger')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
@playlist_bp.route('/<int:player_id>/reorder', methods=['POST'])
@login_required
def reorder_playlist(player_id: int):
"""Reorder playlist items."""
player = Player.query.get_or_404(player_id)
if not player.playlist_id:
return jsonify({'success': False, 'message': 'Player has no playlist'}), 400
try:
playlist = Playlist.query.get(player.playlist_id)
# Get new order from JSON
data = request.get_json()
content_ids = data.get('content_ids', [])
if not content_ids:
return jsonify({'success': False, 'message': 'No content IDs provided'}), 400
# Update positions in association table
for idx, content_id in enumerate(content_ids, start=1):
stmt = update(playlist_content).where(
(playlist_content.c.playlist_id == playlist.id) &
(playlist_content.c.content_id == content_id)
).values(position=idx)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Reordered playlist for player "{player.name}" (version {playlist.version})')
return jsonify({
'success': True,
'message': 'Playlist reordered successfully',
'version': playlist.version
})
except Exception as e:
db.session.rollback()
log_action('error', f'Error reordering playlist: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@playlist_bp.route('/<int:player_id>/update-duration/<int:content_id>', methods=['POST'])
@login_required
def update_duration(player_id: int, content_id: int):
"""Update content duration in playlist."""
player = Player.query.get_or_404(player_id)
if not player.playlist_id:
return jsonify({'success': False, 'message': 'Player has no playlist'}), 400
try:
playlist = Playlist.query.get(player.playlist_id)
content = Content.query.get_or_404(content_id)
duration = request.form.get('duration', type=int)
if not duration or duration < 1:
return jsonify({'success': False, 'message': 'Invalid duration'}), 400
# Update duration in association table
stmt = update(playlist_content).where(
(playlist_content.c.playlist_id == playlist.id) &
(playlist_content.c.content_id == content_id)
).values(duration=duration)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Updated duration for "{content.filename}" in player "{player.name}" playlist')
return jsonify({
'success': True,
'message': 'Duration updated',
'version': playlist.version
})
except Exception as e:
db.session.rollback()
log_action('error', f'Error updating duration: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@playlist_bp.route('/<int:player_id>/update-muted/<int:content_id>', methods=['POST'])
@login_required
def update_muted(player_id: int, content_id: int):
"""Update content muted setting in playlist."""
player = Player.query.get_or_404(player_id)
if not player.playlist_id:
return jsonify({'success': False, 'message': 'Player has no playlist'}), 400
try:
playlist = Playlist.query.get(player.playlist_id)
content = Content.query.get_or_404(content_id)
muted = request.form.get('muted', 'true').lower() == 'true'
# Update muted in association table
stmt = update(playlist_content).where(
(playlist_content.c.playlist_id == playlist.id) &
(playlist_content.c.content_id == content_id)
).values(muted=muted)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Updated muted={muted} for "{content.filename}" in player "{player.name}" playlist')
return jsonify({
'success': True,
'message': 'Audio setting updated',
'muted': muted,
'version': playlist.version
})
except Exception as e:
db.session.rollback()
log_action('error', f'Error updating muted setting: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@playlist_bp.route('/<int:player_id>/clear', methods=['POST'])
@login_required
def clear_playlist(player_id: int):
"""Clear all content from player's playlist."""
player = Player.query.get_or_404(player_id)
if not player.playlist_id:
flash('Player has no playlist assigned.', 'warning')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
try:
playlist = Playlist.query.get(player.playlist_id)
# Delete all content from playlist
from sqlalchemy import delete
stmt = delete(playlist_content).where(
playlist_content.c.playlist_id == playlist.id
)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit()
cache.clear()
log_action('info', f'Cleared playlist for player "{player.name}"')
flash('Playlist cleared successfully.', 'success')
except Exception as e:
db.session.rollback()
log_action('error', f'Error clearing playlist: {str(e)}')
flash('Error clearing playlist.', 'danger')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
-3
View File
@@ -1,7 +1,6 @@
"""Models package for digiserver-v2."""
from app.models.user import User
from app.models.player import Player
from app.models.group import Group, group_content
from app.models.playlist import Playlist, playlist_content
from app.models.content import Content
from app.models.server_log import ServerLog
@@ -13,7 +12,6 @@ from app.models.https_config import HTTPSConfig
__all__ = [
'User',
'Player',
'Group',
'Playlist',
'Content',
'ServerLog',
@@ -21,6 +19,5 @@ __all__ = [
'PlayerEdit',
'PlayerUser',
'HTTPSConfig',
'group_content',
'playlist_content',
]
-7
View File
@@ -38,8 +38,6 @@ class Content(db.Model):
# Relationships - many-to-many with playlists
playlists = db.relationship('Playlist', secondary='playlist_content',
back_populates='contents', lazy='dynamic')
groups = db.relationship('Group', secondary='group_content',
back_populates='contents', lazy='dynamic')
def __repr__(self) -> str:
"""String representation of Content."""
@@ -52,11 +50,6 @@ class Content(db.Model):
return round(self.file_size / (1024 * 1024), 2)
return 0.0
@property
def group_count(self) -> int:
"""Get number of groups containing this content."""
return self.groups.count()
@property
def original_display_name(self) -> str:
"""Name of the original (unedited) file for display purposes."""
-66
View File
@@ -1,66 +0,0 @@
"""Group model for organizing players and content."""
from datetime import datetime
from typing import List, Optional
from app.extensions import db
# Association table for many-to-many relationship between groups and content
group_content = db.Table('group_content',
db.Column('group_id', db.Integer, db.ForeignKey('group.id'), primary_key=True),
db.Column('content_id', db.Integer, db.ForeignKey('content.id'), primary_key=True)
)
class Group(db.Model):
"""Group model for organizing players with shared content.
Attributes:
id: Primary key
name: Unique group name
description: Optional group description
created_at: Group creation timestamp
updated_at: Last modification timestamp
"""
__tablename__ = 'group'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False, unique=True, index=True)
description = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
onupdate=datetime.utcnow, nullable=False)
# Relationships
contents = db.relationship('Content', secondary=group_content,
back_populates='groups', lazy='dynamic')
def __repr__(self) -> str:
"""String representation of Group."""
return f'<Group {self.name} (ID={self.id})>'
@property
def content_count(self) -> int:
"""Get number of content items in this group."""
return self.contents.count()
def add_player(self, player) -> None:
"""Add a player to this group.
Args:
player: Player instance to add
"""
player.group_id = self.id
self.updated_at = datetime.utcnow()
def remove_player(self, player) -> None:
"""Remove a player from this group.
Args:
player: Player instance to remove
"""
if player.group_id == self.id:
player.group_id = None
self.updated_at = datetime.utcnow()
+1 -1
View File
@@ -19,7 +19,7 @@ class Player(db.Model):
orientation: Display orientation (Landscape/Portrait)
status: Current player status (online, offline, error)
last_seen: Last activity timestamp
playlist_version: Version number for playlist synchronization
playlist_id: Assigned playlist (sync version comes from Playlist.version)
created_at: Player creation timestamp
"""
__tablename__ = 'player'
+82 -2
View File
@@ -42,6 +42,24 @@
{% endif %}
</div>
<!-- Live build progress (updated by polling below) -->
<div class="card" id="build-progress" style="display: none;">
<h2>Build progress</h2>
<p id="build-progress-text" style="margin: 0;">
<span class="badge badge-warning" id="build-progress-badge">⏳ Running</span>
<span id="build-progress-step"></span>
</p>
<p id="build-progress-message" style="color: #6c757d; margin-top: 8px;"></p>
<div style="margin-top: 10px;">
<button type="button" class="btn btn-secondary" onclick="window.location.reload()">
🔄 Refresh page
</button>
</div>
<p style="color: #6c757d; font-size: 13px; margin-top: 10px;">
The clone takes a couple of minutes. You can leave this page and come back.
</p>
</div>
<form method="POST" action="{{ url_for('admin.build_player_action') }}">
<!-- Repository -->
<div class="card">
@@ -114,10 +132,12 @@
<div class="card">
<h2>3. Build</h2>
<div class="card-actions" style="display: flex; gap: 10px; flex-wrap: wrap;">
<button type="submit" name="action" value="build_and_config" class="btn btn-primary">
<button type="submit" name="action" value="build_and_config"
class="btn btn-primary" id="btn-build-all">
⬇️ Build files &amp; write config
</button>
<button type="submit" name="action" value="build_files" class="btn btn-secondary">
<button type="submit" name="action" value="build_files"
class="btn btn-secondary" id="btn-build-files">
Build files only
</button>
<button type="submit" name="action" value="save_config" class="btn btn-secondary">
@@ -127,4 +147,64 @@
</div>
</form>
</div>
<script>
// Poll the build status so the admin sees live progress instead of a request
// that appears to hang (the clone takes ~1-2 minutes).
(function () {
const statusUrl = "{{ url_for('admin.build_player_status') }}";
const panel = document.getElementById('build-progress');
const badge = document.getElementById('build-progress-badge');
const step = document.getElementById('build-progress-step');
const message = document.getElementById('build-progress-message');
const bAll = document.getElementById('btn-build-all');
const bFiles = document.getElementById('btn-build-files');
let sawRunning = false;
function render(s) {
const state = s.state || 'idle';
if (state === 'running') {
sawRunning = true;
panel.style.display = 'block';
badge.className = 'badge badge-warning';
badge.textContent = '⏳ Running';
step.textContent = s.step || '';
message.textContent = '';
if (bAll) { bAll.disabled = true; bAll.textContent = '⏳ Building…'; }
if (bFiles) { bFiles.disabled = true; }
return;
}
// Reached a terminal state.
if (state === 'success' || state === 'error') {
panel.style.display = 'block';
if (state === 'success') {
badge.className = 'badge badge-success';
badge.textContent = '✅ Build complete';
} else {
badge.className = 'badge badge-danger';
badge.textContent = '❌ Build failed';
}
step.textContent = '';
message.textContent = s.message || '';
if (sawRunning) {
// Reload once so the staged-version panel reflects the new build.
setTimeout(function () { window.location.reload(); }, 1500);
}
}
}
function poll() {
fetch(statusUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (s) { if (s) render(s); })
.catch(function () { /* transient — keep polling */ });
}
poll();
setInterval(poll, 3000);
})();
</script>
{% endblock %}
-205
View File
@@ -1,205 +0,0 @@
{% extends "base.html" %}
{% block title %}Content Library - DigiServer v2{% endblock %}
{% block content %}
<div class="container">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h1>Content Library</h1>
<a href="{{ url_for('content.upload_content') }}" class="btn btn-success">+ Upload Content</a>
</div>
{% if content_list %}
<div class="card">
<div style="margin-bottom: 15px; padding: 15px; background: #f8f9fa; border-radius: 5px;">
<strong>Total Files:</strong> {{ content_list|length }} |
<strong>Total Assignments:</strong> {% set total = namespace(count=0) %}{% for item in content_list %}{% set total.count = total.count + item.player_count %}{% endfor %}{{ total.count }}
</div>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background: #f8f9fa; text-align: left;">
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">File Name</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Type</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Duration</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Size</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Assigned To</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Uploaded</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Actions</th>
</tr>
</thead>
<tbody>
{% for item in content_list %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 12px;">
<strong>{{ item.filename }}</strong>
</td>
<td style="padding: 12px;">
{% if item.content_type == 'image' %}
<span style="background: #28a745; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📷 Image</span>
{% elif item.content_type == 'video' %}
<span style="background: #007bff; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">🎬 Video</span>
{% elif item.content_type == 'pdf' %}
<span style="background: #dc3545; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📄 PDF</span>
{% elif item.content_type == 'presentation' %}
<span style="background: #ffc107; color: black; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📊 PPT</span>
{% else %}
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📁 Other</span>
{% endif %}
</td>
<td style="padding: 12px;">
{{ item.duration }}s
</td>
<td style="padding: 12px;">
{{ item.file_size }} MB
</td>
<td style="padding: 12px;">
{% if item.player_count == 0 %}
<span style="color: #6c757d; font-style: italic;">Not assigned</span>
{% else %}
<div style="max-height: 100px; overflow-y: auto;">
{% for player in item.players %}
<div style="margin-bottom: 5px;">
<strong>{{ player.name }}</strong>
{% if player.group %}
<span style="color: #6c757d; font-size: 12px;">({{ player.group }})</span>
{% endif %}
</div>
{% endfor %}
</div>
<div style="margin-top: 5px;">
<span style="background: #007bff; color: white; padding: 2px 6px; border-radius: 3px; font-size: 11px;">
{{ item.player_count }} player{% if item.player_count != 1 %}s{% endif %}
</span>
</div>
{% endif %}
</td>
<td style="padding: 12px;">
<small style="color: #6c757d;">{{ item.uploaded_at | localtime }}</small>
</td>
<td style="padding: 12px;">
{% if item.player_count > 0 %}
{% set first_player = item.players[0] %}
<a href="{{ url_for('players.player_page', player_id=first_player.id) }}"
class="btn btn-primary btn-sm"
title="Manage Playlist for {{ first_player.name }}"
style="margin-bottom: 5px;">
📝 Manage Playlist
</a>
{% if item.player_count > 1 %}
<button onclick="showAllPlayers('{{ item.filename|replace("'", "\\'") }}', {{ item.players|tojson }})"
class="btn btn-info btn-sm"
title="View all players with this content">
👥 View All ({{ item.player_count }})
</button>
{% endif %}
{% endif %}
<button onclick="deleteContent('{{ item.filename|replace("'", "\\'") }}')"
class="btn btn-danger btn-sm"
title="Delete this content from all playlists"
style="margin-top: 5px;">
🗑️ Delete
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="background: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460; padding: 15px; border-radius: 5px;">
️ No content uploaded yet. <a href="{{ url_for('content.upload_content') }}" style="color: #0c5460; text-decoration: underline;">Upload your first content</a>
</div>
{% endif %}
</div>
<!-- Modal for viewing all players -->
<div id="playersModal" class="modal" style="display: none;">
<div class="modal-content" style="max-width: 600px; margin: 100px auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);">
<h2 id="modalTitle" style="margin-bottom: 20px; color: #2c3e50;">Players with this content</h2>
<div id="playersList" style="max-height: 400px; overflow-y: auto;"></div>
<div style="text-align: center; margin-top: 20px;">
<button type="button" class="btn" onclick="closePlayersModal()">Close</button>
</div>
</div>
</div>
<style>
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
overflow-y: auto;
}
</style>
<script>
function showAllPlayers(filename, players) {
document.getElementById('modalTitle').textContent = 'Players with: ' + filename;
const playersList = document.getElementById('playersList');
playersList.innerHTML = '<table style="width: 100%; border-collapse: collapse;">';
playersList.innerHTML += '<thead><tr style="background: #f8f9fa;"><th style="padding: 10px; text-align: left;">Player Name</th><th style="padding: 10px; text-align: left;">Group</th><th style="padding: 10px; text-align: left;">Action</th></tr></thead><tbody>';
players.forEach(player => {
playersList.innerHTML += `
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px;"><strong>${player.name}</strong></td>
<td style="padding: 10px;">${player.group || '-'}</td>
<td style="padding: 10px;">
<a href="/players/${player.id}" class="btn btn-sm" style="background: #007bff; color: white; padding: 5px 10px; text-decoration: none; border-radius: 3px;">
Manage Playlist
</a>
</td>
</tr>
`;
});
playersList.innerHTML += '</tbody></table>';
document.getElementById('playersModal').style.display = 'block';
}
function closePlayersModal() {
document.getElementById('playersModal').style.display = 'none';
}
function deleteContent(filename) {
if (confirm(`Are you sure you want to delete "${filename}"?\n\nThis will remove it from ALL player playlists!`)) {
fetch('/content/delete-by-filename', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
filename: filename
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(`Successfully deleted "${filename}" from ${data.deleted_count} playlist(s)`);
location.reload();
} else {
alert('Error deleting content: ' + data.message);
}
})
.catch(error => {
alert('Error deleting content: ' + error);
});
}
}
// Close modal when clicking outside
window.onclick = function(event) {
const modal = document.getElementById('playersModal');
if (event.target == modal) {
closePlayersModal();
}
}
</script>
{% endblock %}
-11
View File
@@ -1,11 +0,0 @@
{% extends "base.html" %}
{% block title %}Edit Content{% endblock %}
{% block content %}
<div class="container">
<h2>Edit Content</h2>
<p>Edit content functionality - placeholder</p>
<a href="{{ url_for('content.list') }}" class="btn btn-secondary">Back to Content</a>
</div>
{% endblock %}
-278
View File
@@ -1,278 +0,0 @@
{% extends "base.html" %}
{% block title %}Upload Content - DigiServer v2{% endblock %}
{% block content %}
<div class="container" style="max-width: 1200px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h1>Upload Content</h1>
</div>
<form id="upload-form" method="POST" enctype="multipart/form-data" onsubmit="handleFormSubmit(event)">
<input type="hidden" name="return_url" value="{{ return_url or url_for('content.content_list') }}">
<div class="card" style="margin-bottom: 20px;">
<h3 style="margin-bottom: 15px;">Select Player</h3>
<div>
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Player:</label>
<select name="player_id" id="player_id" class="form-control" required>
<option value="" disabled {% if not selected_player_id %}selected{% endif %}>Select a Player</option>
{% for player in players %}
<option value="{{ player.id }}" {% if selected_player_id == player.id %}selected{% endif %}>
{{ player.name }} - {{ player.location or 'No location' }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="card" style="margin-bottom: 20px;">
<h3 style="margin-bottom: 15px;">Media Details</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px;">
<div>
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Media Type:</label>
<select name="media_type" id="media_type" class="form-control" required onchange="handleMediaTypeChange()">
<option value="image">Image (JPG, PNG, GIF)</option>
<option value="video">Video (MP4, AVI, MOV)</option>
<option value="pdf">PDF Document</option>
<option value="ppt">PowerPoint (PPT/PPTX)</option>
</select>
<small style="color: #6c757d; display: block; margin-top: 5px;" id="media-type-hint">
Images will be displayed as-is
</small>
</div>
<div>
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Duration (seconds):</label>
<input type="number" name="duration" id="duration" class="form-control" required min="1" value="10">
<small style="color: #6c757d; display: block; margin-top: 5px;">
How long to display each image/slide (videos use actual length)
</small>
</div>
</div>
<div>
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Files:</label>
<input type="file" name="files" id="files" class="form-control" multiple required
accept="image/*,video/*,.pdf,.ppt,.pptx" onchange="handleFileChange()">
<small style="color: #6c757d; display: block; margin-top: 5px;">
Select multiple files. Supported: JPG, PNG, GIF, MP4, PDF, PPT, PPTX
</small>
<div id="file-list" style="margin-top: 10px;"></div>
</div>
</div>
<div style="text-align: center;">
<button type="submit" id="submit-button" class="btn btn-success" style="padding: 10px 30px; font-size: 16px;">
📤 Upload Files
</button>
<a href="{{ return_url or url_for('content.content_list') }}" class="btn" style="padding: 10px 30px; font-size: 16px;">
← Back
</a>
</div>
</form>
</div>
<!-- Modal for Status Updates -->
<div id="statusModal" class="modal" style="display: none;">
<div class="modal-content" style="max-width: 800px; margin: 50px auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);">
<h2 style="margin-bottom: 20px; color: #2c3e50;">Processing Files</h2>
<div style="margin-bottom: 20px;">
<p id="status-message" style="font-size: 16px; color: #555;">Uploading and processing your files. Please wait...</p>
</div>
<!-- Progress Bar -->
<div style="margin-bottom: 30px;">
<label style="display: block; margin-bottom: 10px; font-weight: bold;">File Processing Progress</label>
<div style="width: 100%; height: 30px; background: #e9ecef; border-radius: 5px; overflow: hidden;">
<div id="progress-bar" style="width: 0%; height: 100%; background: linear-gradient(90deg, #007bff, #0056b3); transition: width 0.3s ease; display: flex; align-items: center; justify-content: center; color: white; font-weight: bold; font-size: 14px;">
0%
</div>
</div>
</div>
<div style="text-align: center; margin-top: 20px;">
<button type="button" class="btn" onclick="closeModal()" disabled id="close-modal-btn">Close</button>
</div>
</div>
</div>
<style>
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
overflow-y: auto;
}
</style>
<script>
let progressInterval = null;
let sessionId = null;
let returnUrl = '{{ return_url or url_for("content.content_list") }}';
function generateSessionId() {
return 'upload_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
function handleFormSubmit(event) {
event.preventDefault();
sessionId = generateSessionId();
const form = document.getElementById('upload-form');
let sessionInput = document.getElementById('session_id_input');
if (!sessionInput) {
sessionInput = document.createElement('input');
sessionInput.type = 'hidden';
sessionInput.name = 'session_id';
sessionInput.id = 'session_id_input';
form.appendChild(sessionInput);
}
sessionInput.value = sessionId;
showStatusModal();
const formData = new FormData(form);
fetch(form.action, {
method: 'POST',
body: formData
})
.then(response => {
if (!response.ok) {
throw new Error('Upload failed');
}
console.log('Form submitted successfully');
})
.catch(error => {
console.error('Form submission error:', error);
document.getElementById('status-message').textContent = 'Upload failed: ' + error.message;
document.getElementById('progress-bar').style.background = '#dc3545';
document.getElementById('close-modal-btn').disabled = false;
});
}
function showStatusModal() {
const modal = document.getElementById('statusModal');
modal.style.display = 'block';
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 converting video. This may take several minutes...';
break;
case 'pdf':
statusMessage.textContent = 'Uploading and converting PDF to images...';
break;
case 'ppt':
statusMessage.textContent = 'Uploading and converting PowerPoint to images...';
break;
default:
statusMessage.textContent = 'Uploading and processing your files. Please wait...';
}
pollUploadProgress();
}
function closeModal() {
const modal = document.getElementById('statusModal');
modal.style.display = 'none';
if (progressInterval) {
clearInterval(progressInterval);
}
window.location.href = returnUrl;
}
function pollUploadProgress() {
progressInterval = setInterval(() => {
fetch(`/api/upload-progress/${sessionId}`)
.then(response => response.json())
.then(data => {
const progressBar = document.getElementById('progress-bar');
progressBar.style.width = `${data.progress}%`;
progressBar.textContent = `${data.progress}%`;
document.getElementById('status-message').textContent = data.message;
if (data.status === 'complete' || data.status === 'error') {
clearInterval(progressInterval);
progressInterval = null;
const closeBtn = document.getElementById('close-modal-btn');
closeBtn.disabled = false;
if (data.status === 'complete') {
progressBar.style.background = '#28a745';
setTimeout(() => closeModal(), 2000);
} else if (data.status === 'error') {
progressBar.style.background = '#dc3545';
}
}
})
.catch(error => console.error('Error fetching progress:', error));
}, 500);
}
function handleMediaTypeChange() {
const mediaType = document.getElementById('media_type').value;
const hint = document.getElementById('media-type-hint');
switch(mediaType) {
case 'image':
hint.textContent = 'Images will be displayed as-is';
break;
case 'video':
hint.textContent = 'Videos will be converted to optimized format';
break;
case 'pdf':
hint.textContent = 'PDF will be converted to images (one per page)';
break;
case 'ppt':
hint.textContent = 'PowerPoint will be converted to images (one per slide)';
break;
}
}
function handleFileChange() {
const filesInput = document.getElementById('files');
const fileList = document.getElementById('file-list');
const mediaType = document.getElementById('media_type').value;
const durationInput = document.getElementById('duration');
fileList.innerHTML = '';
if (filesInput.files.length > 0) {
fileList.innerHTML = '<strong>Selected files:</strong><ul style="margin: 5px 0; padding-left: 20px;">';
for (let i = 0; i < filesInput.files.length; i++) {
const file = filesInput.files[i];
const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
fileList.innerHTML += `<li>${file.name} (${sizeMB} MB)</li>`;
}
fileList.innerHTML += '</ul>';
}
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;
};
video.src = URL.createObjectURL(file);
}
}
</script>
{% endblock %}
-227
View File
@@ -1,227 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ player.name }} - DigiServer v2{% endblock %}
{% block content %}
<div class="container" style="max-width: 1400px;">
<!-- Header -->
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<div>
<h1>{{ player.name }}</h1>
<div style="margin-top: 10px;">
{% if status_info.online %}
<span style="background: #28a745; color: white; padding: 5px 12px; border-radius: 3px; font-size: 14px; margin-right: 10px;">
🟢 Online
</span>
{% else %}
<span style="background: #6c757d; color: white; padding: 5px 12px; border-radius: 3px; font-size: 14px; margin-right: 10px;">
⚫ Offline
</span>
{% endif %}
<span style="color: #6c757d; font-size: 14px;">
Last seen: {{ status_info.last_seen_ago }}
</span>
</div>
</div>
<div>
<a href="{{ url_for('players.edit_player', player_id=player.id) }}" class="btn btn-primary">
✏️ Edit Player
</a>
<a href="{{ url_for('playlist.manage_playlist', player_id=player.id) }}" class="btn btn-success">
🎬 Manage Playlist
</a>
<a href="{{ url_for('players.list') }}" class="btn">
← Back to Players
</a>
</div>
</div>
<!-- Main Content Grid -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px;">
<!-- Player Information Card -->
<div class="card">
<h3 style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #dee2e6;">
📋 Player Information
</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold; width: 40%;">Display Name:</td>
<td style="padding: 10px;">{{ player.name }}</td>
</tr>
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold;">Hostname:</td>
<td style="padding: 10px;">
<code style="background: #f8f9fa; padding: 3px 8px; border-radius: 3px;">{{ player.hostname }}</code>
</td>
</tr>
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold;">Location:</td>
<td style="padding: 10px;">{{ player.location or '-' }}</td>
</tr>
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold;">Orientation:</td>
<td style="padding: 10px;">{{ player.orientation or 'Landscape' }}</td>
</tr>
<tr>
<td style="padding: 10px; font-weight: bold;">Created:</td>
<td style="padding: 10px;">{{ player.created_at | localtime }}</td>
</tr>
</table>
</div>
<!-- Authentication Details Card -->
<div class="card">
<h3 style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #dee2e6;">
🔐 Authentication Details
</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold; width: 40%;">Password Set:</td>
<td style="padding: 10px;">
{% if player.password_hash %}
<span style="color: #28a745;">✓ Yes</span>
{% else %}
<span style="color: #dc3545;">✗ No</span>
{% endif %}
</td>
</tr>
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold;">Quick Connect Code:</td>
<td style="padding: 10px;">
{% if player.quickconnect_code %}
<span style="color: #28a745;">✓ Yes</span>
{% else %}
<span style="color: #dc3545;">✗ No</span>
{% endif %}
</td>
</tr>
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold;">Auth Code:</td>
<td style="padding: 10px;">
{% if player.auth_code %}
<span style="color: #28a745;">✓ Yes</span>
<form method="POST" action="{{ url_for('players.regenerate_auth_code', player_id=player.id) }}" style="display: inline; margin-left: 10px;">
<button type="submit" class="btn btn-sm" style="background: #ffc107; padding: 3px 8px;"
onclick="return confirm('Regenerate auth code? The player will need to authenticate again.')">
🔄 Regenerate
</button>
</form>
{% else %}
<span style="color: #dc3545;">✗ No</span>
{% endif %}
</td>
</tr>
<tr>
<td colspan="2" style="padding: 15px 10px;">
<a href="{{ url_for('players.edit_player', player_id=player.id) }}"
class="btn btn-primary" style="width: 100%; text-align: center;">
✏️ Edit Authentication Settings
</a>
</td>
</tr>
</table>
</div>
</div>
<!-- Playlist Management Card -->
<div class="card" style="margin-bottom: 20px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h3 style="margin: 0;">🎬 Playlist Management</h3>
</div>
{% if playlist %}
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin-bottom: 15px;">
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px;">
<div>
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Total Items</div>
<div style="font-size: 24px; font-weight: bold; color: #333;">{{ playlist|length }}</div>
</div>
<div>
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Total Duration</div>
<div style="font-size: 24px; font-weight: bold; color: #333;">
{% set total_duration = namespace(value=0) %}
{% for item in playlist %}
{% set total_duration.value = total_duration.value + (item.duration or 10) %}
{% endfor %}
{{ total_duration.value }}s
</div>
</div>
<div>
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Playlist Version</div>
<div style="font-size: 24px; font-weight: bold; color: #333;">{{ player.playlist_version }}</div>
</div>
</div>
</div>
{% endif %}
<a href="{{ url_for('playlist.manage_playlist', player_id=player.id) }}"
class="btn btn-primary"
style="display: inline-block; width: 100%; text-align: center; padding: 15px; font-size: 16px;">
🎬 Open Playlist Manager
</a>
{% if not playlist %}
<div style="background: #fff3cd; border: 1px solid #ffc107; color: #856404; padding: 15px; border-radius: 5px; text-align: center; margin-top: 15px;">
⚠️ No content in playlist. Open the playlist manager to add content.
</div>
{% endif %}
</div>
<!-- Player Activity Log Card -->
<div class="card">
<h3 style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #dee2e6;">
📊 Recent Activity & Feedback
</h3>
{% if recent_feedback %}
<div style="max-height: 400px; overflow-y: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead style="position: sticky; top: 0; background: white;">
<tr style="background: #f8f9fa; text-align: left;">
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Time</th>
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Status</th>
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Message</th>
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Error</th>
</tr>
</thead>
<tbody>
{% for feedback in recent_feedback %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; white-space: nowrap;">
<small style="color: #6c757d;">{{ feedback.timestamp | localtime('%Y-%m-%d %H:%M:%S') }}</small>
</td>
<td style="padding: 10px;">
{% if feedback.status == 'playing' %}
<span style="background: #28a745; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">▶️ Playing</span>
{% elif feedback.status == 'idle' %}
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">⏸️ Idle</span>
{% elif feedback.status == 'error' %}
<span style="background: #dc3545; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">❌ Error</span>
{% else %}
<span style="background: #007bff; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">{{ feedback.status }}</span>
{% endif %}
</td>
<td style="padding: 10px;">
{{ feedback.message or '-' }}
</td>
<td style="padding: 10px;">
{% if feedback.error %}
<span style="color: #dc3545; font-family: monospace; font-size: 12px;">{{ feedback.error[:50] }}...</span>
{% else %}
-
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="background: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460; padding: 15px; border-radius: 5px; text-align: center;">
️ No activity logs yet. The player will send feedback once it starts playing content.
</div>
{% endif %}
</div>
</div>
{% endblock %}
+2 -14
View File
@@ -10,14 +10,7 @@ from app.utils.uploads import (
get_file_size,
delete_file
)
from app.utils.group_player_management import (
get_player_status_info,
get_group_statistics,
assign_player_to_group,
bulk_assign_players_to_group,
get_online_players_count,
get_players_by_status
)
from app.utils.group_player_management import get_player_status_info
from app.utils.pptx_converter import pptx_to_pdf_libreoffice, validate_pptx_file
__all__ = [
@@ -36,13 +29,8 @@ __all__ = [
'clear_upload_progress',
'get_file_size',
'delete_file',
# Group/Player Management
# Player Management
'get_player_status_info',
'get_group_statistics',
'assign_player_to_group',
'bulk_assign_players_to_group',
'get_online_players_count',
'get_players_by_status',
# PPTX Converter
'pptx_to_pdf_libreoffice',
'validate_pptx_file',
+93 -170
View File
@@ -37,43 +37,110 @@ class CaddyConfigGenerator:
"""Generate Caddyfile configuration based on HTTPSConfig."""
@staticmethod
def generate_caddyfile(config: Optional['HTTPSConfig'] = None) -> str:
def generate_caddyfile(config: Optional['HTTPSConfig'] = None,
http_fallback: bool = True,
http_port: int = 80,
https_port: int = 443) -> str:
"""Generate a complete Caddyfile.
Behaviour:
- HTTPS disabled / no domain HTTP-only on port 80 (initial deploy mode).
- HTTPS enabled + real domain Caddy auto-provisions a Let's Encrypt cert
for that domain; HTTP redirects to HTTPS automatically.
- HTTPS enabled + IP only (no domain) TLS with Caddy's internal CA
(self-signed, trusted within the Docker network).
Design goals
------------
* **One HTTP endpoint** (port 80) that always answers, whatever the Host
header is so ``http://<ip>`` and ``http://<hostname>`` both work.
* **HTTPS on port 443** for the same names when it is enabled.
* If HTTPS is disabled or never configured, port 80 simply serves the
app there is no separate "HTTP mode" to configure.
Behaviour by configuration
--------------------------
* HTTPS off, or no address configured plain HTTP on ``:http_port``.
* HTTPS on the app is served on port 80 for every configured name and
on port 443 over TLS. Whether port 80 *serves* or *redirects* to
HTTPS is controlled by ``http_fallback``.
Which certificate each name gets
--------------------------------
* ``domain`` (when set) Caddy obtains a certificate automatically
(Let's Encrypt/ACME). Only valid for a **publicly resolvable** name.
* ``ip_address`` / ``hostname`` ``tls internal`` (Caddy's local CA).
This needs no public DNS and no ACME, which is the right choice for an
intranet name such as ``digiserver.sibiusb.harting.intra``.
Args:
config: HTTPSConfig instance, or None to load from the database.
http_fallback: When True, port 80 keeps *serving* the app alongside
HTTPS. This is the resilient default: clients that cannot trust
the internal CA (e.g. a Kivy player with ``verify_ssl: true``)
are still able to connect. When False, port 80 issues a 301
redirect to HTTPS instead.
http_port: Port Caddy listens on for plain HTTP (default 80).
https_port: Port used to build redirect targets when
``http_fallback`` is False (default 443).
Returns:
The complete Caddyfile as a string.
"""
if config is None:
config = HTTPSConfig.get_config()
email = (config.email or "admin@localhost") if config else "admin@localhost"
https_enabled = config.https_enabled if config else False
https_enabled = bool(config.https_enabled) if config else False
domain = (config.domain or "").strip() if config else ""
ip_address = (config.ip_address or "").strip() if config else ""
hostname = (config.hostname or "").strip() if config else ""
global_block = f"""{{\n admin 0.0.0.0:2019\n email {email}\n}}\n\n"""
# Every name the server should answer to, in priority order, without
# duplicates. The IP comes first because it always resolves.
names: list[str] = []
for candidate in (ip_address, hostname, domain):
if candidate and candidate not in names:
names.append(candidate)
if https_enabled and domain:
# Caddy handles Let's Encrypt + HTTP→HTTPS redirect automatically
# when a plain hostname (no scheme) is used.
caddyfile = global_block
caddyfile += f"{domain} {{\n{_PROXY_SNIPPET}}}\n"
# Also accept requests on the raw IP (HTTP only, no cert needed)
if ip_address:
caddyfile += f"\nhttp://{ip_address} {{\n{_PROXY_SNIPPET}}}\n"
elif https_enabled and ip_address:
# No public domain — use Caddy's internal CA (self-signed)
caddyfile = global_block
caddyfile += f"https://{ip_address} {{\n tls internal\n{_PROXY_SNIPPET}}}\n"
caddyfile += f"\nhttp://{ip_address} {{\n redir https://{ip_address}{{uri}} 301\n}}\n"
else:
# HTTP-only fallback (first deploy, before HTTPS is configured)
caddyfile = "{\n admin 0.0.0.0:2019\n}\n\n"
caddyfile += f":80 {{\n{_PROXY_SNIPPET}}}\n"
global_block = f"{{\n admin 0.0.0.0:2019\n email {email}\n"
# ── TLS with no SNI ────────────────────────────────────────────────
# Browsers do NOT send SNI when the URL is an IP address (an IP is not
# a valid SNI hostname). Without a fallback Caddy would identify such a
# connection by the container's own internal IP, match no certificate
# and abort the handshake with:
# "no certificate available for '<container-ip>'"
# `default_sni` makes a SNI-less ClientHello resolve to a name we do
# serve, so https://<ip> works in the browser.
if https_enabled and not domain and ip_address:
global_block += f" default_sni {ip_address}\n"
global_block += "}\n\n"
# ── Plain HTTP only: HTTPS disabled, or no address to certify ───────
if not (https_enabled and names):
return global_block + f":{http_port} {{\n{_PROXY_SNIPPET}}}\n"
caddyfile = global_block
# ── Port 80: catch-all so ANY Host header is answered ──────────────
# Without this, a request for an unexpected name (e.g. a bare IP when
# only a hostname is configured) would hit no site block and fail.
caddyfile += f":{http_port} {{\n{_PROXY_SNIPPET}}}\n\n"
# ── Port 80: explicit per-name blocks ──────────────────────────────
for name in names:
if http_fallback:
caddyfile += f"http://{name} {{\n{_PROXY_SNIPPET}}}\n\n"
else:
# Redirect to the port the host actually publishes.
https_url = (f"https://{name}" if https_port == 443
else f"https://{name}:{https_port}")
caddyfile += f"http://{name} {{\n redir {https_url}{{uri}} 301\n}}\n\n"
# ── Port 443: TLS listeners ────────────────────────────────────────
for name in names:
if domain and name == domain:
# Public name → let Caddy obtain a real certificate.
caddyfile += f"https://{name} {{\n{_PROXY_SNIPPET}}}\n\n"
else:
# IP or intranet name → Caddy's internal CA.
caddyfile += (f"https://{name} {{\n tls internal\n"
f"{_PROXY_SNIPPET}}}\n\n")
return caddyfile
@@ -121,148 +188,4 @@ class CaddyConfigGenerator:
return response.status == 200
except Exception as e:
print(f"Caddy reload error: {str(e)}")
return False
"""Generate complete Caddyfile content.
Args:
config: HTTPSConfig instance or None
Returns:
Complete Caddyfile content as string
"""
# Get config from database if not provided
if config is None:
config = HTTPSConfig.get_config()
# Base configuration
email = "admin@localhost"
if config and config.email:
email = config.email
base_config = f"""{{
# Global options
email {email}
# Admin API for configuration management (listen on all interfaces)
admin 0.0.0.0:2019
# Uncomment for testing to avoid rate limits
# acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}}
# Shared reverse proxy configuration
(reverse_proxy_config) {{
reverse_proxy digiserver-app:5000 {{
header_up Host {{host}}
header_up X-Real-IP {{remote_host}}
header_up X-Forwarded-Proto {{scheme}}
# Timeouts for large uploads
transport http {{
read_timeout 300s
write_timeout 300s
}}
}}
# File upload size limit (2GB)
request_body {{
max_size 2GB
}}
# Security headers
header {{
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
X-XSS-Protection "1; mode=block"
}}
# Logging
log {{
output file /var/log/caddy/access.log
}}
}}
# Localhost (development/local access)
http://localhost {{
import reverse_proxy_config
}}
"""
# Add main domain/IP configuration if HTTPS is enabled
if config and config.https_enabled and config.domain and config.ip_address:
# Internal domain configuration
domain_config = f"""
# Internal domain (HTTP only - internal use)
http://{config.domain} {{
import reverse_proxy_config
}}
# Handle IP address access
http://{config.ip_address} {{
import reverse_proxy_config
}}
"""
base_config += domain_config
else:
# Default fallback configuration
base_config += """
# Internal domain (HTTP only - internal use)
http://digiserver.sibiusb.harting.intra {
import reverse_proxy_config
}
# Handle IP address access
http://10.76.152.164 {
import reverse_proxy_config
}
"""
# Add catch-all for any other HTTP requests
base_config += """
# Catch-all for any other HTTP requests
http://* {
import reverse_proxy_config
}
"""
return base_config
@staticmethod
def write_caddyfile(caddyfile_content: str, path: str = '/app/Caddyfile') -> bool:
"""Write Caddyfile to disk.
Args:
caddyfile_content: Content to write
path: Path to Caddyfile
Returns:
True if successful, False otherwise
"""
try:
with open(path, 'w') as f:
f.write(caddyfile_content)
return True
except Exception as e:
print(f"Error writing Caddyfile: {str(e)}")
return False
@staticmethod
def reload_caddy() -> bool:
"""Reload Caddy configuration without restart.
Note: Caddy monitoring is handled via file watching. After writing the Caddyfile,
Caddy should automatically reload. If it doesn't, you may need to restart the
Caddy container manually.
Returns:
True if configuration was written successfully (Caddy will auto-reload)
"""
try:
# Just verify that Caddy is reachable
import urllib.request
response = urllib.request.urlopen('http://caddy:2019/config/', timeout=2)
return response.status == 200
except Exception as e:
# Caddy might not be reachable, but Caddyfile was already written
# Caddy should reload automatically when it detects file changes
print(f"Note: Caddy reload check returned: {str(e)}")
return True # Return True anyway since Caddyfile was written
+18 -151
View File
@@ -1,23 +1,26 @@
"""Group and player management utilities."""
from typing import Dict, List, Optional
from datetime import datetime, timedelta
"""Player status utilities.
from app.extensions import db
from app.models import Player, Group, PlayerFeedback
from app.utils.logger import log_action
Note: the group-management helpers that used to live here were removed along
with the deprecated Group subsystem (the ``group`` table had no rows and the
``/api/groups`` endpoint had already been archived).
"""
from typing import Dict
from datetime import datetime
from app.models import Player, PlayerFeedback
def get_player_status_info(player_id: int) -> Dict:
"""Get comprehensive status information for a player.
Args:
player_id: Player ID to query
Returns:
Dictionary with status information
"""
player = Player.query.get(player_id)
if not player:
return {
'online': False,
@@ -25,18 +28,18 @@ def get_player_status_info(player_id: int) -> Dict:
'last_seen': None,
'latest_feedback': None
}
# Check if player is online (seen in last 5 minutes)
is_online = False
if player.last_seen:
delta = datetime.utcnow() - player.last_seen
is_online = delta.total_seconds() < 300
# Get latest feedback
latest_feedback = PlayerFeedback.query.filter_by(player_id=player_id)\
.order_by(PlayerFeedback.timestamp.desc())\
.first()
return {
'online': is_online,
'status': player.status,
@@ -51,154 +54,18 @@ def get_player_status_info(player_id: int) -> Dict:
}
def get_group_statistics(group_id: int) -> Dict:
"""Get statistics for a group.
Args:
group_id: Group ID to query
Returns:
Dictionary with group statistics
"""
group = Group.query.get(group_id)
if not group:
return {
'total_players': 0,
'online_players': 0,
'total_content': 0,
'error_count': 0
}
total_players = group.player_count
total_content = group.content_count
# Count online players
online_players = 0
error_count = 0
five_min_ago = datetime.utcnow() - timedelta(minutes=5)
for player in group.players:
if player.last_seen and player.last_seen >= five_min_ago:
online_players += 1
if player.status == 'error':
error_count += 1
return {
'group_id': group_id,
'group_name': group.name,
'total_players': total_players,
'online_players': online_players,
'offline_players': total_players - online_players,
'total_content': total_content,
'error_count': error_count
}
def assign_player_to_group(player_id: int, group_id: Optional[int]) -> bool:
"""Assign a player to a group or unassign if group_id is None.
Args:
player_id: Player ID to assign
group_id: Group ID to assign to, or None to unassign
Returns:
True if successful, False otherwise
"""
try:
player = Player.query.get(player_id)
if not player:
log_action('error', f'Player {player_id} not found')
return False
old_group_id = player.group_id
player.group_id = group_id
db.session.commit()
if group_id:
group = Group.query.get(group_id)
log_action('info', f'Player "{player.name}" assigned to group "{group.name}"')
else:
log_action('info', f'Player "{player.name}" unassigned from group')
return True
except Exception as e:
db.session.rollback()
log_action('error', f'Error assigning player to group: {str(e)}')
return False
def bulk_assign_players_to_group(player_ids: List[int], group_id: Optional[int]) -> int:
"""Assign multiple players to a group.
Args:
player_ids: List of player IDs to assign
group_id: Group ID to assign to, or None to unassign
Returns:
Number of players successfully assigned
"""
count = 0
try:
for player_id in player_ids:
player = Player.query.get(player_id)
if player:
player.group_id = group_id
count += 1
db.session.commit()
if group_id:
group = Group.query.get(group_id)
log_action('info', f'Bulk assigned {count} players to group "{group.name}"')
else:
log_action('info', f'Bulk unassigned {count} players from groups')
return count
except Exception as e:
db.session.rollback()
log_action('error', f'Error bulk assigning players: {str(e)}')
return 0
def get_online_players_count() -> int:
"""Get count of online players (seen in last 5 minutes).
Returns:
Number of online players
"""
five_min_ago = datetime.utcnow() - timedelta(minutes=5)
return Player.query.filter(Player.last_seen >= five_min_ago).count()
def get_players_by_status(status: str) -> List[Player]:
"""Get all players with a specific status.
Args:
status: Status to filter by
Returns:
List of Player instances
"""
return Player.query.filter_by(status=status).all()
def _format_time_ago(dt: datetime) -> str:
"""Format datetime as 'time ago' string.
Args:
dt: Datetime to format
Returns:
Formatted string like '5 minutes ago'
"""
delta = datetime.utcnow() - dt
seconds = delta.total_seconds()
if seconds < 60:
return f'{int(seconds)} seconds ago'
elif seconds < 3600:
-120
View File
@@ -1,120 +0,0 @@
"""Nginx configuration reader utility."""
import os
import re
from typing import Dict, List, Optional, Any
class NginxConfigReader:
"""Read and parse Nginx configuration files."""
def __init__(self, config_path: str = '/etc/nginx/nginx.conf'):
"""Initialize Nginx config reader."""
self.config_path = config_path
self.config_content = None
self.is_available = os.path.exists(config_path)
if self.is_available:
try:
with open(config_path, 'r') as f:
self.config_content = f.read()
except Exception as e:
self.is_available = False
self.error = str(e)
def get_status(self) -> Dict[str, Any]:
"""Get Nginx configuration status."""
if not self.is_available:
return {
'available': False,
'error': 'Nginx configuration not found',
'path': self.config_path
}
return {
'available': True,
'path': self.config_path,
'file_exists': os.path.exists(self.config_path),
'ssl_enabled': self._check_ssl_enabled(),
'http_ports': self._extract_http_ports(),
'https_ports': self._extract_https_ports(),
'upstream_servers': self._extract_upstream_servers(),
'server_names': self._extract_server_names(),
'ssl_protocols': self._extract_ssl_protocols(),
'client_max_body_size': self._extract_client_max_body_size(),
'gzip_enabled': self._check_gzip_enabled(),
}
def _check_ssl_enabled(self) -> bool:
"""Check if SSL is enabled."""
if not self.config_content:
return False
return 'ssl_certificate' in self.config_content
def _extract_http_ports(self) -> List[int]:
"""Extract HTTP listening ports."""
if not self.config_content:
return []
pattern = r'listen\s+(\d+)'
matches = re.findall(pattern, self.config_content)
return sorted(list(set(int(p) for p in matches if int(p) < 1000)))
def _extract_https_ports(self) -> List[int]:
"""Extract HTTPS listening ports."""
if not self.config_content:
return []
pattern = r'listen\s+(\d+).*ssl'
matches = re.findall(pattern, self.config_content)
return sorted(list(set(int(p) for p in matches)))
def _extract_upstream_servers(self) -> List[str]:
"""Extract upstream servers."""
if not self.config_content:
return []
upstream_match = re.search(r'upstream\s+\w+\s*{([^}]+)}', self.config_content)
if upstream_match:
upstream_content = upstream_match.group(1)
servers = re.findall(r'server\s+([^\s;]+)', upstream_content)
return servers
return []
def _extract_server_names(self) -> List[str]:
"""Extract server names."""
if not self.config_content:
return []
pattern = r'server_name\s+([^;]+);'
matches = re.findall(pattern, self.config_content)
result = []
for match in matches:
names = match.strip().split()
result.extend(names)
return result
def _extract_ssl_protocols(self) -> List[str]:
"""Extract SSL protocols."""
if not self.config_content:
return []
pattern = r'ssl_protocols\s+([^;]+);'
match = re.search(pattern, self.config_content)
if match:
return match.group(1).strip().split()
return []
def _extract_client_max_body_size(self) -> Optional[str]:
"""Extract client max body size."""
if not self.config_content:
return None
pattern = r'client_max_body_size\s+([^;]+);'
match = re.search(pattern, self.config_content)
return match.group(1).strip() if match else None
def _check_gzip_enabled(self) -> bool:
"""Check if gzip is enabled."""
if not self.config_content:
return False
return bool(re.search(r'gzip\s+on\s*;', self.config_content))
def get_nginx_status() -> Dict[str, Any]:
"""Get Nginx configuration status."""
reader = NginxConfigReader()
return reader.get_status()
+281 -57
View File
@@ -8,12 +8,25 @@ Admins use the "Build player files" admin page to:
The SSH deployment flow then ships this staged directory to player devices, so
the version admins build here is exactly what gets deployed.
Performance note
----------------
The player repository is large (~200 MB) and a full clone takes ~90 s. Because
the build runs inside an HTTP request, that would exceed gunicorn's worker
timeout and the worker would be killed mid-clone, leaving a broken checkout.
Two mitigations are used together:
* **Shallow clones** (``--depth 1``) only the tip of the requested branch is
fetched, which is all a deployment needs. Drastically reduces transfer size.
* **Background execution** the admin route starts the build in a daemon
thread and the page polls for progress, so no worker ever blocks on git.
"""
import os
import json
import shutil
import subprocess
import logging
import threading
from datetime import datetime
from typing import Any, Dict, Optional
@@ -24,90 +37,154 @@ logger = logging.getLogger(__name__)
# Metadata file name stored in the Flask instance folder.
BUILD_META_FILENAME = 'player_build.json'
# Only the tip of the branch is needed to deploy a player, so history is not
# fetched. Keeps the transfer small enough to avoid worker timeouts.
CLONE_DEPTH = '1'
def _run_git(args, cwd=None, timeout=300) -> subprocess.CompletedProcess:
return subprocess.run(
['git'] + args,
cwd=cwd,
capture_output=True,
text=True,
timeout=timeout,
)
# Never let git wait for a human. Without this, a private/renamed repository
# makes git block on a username prompt until the worker is killed.
GIT_ENV = {
'GIT_TERMINAL_PROMPT': '0', # never prompt for credentials
'GIT_ASKPASS': 'true', # answer any credential request immediately
'GIT_SSH_COMMAND': 'ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new',
}
def _git_env() -> Dict[str, str]:
"""Environment for git subprocesses: inherit the process env plus our flags."""
env = dict(os.environ)
env.update(GIT_ENV)
return env
def _run_git(args, cwd=None, timeout=120) -> subprocess.CompletedProcess:
"""Run a git command, never prompting for input.
Args:
args: git arguments (without the leading 'git').
cwd: working directory for the command.
timeout: hard cap in seconds. Defaults to 120 to stay within a
reasonable window even when running in the foreground.
Returns:
The completed process. ``returncode`` is 124 on timeout so callers can
distinguish a timeout from a normal failure.
"""
try:
return subprocess.run(
['git'] + args,
cwd=cwd,
capture_output=True,
text=True,
timeout=timeout,
env=_git_env(),
)
except subprocess.TimeoutExpired as e:
# Surface timeouts as a normal result so callers do not need try/except.
out = e.stdout.decode() if isinstance(e.stdout, bytes) else (e.stdout or '')
err = e.stderr.decode() if isinstance(e.stderr, bytes) else (e.stderr or '')
return subprocess.CompletedProcess(
args=['git'] + list(args), returncode=124,
stdout=out, stderr=(err + f'\ngit {" ".join(args)} timed out after {timeout}s').strip(),
)
def get_short_head(player_code_dir: str) -> str:
"""Return the short git commit of the staged code, or 'unknown'."""
try:
result = _run_git(['-C', player_code_dir, 'rev-parse', '--short', 'HEAD'], timeout=10)
if result.returncode == 0:
return result.stdout.strip()
except Exception:
pass
result = _run_git(['-C', player_code_dir, 'rev-parse', '--short', 'HEAD'], timeout=10)
if result.returncode == 0:
return result.stdout.strip()
return 'unknown'
def is_valid_checkout(path: str) -> bool:
"""True when *path* is a usable git checkout with a resolvable HEAD."""
if not os.path.isdir(os.path.join(path, '.git')):
return False
return get_short_head(path) != 'unknown'
def _clone(path: str, repo_url: str, branch: str) -> subprocess.CompletedProcess:
"""Shallow-clone a single branch into *path*."""
return _run_git([
'clone', '--depth', CLONE_DEPTH, '--single-branch',
'--branch', branch, repo_url, path,
], timeout=600)
def build_player_files(player_code_dir: str, repo_url: str, branch: str = 'main') -> Dict[str, Any]:
"""Clone or refresh the player source into ``player_code_dir``.
If the directory is already a git checkout of ``repo_url`` it is updated in
place (fetch + hard reset to the chosen branch). Otherwise it is cloned
fresh (an existing non-git directory is replaced).
Uses a **shallow single-branch clone/update** so only the tip of the wanted
branch is transferred. If the directory is a usable checkout it is updated
(fetch + hard reset to the branch). A directory that exists but is NOT a
usable checkout e.g. left behind by an interrupted clone is removed and
re-cloned, since updating it can never work.
Returns a dict: ``success`` (bool), ``message`` (str), ``version`` (str),
``branch`` (str).
Args:
player_code_dir: Destination directory for the staged player code.
repo_url: Git repository to pull from.
branch: Branch to stage.
Returns:
``{'success': bool, 'message': str, 'version': str|None, 'branch': str}``
"""
branch = (branch or 'main').strip()
repo_url = (repo_url or '').strip()
def fail(message: str) -> Dict[str, Any]:
return {'success': False, 'message': message,
'version': get_short_head(player_code_dir), 'branch': branch}
if not repo_url:
return {'success': False, 'message': 'Repository URL is required.', 'version': None, 'branch': branch}
return {'success': False, 'message': 'Repository URL is required.',
'version': None, 'branch': branch}
try:
git_dir = os.path.join(player_code_dir, '.git')
is_git_repo = os.path.isdir(git_dir)
usable = is_valid_checkout(player_code_dir)
if is_git_repo:
# Update existing checkout in place.
fetch = _run_git(['-C', player_code_dir, 'fetch', '--prune', 'origin'])
if usable:
# Update in place. Depth 1 keeps the update cheap; fetch by ref so
# it works on a shallow clone.
fetch = _run_git(
['-C', player_code_dir, 'fetch', '--depth', CLONE_DEPTH,
'--prune', 'origin', branch])
if fetch.returncode != 0:
return {
'success': False,
'message': f'git fetch failed: {fetch.stderr.strip() or fetch.stdout.strip()}',
'version': get_short_head(player_code_dir),
'branch': branch,
}
return fail(f'git fetch failed: {fetch.stderr.strip() or fetch.stdout.strip()}')
# Point origin at the requested URL in case it changed.
_run_git(['-C', player_code_dir, 'remote', 'set-url', 'origin', repo_url])
checkout = _run_git(['-C', player_code_dir, 'checkout', branch])
if checkout.returncode != 0:
return {
'success': False,
'message': f'git checkout {branch} failed: {checkout.stderr.strip()}',
'version': get_short_head(player_code_dir),
'branch': branch,
}
return fail(f'git checkout {branch} failed: {checkout.stderr.strip()}')
reset = _run_git(['-C', player_code_dir, 'reset', '--hard', f'origin/{branch}'])
if reset.returncode != 0:
return {
'success': False,
'message': f'git reset failed: {reset.stderr.strip()}',
'version': get_short_head(player_code_dir),
'branch': branch,
}
return fail(f'git reset failed: {reset.stderr.strip()}')
action = 'Updated'
else:
# Fresh clone. Replace any existing (non-git) directory.
# Fresh clone. A previous attempt may have left a partial directory
# (e.g. killed mid-clone) — it must go, or the clone will fail with
# "destination path already exists and is not an empty directory".
parent = os.path.dirname(player_code_dir.rstrip('/'))
os.makedirs(parent, exist_ok=True)
if parent:
os.makedirs(parent, exist_ok=True)
if os.path.exists(player_code_dir):
shutil.rmtree(player_code_dir)
clone = _run_git(['clone', '--branch', branch, repo_url, player_code_dir])
logger.info('Removing unusable directory before clone: %s', player_code_dir)
shutil.rmtree(player_code_dir, ignore_errors=True)
clone = _clone(player_code_dir, repo_url, branch)
if clone.returncode != 0:
return {
'success': False,
'message': f'git clone failed: {clone.stderr.strip() or clone.stdout.strip()}',
'version': None,
'branch': branch,
}
# Do not leave a half-written directory behind.
shutil.rmtree(player_code_dir, ignore_errors=True)
detail = clone.stderr.strip() or clone.stdout.strip()
if clone.returncode == 124 or 'timed out' in detail:
return fail(f'git clone timed out. The repository may be very '
f'large or unreachable: {detail}')
return fail(f'git clone failed: {detail}')
action = 'Cloned'
version = get_short_head(player_code_dir)
@@ -118,11 +195,15 @@ def build_player_files(player_code_dir: str, repo_url: str, branch: str = 'main'
'version': version,
'branch': branch,
}
except subprocess.TimeoutExpired:
return {'success': False, 'message': 'Git operation timed out.', 'version': None, 'branch': branch}
except Exception as e:
except Exception as e: # noqa: BLE001 - surface any failure
logger.exception('build_player_files failed')
return {'success': False, 'message': f'Build failed: {str(e)}', 'version': None, 'branch': branch}
# Never leave a broken checkout behind for the next attempt.
try:
if not is_valid_checkout(player_code_dir):
shutil.rmtree(player_code_dir, ignore_errors=True)
except Exception:
pass
return fail(f'Build failed: {str(e)}')
def write_base_config(
@@ -221,3 +302,146 @@ def make_build_record(repo_url, branch, server_ip, port, use_https, verify_ssl,
'built_at': datetime.utcnow().isoformat(timespec='seconds') + 'Z',
'built_by': built_by,
}
# ---------------------------------------------------------------------------
# Background builds
#
# A full clone/refresh takes far longer than gunicorn's worker timeout, so the
# build must not run inside the request. The admin route starts it here and the
# page polls `build_state()` for progress.
# ---------------------------------------------------------------------------
# Serialises writes to _build_state between the request thread and the worker.
_build_lock = threading.Lock()
# Coarse progress for the admin UI. 'state' is one of:
# idle | running | success | error
_build_state: Dict[str, Any] = {'state': 'idle'}
def get_build_state() -> Dict[str, Any]:
"""Return a snapshot of the current/last build for the admin UI."""
with _build_lock:
return dict(_build_state)
def is_build_running() -> bool:
"""True while a build is in progress."""
with _build_lock:
return _build_state.get('state') == 'running'
def _set_build_state(**fields: Any) -> None:
with _build_lock:
_build_state.update(fields)
def _run_build_job(app, player_code_dir: str, repo_url: str, branch: str,
config_payload: Optional[Dict[str, Any]],
meta_path: str, built_by: str) -> None:
"""Worker body: build files, optionally write config, then persist settings.
Runs in a daemon thread with its own Flask app context so it is independent
of the request/response cycle that triggered it.
"""
started = datetime.utcnow()
try:
_set_build_state(state='running', step='Fetching player source…',
started_at=started.isoformat(timespec='seconds') + 'Z',
message='', version=None)
result = build_player_files(player_code_dir, repo_url, branch)
version = result.get('version')
if not result['success']:
_set_build_state(state='error', step='', message=result['message'],
version=version,
finished_at=datetime.utcnow().isoformat(timespec='seconds') + 'Z')
logger.error('Background player build failed: %s', result['message'])
return
# Optional step 2: write the base config.
if config_payload:
_set_build_state(step='Writing player config…')
cfg = write_base_config(player_code_dir=player_code_dir, **config_payload)
if not cfg['success']:
_set_build_state(state='error', step='', message=cfg['message'],
version=version,
finished_at=datetime.utcnow().isoformat(timespec='seconds') + 'Z')
logger.error('Background player config write failed: %s', cfg['message'])
return
result = {**result, 'message': f"{result['message']} {cfg['message']}"}
if version is None:
version = get_short_head(player_code_dir)
save_build_settings(
meta_path,
make_build_record(
repo_url=repo_url, branch=branch,
server_ip=(config_payload or {}).get('server_ip', ''),
port=(config_payload or {}).get('port', ''),
use_https=(config_payload or {}).get('use_https', False),
verify_ssl=(config_payload or {}).get('verify_ssl', False),
orientation=(config_payload or {}).get('orientation', 'Landscape'),
max_resolution=(config_payload or {}).get('max_resolution', '1920x1080'),
version=version, built_by=built_by,
),
)
_set_build_state(state='success', step='', message=result['message'],
version=version,
finished_at=datetime.utcnow().isoformat(timespec='seconds') + 'Z')
logger.info('Background player build complete (version %s)', version)
except Exception as e: # noqa: BLE001 - never kill the thread silently
logger.exception('Background player build crashed')
_set_build_state(state='error', step='', message=f'Build failed: {e}',
finished_at=datetime.utcnow().isoformat(timespec='seconds') + 'Z')
def start_background_build(player_code_dir: str, repo_url: str, branch: str,
config_payload: Optional[Dict[str, Any]],
meta_path: str, built_by: str) -> bool:
"""Start a player build in a daemon thread.
Args:
player_code_dir: Where to stage the player source.
repo_url: Git repository URL.
branch: Branch to stage.
config_payload: Keyword args for :func:`write_base_config`, or None to
skip writing the config.
meta_path: Where to persist the build record.
built_by: Username shown in the UI/logs.
Returns:
False if a build is already running (callers should tell the user),
True if a new build was started.
Raises:
RuntimeError: if called with no Flask application context the worker
thread needs a real app object to push its own context.
"""
from flask import current_app
if is_build_running():
return False
# Capture the real app object now. `current_app` resolves inside either a
# request or a plain application context; the worker thread pushes its own
# context later, since the caller's context is gone by then.
app = current_app._get_current_object()
_set_build_state(state='running', step='Starting…', message='', version=None,
started_at=datetime.utcnow().isoformat(timespec='seconds') + 'Z',
finished_at=None, built_by=built_by,
repo_url=repo_url, branch=branch)
thread = threading.Thread(
target=_run_build_job,
args=(app, player_code_dir, repo_url, branch, config_payload, meta_path, built_by),
name='player-build',
daemon=True,
)
thread.start()
return True