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:
@@ -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
@@ -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
@@ -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({
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
@@ -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'
|
||||
|
||||
@@ -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 & 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 %}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user