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:
@@ -47,6 +47,11 @@ Thumbs.db
|
|||||||
# Runtime data volumes (mounted at runtime, NOT part of the image)
|
# Runtime data volumes (mounted at runtime, NOT part of the image)
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
# Archived snapshot of the pre-sanitization codebase (not part of the image).
|
||||||
|
# Matched at any depth so it stays excluded regardless of where it is moved.
|
||||||
|
legacy code/
|
||||||
|
**/legacy code/
|
||||||
|
|
||||||
# Documentation
|
# Documentation
|
||||||
BLUEPRINT_GUIDE.md
|
BLUEPRINT_GUIDE.md
|
||||||
ICON_INTEGRATION.md
|
ICON_INTEGRATION.md
|
||||||
|
|||||||
+41
-14
@@ -2,6 +2,47 @@
|
|||||||
# Copy to .env and update with your production values
|
# Copy to .env and update with your production values
|
||||||
# IMPORTANT: Never commit this file to git
|
# IMPORTANT: Never commit this file to git
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Deploy-time TLS bootstrap. Copy this file to `.env` and set these two before
|
||||||
|
# `docker compose up`. Both must be present for HTTPS to be configured at
|
||||||
|
# startup; if either is missing the app stays on the plain-HTTP fallback and you
|
||||||
|
# can enable HTTPS later from Admin → HTTPS Configuration (no restart needed).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Hostname shown in the UI and used in the Caddy site block.
|
||||||
|
HOSTNAME_INTERNAL=digiserver
|
||||||
|
|
||||||
|
# The host's LAN IP as reachable by the players/browsers.
|
||||||
|
# Replace 192.168.1.100 with THIS server's actual LAN IP. It is used for the
|
||||||
|
# Caddy site blocks and the certificate, so a wrong value breaks HTTPS.
|
||||||
|
# Find it with: ip -4 route get 1.1.1.1 | grep -oP 'src \K[\d.]+'
|
||||||
|
HOST_IP=192.168.1.100
|
||||||
|
|
||||||
|
# Public domain for Let's Encrypt. LEAVE EMPTY for an intranet/internal name
|
||||||
|
# (e.g. "digiserver" or "signage.corp.local") — a non-public name cannot pass an
|
||||||
|
# ACME challenge, so an empty DOMAIN selects Caddy's internal CA instead.
|
||||||
|
DOMAIN=
|
||||||
|
|
||||||
|
# Email for ACME/Let's Encrypt notifications (unused by the internal CA).
|
||||||
|
SSL_EMAIL=admin@example.com
|
||||||
|
|
||||||
|
# Published ports. Caddy listens on 80/443 inside the container; these control
|
||||||
|
# which host ports they are mapped to. Port 80 is always answered — the site
|
||||||
|
# responds whether clients use the IP or the hostname.
|
||||||
|
HTTP_PORT=80
|
||||||
|
HTTPS_PORT=443
|
||||||
|
|
||||||
|
# "true" → also serve plain HTTP alongside HTTPS. Required for players whose
|
||||||
|
# trust store lacks the internal CA (i.e. verify_ssl is not disabled).
|
||||||
|
# "false" → serve TLS only and redirect HTTP to https://<host>:<HTTPS_PORT>.
|
||||||
|
HTTPS_HTTP_FALLBACK=true
|
||||||
|
|
||||||
|
# After configuring HTTPS, probe it and automatically fall back to plain HTTP if
|
||||||
|
# it does not come up — so a bad certificate can never make the site unreachable.
|
||||||
|
# Set "false" to trust the configuration without probing.
|
||||||
|
HTTPS_VERIFY=true
|
||||||
|
|
||||||
# Flask Configuration
|
# Flask Configuration
|
||||||
FLASK_ENV=production
|
FLASK_ENV=production
|
||||||
FLASK_APP=app.app:create_app
|
FLASK_APP=app.app:create_app
|
||||||
@@ -20,25 +61,11 @@ ADMIN_EMAIL=admin@your-domain.com
|
|||||||
# For SQLite: sqlite:////data/instance/dashboard.db
|
# For SQLite: sqlite:////data/instance/dashboard.db
|
||||||
# DATABASE_URL=
|
# DATABASE_URL=
|
||||||
|
|
||||||
# Server Configuration
|
|
||||||
# Set BEFORE deployment if host will have static IP after restart
|
|
||||||
# This IP/domain will be used for SSL certificates and nginx configuration
|
|
||||||
DOMAIN=your-domain.com
|
|
||||||
HOST_IP=192.168.0.121
|
|
||||||
EMAIL=admin@your-domain.com
|
|
||||||
PREFERRED_URL_SCHEME=https
|
PREFERRED_URL_SCHEME=https
|
||||||
|
|
||||||
# SSL/HTTPS (configured in nginx.conf by default)
|
|
||||||
SSL_CERT_PATH=/etc/nginx/ssl/cert.pem
|
|
||||||
SSL_KEY_PATH=/etc/nginx/ssl/key.pem
|
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
# Security Headers (configured in nginx.conf)
|
|
||||||
HSTS_MAX_AGE=31536000
|
|
||||||
HSTS_INCLUDE_SUBDOMAINS=true
|
|
||||||
|
|
||||||
# Features (optional)
|
# Features (optional)
|
||||||
ENABLE_LIBREOFFICE=true
|
ENABLE_LIBREOFFICE=true
|
||||||
MAX_UPLOAD_SIZE=500000000 # 500MB
|
MAX_UPLOAD_SIZE=500000000 # 500MB
|
||||||
|
|||||||
+24
@@ -26,6 +26,23 @@ data/
|
|||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Ad-hoc backups of .env (they contain real secrets — must never be committed)
|
||||||
|
.env.bak
|
||||||
|
.env.bak.*
|
||||||
|
.env.*.bak
|
||||||
|
*.env.bak
|
||||||
|
|
||||||
|
# Deployment artefacts that contain secrets
|
||||||
|
.deployment-credentials
|
||||||
|
caddy-root.crt
|
||||||
|
|
||||||
|
# Certificates / keys (generated by Caddy or the host)
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
!data/caddy-data/**
|
||||||
|
!Caddyfile.example
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
*.db
|
*.db
|
||||||
@@ -59,3 +76,10 @@ build/
|
|||||||
#data
|
#data
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
# Local archive / restore-point snapshots.
|
||||||
|
# Kept on disk for reference (see docs/SANITIZATION-REVIEW.md) but deliberately
|
||||||
|
# NOT tracked: docs/legacy code/ is a full pre-sanitization repo snapshot and
|
||||||
|
# docs/old_code_documentation/ is a byte-identical copy of the tree inside it.
|
||||||
|
docs/legacy code/
|
||||||
|
docs/old_code_documentation/
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
# Caddy admin API — used by DigiServer to reload config after HTTPS is enabled
|
||||||
|
admin 0.0.0.0:2019
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default: serve the app on HTTP port 80
|
||||||
|
# Once HTTPS is configured via Admin → HTTPS Config, Caddy will reload
|
||||||
|
# this file and start provisioning a Let's Encrypt certificate automatically.
|
||||||
|
:80 {
|
||||||
|
reverse_proxy digiserver-app:5000 {
|
||||||
|
header_up Host {host}
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
header_up X-Forwarded-Proto {scheme}
|
||||||
|
transport http {
|
||||||
|
read_timeout 300s
|
||||||
|
write_timeout 300s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request_body {
|
||||||
|
max_size 2GB
|
||||||
|
}
|
||||||
|
|
||||||
|
encode gzip
|
||||||
|
|
||||||
|
header {
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-XSS-Protection "1; mode=block"
|
||||||
|
}
|
||||||
|
|
||||||
|
log {
|
||||||
|
output file /var/log/caddy/access.log
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,7 +94,6 @@ def register_blueprints(app):
|
|||||||
from app.blueprints.admin import admin_bp
|
from app.blueprints.admin import admin_bp
|
||||||
from app.blueprints.players import players_bp
|
from app.blueprints.players import players_bp
|
||||||
from app.blueprints.content import content_bp
|
from app.blueprints.content import content_bp
|
||||||
from app.blueprints.playlist import playlist_bp
|
|
||||||
from app.blueprints.api import api_bp
|
from app.blueprints.api import api_bp
|
||||||
|
|
||||||
# Register blueprints (using URL prefixes from blueprint definitions)
|
# Register blueprints (using URL prefixes from blueprint definitions)
|
||||||
@@ -103,7 +102,6 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(admin_bp)
|
app.register_blueprint(admin_bp)
|
||||||
app.register_blueprint(players_bp)
|
app.register_blueprint(players_bp)
|
||||||
app.register_blueprint(content_bp)
|
app.register_blueprint(content_bp)
|
||||||
app.register_blueprint(playlist_bp)
|
|
||||||
app.register_blueprint(api_bp)
|
app.register_blueprint(api_bp)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+69
-47
@@ -1072,11 +1072,14 @@ def build_player():
|
|||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def build_player_action():
|
def build_player_action():
|
||||||
"""Build/refresh the staged player code and/or write its base config."""
|
"""Start building/refreshing the staged player code.
|
||||||
from app.utils.player_build import (
|
|
||||||
build_player_files, write_base_config, get_short_head,
|
The build runs in a background thread because a full clone of the player
|
||||||
save_build_settings, make_build_record,
|
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']
|
player_code_dir = current_app.config['PLAYER_CODE_DIR']
|
||||||
action = request.form.get('action', 'build_and_config')
|
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'
|
orientation = request.form.get('orientation', 'Landscape').strip() or 'Landscape'
|
||||||
max_resolution = request.form.get('max_resolution', '1920x1080').strip() or '1920x1080'
|
max_resolution = request.form.get('max_resolution', '1920x1080').strip() or '1920x1080'
|
||||||
|
|
||||||
# Validation
|
# Validation (unchanged — fail fast before starting any work)
|
||||||
errors = []
|
errors = []
|
||||||
if action in ('build_files', 'build_and_config') and not repo_url:
|
if action in ('build_files', 'build_and_config') and not repo_url:
|
||||||
errors.append('Repository URL is required to build player files.')
|
errors.append('Repository URL is required to build player files.')
|
||||||
if action in ('save_config', 'build_and_config') and not server_ip:
|
if action in ('save_config', 'build_and_config') and not server_ip:
|
||||||
errors.append('Server IP / domain is required for the player configuration.')
|
errors.append('Server IP / domain is required for the player configuration.')
|
||||||
try:
|
try:
|
||||||
port_num = int(port)
|
int(port)
|
||||||
if port_num < 1 or port_num > 65535:
|
|
||||||
errors.append('Port must be between 1 and 65535.')
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
errors.append('Port must be a valid number.')
|
errors.append('Port must be a valid number.')
|
||||||
|
|
||||||
@@ -1108,51 +1109,72 @@ def build_player_action():
|
|||||||
flash(err, 'warning')
|
flash(err, 'warning')
|
||||||
return redirect(url_for('admin.build_player'))
|
return redirect(url_for('admin.build_player'))
|
||||||
|
|
||||||
messages = []
|
# 'save_config' only writes the config file — it touches no network and is
|
||||||
success = True
|
# fast, so it stays synchronous.
|
||||||
version = None
|
if action == 'save_config':
|
||||||
|
from app.utils.player_build import (
|
||||||
# Step 1: build/refresh files from the repository.
|
write_base_config, get_short_head, save_build_settings, make_build_record,
|
||||||
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'):
|
|
||||||
cfg_result = write_base_config(
|
cfg_result = write_base_config(
|
||||||
player_code_dir=player_code_dir,
|
player_code_dir=player_code_dir,
|
||||||
server_ip=server_ip,
|
server_ip=server_ip, port=port, use_https=use_https,
|
||||||
port=port,
|
verify_ssl=verify_ssl, orientation=orientation,
|
||||||
use_https=use_https,
|
|
||||||
verify_ssl=verify_ssl,
|
|
||||||
orientation=orientation,
|
|
||||||
max_resolution=max_resolution,
|
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)
|
version = get_short_head(player_code_dir)
|
||||||
save_build_settings(
|
save_build_settings(
|
||||||
_player_build_meta_path(),
|
_player_build_meta_path(),
|
||||||
make_build_record(
|
make_build_record(
|
||||||
repo_url=repo_url, branch=branch, server_ip=server_ip, port=port,
|
repo_url=repo_url, branch=branch, server_ip=server_ip, port=port,
|
||||||
use_https=use_https, verify_ssl=verify_ssl, orientation=orientation,
|
use_https=use_https, verify_ssl=verify_ssl, orientation=orientation,
|
||||||
max_resolution=max_resolution, version=version, built_by=current_user.username,
|
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 not started:
|
||||||
if success:
|
flash('⚠️ A build is already running — wait for it to finish.', 'warning')
|
||||||
log_action('info', f'Player files built by {current_user.username} (version {version})')
|
|
||||||
flash(f'✅ {summary}', 'success')
|
|
||||||
else:
|
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'))
|
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 typing import Optional, Dict, List
|
||||||
|
|
||||||
from app.extensions import db, cache
|
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
|
from app.utils.logger import log_action
|
||||||
|
|
||||||
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
||||||
@@ -86,6 +88,25 @@ def verify_player_auth(f):
|
|||||||
return decorated_function
|
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'])
|
@api_bp.route('/health', methods=['GET'])
|
||||||
def health_check():
|
def health_check():
|
||||||
"""API health check endpoint."""
|
"""API health check endpoint."""
|
||||||
@@ -113,7 +134,7 @@ def authenticate_player():
|
|||||||
quickconnect_code: Quick connect code (optional if using password)
|
quickconnect_code: Quick connect code (optional if using password)
|
||||||
|
|
||||||
Returns:
|
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()
|
data = request.get_json()
|
||||||
|
|
||||||
@@ -265,12 +286,8 @@ def get_playlist_by_quickconnect():
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
# Get playlist version from the assigned playlist
|
# Get playlist version from the assigned playlist
|
||||||
playlist_version = 1
|
assigned_playlist = get_assigned_playlist(player)
|
||||||
if player.playlist_id:
|
playlist_version = assigned_playlist.version if assigned_playlist else 0
|
||||||
from app.models import Playlist
|
|
||||||
assigned_playlist = Playlist.query.get(player.playlist_id)
|
|
||||||
if assigned_playlist:
|
|
||||||
playlist_version = assigned_playlist.version
|
|
||||||
|
|
||||||
# Hash the quickconnect code for validation on client side
|
# Hash the quickconnect code for validation on client side
|
||||||
hashed_quickconnect = bcrypt.hashpw(
|
hashed_quickconnect = bcrypt.hashpw(
|
||||||
@@ -322,12 +339,8 @@ def get_player_playlist(player_id: int):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
# Get playlist version from the assigned playlist
|
# Get playlist version from the assigned playlist
|
||||||
playlist_version = 1
|
assigned_playlist = get_assigned_playlist(player)
|
||||||
if player.playlist_id:
|
playlist_version = assigned_playlist.version if assigned_playlist else 0
|
||||||
from app.models import Playlist
|
|
||||||
assigned_playlist = Playlist.query.get(player.playlist_id)
|
|
||||||
if assigned_playlist:
|
|
||||||
playlist_version = assigned_playlist.version
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'player_id': player_id,
|
'player_id': player_id,
|
||||||
@@ -363,10 +376,16 @@ def get_playlist_version(player_id: int):
|
|||||||
player.last_seen = datetime.utcnow()
|
player.last_seen = datetime.utcnow()
|
||||||
db.session.commit()
|
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({
|
return jsonify({
|
||||||
'player_id': player_id,
|
'player_id': player_id,
|
||||||
'playlist_version': player.playlist_version,
|
'playlist_id': player.playlist_id,
|
||||||
'content_count': Content.query.filter_by(player_id=player_id).count()
|
'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:
|
except Exception as e:
|
||||||
@@ -378,7 +397,6 @@ def get_playlist_version(player_id: int):
|
|||||||
def get_cached_playlist(player_id: int) -> List[Dict]:
|
def get_cached_playlist(player_id: int) -> List[Dict]:
|
||||||
"""Get cached playlist for a player based on assigned playlist."""
|
"""Get cached playlist for a player based on assigned playlist."""
|
||||||
from flask import url_for
|
from flask import url_for
|
||||||
from app.models import Playlist
|
|
||||||
|
|
||||||
player = Player.query.get(player_id)
|
player = Player.query.get(player_id)
|
||||||
if not player or not player.playlist_id:
|
if not player or not player.playlist_id:
|
||||||
@@ -556,7 +574,6 @@ def get_player_status(player_id: int):
|
|||||||
'player_id': player_id,
|
'player_id': player_id,
|
||||||
'name': player.name,
|
'name': player.name,
|
||||||
'location': player.location,
|
'location': player.location,
|
||||||
'group_id': player.group_id,
|
|
||||||
'status': player.status,
|
'status': player.status,
|
||||||
'is_online': is_online,
|
'is_online': is_online,
|
||||||
'last_seen': player.last_seen.isoformat() if player.last_seen else None,
|
'last_seen': player.last_seen.isoformat() if player.last_seen else None,
|
||||||
@@ -593,7 +610,6 @@ def system_info():
|
|||||||
try:
|
try:
|
||||||
# Get counts
|
# Get counts
|
||||||
total_players = Player.query.count()
|
total_players = Player.query.count()
|
||||||
total_groups = Group.query.count()
|
|
||||||
total_content = Content.query.count()
|
total_content = Content.query.count()
|
||||||
|
|
||||||
# Count online players (seen in last 5 minutes)
|
# Count online players (seen in last 5 minutes)
|
||||||
@@ -610,7 +626,6 @@ def system_info():
|
|||||||
'total': total_players,
|
'total': total_players,
|
||||||
'online': online_players
|
'online': online_players
|
||||||
},
|
},
|
||||||
'groups': total_groups,
|
|
||||||
'content': total_content,
|
'content': total_content,
|
||||||
'logs_24h': recent_logs,
|
'logs_24h': recent_logs,
|
||||||
'timestamp': datetime.utcnow().isoformat()
|
'timestamp': datetime.utcnow().isoformat()
|
||||||
@@ -621,35 +636,6 @@ def system_info():
|
|||||||
return jsonify({'error': 'Internal server error'}), 500
|
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'])
|
@api_bp.route('/content', methods=['GET'])
|
||||||
@rate_limit(max_requests=60, window=60)
|
@rate_limit(max_requests=60, window=60)
|
||||||
def list_content():
|
def list_content():
|
||||||
@@ -665,8 +651,7 @@ def list_content():
|
|||||||
'type': content.content_type,
|
'type': content.content_type,
|
||||||
'duration': content.duration,
|
'duration': content.duration,
|
||||||
'size': content.file_size,
|
'size': content.file_size,
|
||||||
'uploaded_at': content.uploaded_at.isoformat(),
|
'uploaded_at': content.uploaded_at.isoformat()
|
||||||
'group_count': content.groups.count()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return jsonify({
|
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
|
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'])
|
@players_bp.route('/bulk/delete', methods=['POST'])
|
||||||
@@ -686,97 +678,3 @@ def deployment_status():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
log_action('error', f'Error fetching deployment status: {str(e)}')
|
log_action('error', f'Error fetching deployment status: {str(e)}')
|
||||||
return jsonify({'error': str(e)}), 500
|
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."""
|
"""Models package for digiserver-v2."""
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.player import Player
|
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.playlist import Playlist, playlist_content
|
||||||
from app.models.content import Content
|
from app.models.content import Content
|
||||||
from app.models.server_log import ServerLog
|
from app.models.server_log import ServerLog
|
||||||
@@ -13,7 +12,6 @@ from app.models.https_config import HTTPSConfig
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
'User',
|
'User',
|
||||||
'Player',
|
'Player',
|
||||||
'Group',
|
|
||||||
'Playlist',
|
'Playlist',
|
||||||
'Content',
|
'Content',
|
||||||
'ServerLog',
|
'ServerLog',
|
||||||
@@ -21,6 +19,5 @@ __all__ = [
|
|||||||
'PlayerEdit',
|
'PlayerEdit',
|
||||||
'PlayerUser',
|
'PlayerUser',
|
||||||
'HTTPSConfig',
|
'HTTPSConfig',
|
||||||
'group_content',
|
|
||||||
'playlist_content',
|
'playlist_content',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -38,8 +38,6 @@ class Content(db.Model):
|
|||||||
# Relationships - many-to-many with playlists
|
# Relationships - many-to-many with playlists
|
||||||
playlists = db.relationship('Playlist', secondary='playlist_content',
|
playlists = db.relationship('Playlist', secondary='playlist_content',
|
||||||
back_populates='contents', lazy='dynamic')
|
back_populates='contents', lazy='dynamic')
|
||||||
groups = db.relationship('Group', secondary='group_content',
|
|
||||||
back_populates='contents', lazy='dynamic')
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
"""String representation of Content."""
|
"""String representation of Content."""
|
||||||
@@ -52,11 +50,6 @@ class Content(db.Model):
|
|||||||
return round(self.file_size / (1024 * 1024), 2)
|
return round(self.file_size / (1024 * 1024), 2)
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
@property
|
|
||||||
def group_count(self) -> int:
|
|
||||||
"""Get number of groups containing this content."""
|
|
||||||
return self.groups.count()
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def original_display_name(self) -> str:
|
def original_display_name(self) -> str:
|
||||||
"""Name of the original (unedited) file for display purposes."""
|
"""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)
|
orientation: Display orientation (Landscape/Portrait)
|
||||||
status: Current player status (online, offline, error)
|
status: Current player status (online, offline, error)
|
||||||
last_seen: Last activity timestamp
|
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
|
created_at: Player creation timestamp
|
||||||
"""
|
"""
|
||||||
__tablename__ = 'player'
|
__tablename__ = 'player'
|
||||||
|
|||||||
@@ -42,6 +42,24 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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') }}">
|
<form method="POST" action="{{ url_for('admin.build_player_action') }}">
|
||||||
<!-- Repository -->
|
<!-- Repository -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -114,10 +132,12 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>3. Build</h2>
|
<h2>3. Build</h2>
|
||||||
<div class="card-actions" style="display: flex; gap: 10px; flex-wrap: wrap;">
|
<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
|
⬇️ Build files & write config
|
||||||
</button>
|
</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
|
Build files only
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" name="action" value="save_config" class="btn btn-secondary">
|
<button type="submit" name="action" value="save_config" class="btn btn-secondary">
|
||||||
@@ -127,4 +147,64 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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 %}
|
{% 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,
|
get_file_size,
|
||||||
delete_file
|
delete_file
|
||||||
)
|
)
|
||||||
from app.utils.group_player_management import (
|
from app.utils.group_player_management import get_player_status_info
|
||||||
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.pptx_converter import pptx_to_pdf_libreoffice, validate_pptx_file
|
from app.utils.pptx_converter import pptx_to_pdf_libreoffice, validate_pptx_file
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -36,13 +29,8 @@ __all__ = [
|
|||||||
'clear_upload_progress',
|
'clear_upload_progress',
|
||||||
'get_file_size',
|
'get_file_size',
|
||||||
'delete_file',
|
'delete_file',
|
||||||
# Group/Player Management
|
# Player Management
|
||||||
'get_player_status_info',
|
'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 Converter
|
||||||
'pptx_to_pdf_libreoffice',
|
'pptx_to_pdf_libreoffice',
|
||||||
'validate_pptx_file',
|
'validate_pptx_file',
|
||||||
|
|||||||
+93
-170
@@ -37,43 +37,110 @@ class CaddyConfigGenerator:
|
|||||||
"""Generate Caddyfile configuration based on HTTPSConfig."""
|
"""Generate Caddyfile configuration based on HTTPSConfig."""
|
||||||
|
|
||||||
@staticmethod
|
@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.
|
"""Generate a complete Caddyfile.
|
||||||
|
|
||||||
Behaviour:
|
Design goals
|
||||||
- HTTPS disabled / no domain → HTTP-only on port 80 (initial deploy mode).
|
------------
|
||||||
- HTTPS enabled + real domain → Caddy auto-provisions a Let's Encrypt cert
|
* **One HTTP endpoint** (port 80) that always answers, whatever the Host
|
||||||
for that domain; HTTP redirects to HTTPS automatically.
|
header is — so ``http://<ip>`` and ``http://<hostname>`` both work.
|
||||||
- HTTPS enabled + IP only (no domain) → TLS with Caddy's internal CA
|
* **HTTPS on port 443** for the same names when it is enabled.
|
||||||
(self-signed, trusted within the Docker network).
|
* 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:
|
if config is None:
|
||||||
config = HTTPSConfig.get_config()
|
config = HTTPSConfig.get_config()
|
||||||
|
|
||||||
email = (config.email or "admin@localhost") if config else "admin@localhost"
|
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 ""
|
domain = (config.domain or "").strip() if config else ""
|
||||||
ip_address = (config.ip_address 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:
|
global_block = f"{{\n admin 0.0.0.0:2019\n email {email}\n"
|
||||||
# Caddy handles Let's Encrypt + HTTP→HTTPS redirect automatically
|
|
||||||
# when a plain hostname (no scheme) is used.
|
# ── TLS with no SNI ────────────────────────────────────────────────
|
||||||
caddyfile = global_block
|
# Browsers do NOT send SNI when the URL is an IP address (an IP is not
|
||||||
caddyfile += f"{domain} {{\n{_PROXY_SNIPPET}}}\n"
|
# a valid SNI hostname). Without a fallback Caddy would identify such a
|
||||||
# Also accept requests on the raw IP (HTTP only, no cert needed)
|
# connection by the container's own internal IP, match no certificate
|
||||||
if ip_address:
|
# and abort the handshake with:
|
||||||
caddyfile += f"\nhttp://{ip_address} {{\n{_PROXY_SNIPPET}}}\n"
|
# "no certificate available for '<container-ip>'"
|
||||||
elif https_enabled and ip_address:
|
# `default_sni` makes a SNI-less ClientHello resolve to a name we do
|
||||||
# No public domain — use Caddy's internal CA (self-signed)
|
# serve, so https://<ip> works in the browser.
|
||||||
caddyfile = global_block
|
if https_enabled and not domain and ip_address:
|
||||||
caddyfile += f"https://{ip_address} {{\n tls internal\n{_PROXY_SNIPPET}}}\n"
|
global_block += f" default_sni {ip_address}\n"
|
||||||
caddyfile += f"\nhttp://{ip_address} {{\n redir https://{ip_address}{{uri}} 301\n}}\n"
|
|
||||||
else:
|
global_block += "}\n\n"
|
||||||
# HTTP-only fallback (first deploy, before HTTPS is configured)
|
|
||||||
caddyfile = "{\n admin 0.0.0.0:2019\n}\n\n"
|
# ── Plain HTTP only: HTTPS disabled, or no address to certify ───────
|
||||||
caddyfile += f":80 {{\n{_PROXY_SNIPPET}}}\n"
|
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
|
return caddyfile
|
||||||
|
|
||||||
@@ -121,148 +188,4 @@ class CaddyConfigGenerator:
|
|||||||
return response.status == 200
|
return response.status == 200
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Caddy reload error: {str(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,10 +1,13 @@
|
|||||||
"""Group and player management utilities."""
|
"""Player status utilities.
|
||||||
from typing import Dict, List, Optional
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
from app.extensions import db
|
Note: the group-management helpers that used to live here were removed along
|
||||||
from app.models import Player, Group, PlayerFeedback
|
with the deprecated Group subsystem (the ``group`` table had no rows and the
|
||||||
from app.utils.logger import log_action
|
``/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:
|
def get_player_status_info(player_id: int) -> Dict:
|
||||||
@@ -51,142 +54,6 @@ 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:
|
def _format_time_ago(dt: datetime) -> str:
|
||||||
"""Format datetime as 'time ago' string.
|
"""Format datetime as 'time ago' string.
|
||||||
|
|
||||||
|
|||||||
@@ -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 SSH deployment flow then ships this staged directory to player devices, so
|
||||||
the version admins build here is exactly what gets deployed.
|
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 os
|
||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
@@ -24,90 +37,154 @@ logger = logging.getLogger(__name__)
|
|||||||
# Metadata file name stored in the Flask instance folder.
|
# Metadata file name stored in the Flask instance folder.
|
||||||
BUILD_META_FILENAME = 'player_build.json'
|
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:
|
# Never let git wait for a human. Without this, a private/renamed repository
|
||||||
return subprocess.run(
|
# makes git block on a username prompt until the worker is killed.
|
||||||
['git'] + args,
|
GIT_ENV = {
|
||||||
cwd=cwd,
|
'GIT_TERMINAL_PROMPT': '0', # never prompt for credentials
|
||||||
capture_output=True,
|
'GIT_ASKPASS': 'true', # answer any credential request immediately
|
||||||
text=True,
|
'GIT_SSH_COMMAND': 'ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new',
|
||||||
timeout=timeout,
|
}
|
||||||
)
|
|
||||||
|
|
||||||
|
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:
|
def get_short_head(player_code_dir: str) -> str:
|
||||||
"""Return the short git commit of the staged code, or 'unknown'."""
|
"""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)
|
||||||
result = _run_git(['-C', player_code_dir, 'rev-parse', '--short', 'HEAD'], timeout=10)
|
if result.returncode == 0:
|
||||||
if result.returncode == 0:
|
return result.stdout.strip()
|
||||||
return result.stdout.strip()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return 'unknown'
|
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]:
|
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``.
|
"""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
|
Uses a **shallow single-branch clone/update** so only the tip of the wanted
|
||||||
place (fetch + hard reset to the chosen branch). Otherwise it is cloned
|
branch is transferred. If the directory is a usable checkout it is updated
|
||||||
fresh (an existing non-git directory is replaced).
|
(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),
|
Args:
|
||||||
``branch`` (str).
|
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()
|
branch = (branch or 'main').strip()
|
||||||
repo_url = (repo_url or '').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:
|
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:
|
try:
|
||||||
git_dir = os.path.join(player_code_dir, '.git')
|
usable = is_valid_checkout(player_code_dir)
|
||||||
is_git_repo = os.path.isdir(git_dir)
|
|
||||||
|
|
||||||
if is_git_repo:
|
if usable:
|
||||||
# Update existing checkout in place.
|
# Update in place. Depth 1 keeps the update cheap; fetch by ref so
|
||||||
fetch = _run_git(['-C', player_code_dir, 'fetch', '--prune', 'origin'])
|
# it works on a shallow clone.
|
||||||
|
fetch = _run_git(
|
||||||
|
['-C', player_code_dir, 'fetch', '--depth', CLONE_DEPTH,
|
||||||
|
'--prune', 'origin', branch])
|
||||||
if fetch.returncode != 0:
|
if fetch.returncode != 0:
|
||||||
return {
|
return fail(f'git fetch failed: {fetch.stderr.strip() or fetch.stdout.strip()}')
|
||||||
'success': False,
|
|
||||||
'message': f'git fetch failed: {fetch.stderr.strip() or fetch.stdout.strip()}',
|
|
||||||
'version': get_short_head(player_code_dir),
|
|
||||||
'branch': branch,
|
|
||||||
}
|
|
||||||
# Point origin at the requested URL in case it changed.
|
# Point origin at the requested URL in case it changed.
|
||||||
_run_git(['-C', player_code_dir, 'remote', 'set-url', 'origin', repo_url])
|
_run_git(['-C', player_code_dir, 'remote', 'set-url', 'origin', repo_url])
|
||||||
|
|
||||||
checkout = _run_git(['-C', player_code_dir, 'checkout', branch])
|
checkout = _run_git(['-C', player_code_dir, 'checkout', branch])
|
||||||
if checkout.returncode != 0:
|
if checkout.returncode != 0:
|
||||||
return {
|
return fail(f'git checkout {branch} failed: {checkout.stderr.strip()}')
|
||||||
'success': False,
|
|
||||||
'message': f'git checkout {branch} failed: {checkout.stderr.strip()}',
|
|
||||||
'version': get_short_head(player_code_dir),
|
|
||||||
'branch': branch,
|
|
||||||
}
|
|
||||||
reset = _run_git(['-C', player_code_dir, 'reset', '--hard', f'origin/{branch}'])
|
reset = _run_git(['-C', player_code_dir, 'reset', '--hard', f'origin/{branch}'])
|
||||||
if reset.returncode != 0:
|
if reset.returncode != 0:
|
||||||
return {
|
return fail(f'git reset failed: {reset.stderr.strip()}')
|
||||||
'success': False,
|
|
||||||
'message': f'git reset failed: {reset.stderr.strip()}',
|
|
||||||
'version': get_short_head(player_code_dir),
|
|
||||||
'branch': branch,
|
|
||||||
}
|
|
||||||
action = 'Updated'
|
action = 'Updated'
|
||||||
else:
|
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('/'))
|
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):
|
if os.path.exists(player_code_dir):
|
||||||
shutil.rmtree(player_code_dir)
|
logger.info('Removing unusable directory before clone: %s', player_code_dir)
|
||||||
clone = _run_git(['clone', '--branch', branch, repo_url, player_code_dir])
|
shutil.rmtree(player_code_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
clone = _clone(player_code_dir, repo_url, branch)
|
||||||
if clone.returncode != 0:
|
if clone.returncode != 0:
|
||||||
return {
|
# Do not leave a half-written directory behind.
|
||||||
'success': False,
|
shutil.rmtree(player_code_dir, ignore_errors=True)
|
||||||
'message': f'git clone failed: {clone.stderr.strip() or clone.stdout.strip()}',
|
detail = clone.stderr.strip() or clone.stdout.strip()
|
||||||
'version': None,
|
if clone.returncode == 124 or 'timed out' in detail:
|
||||||
'branch': branch,
|
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'
|
action = 'Cloned'
|
||||||
|
|
||||||
version = get_short_head(player_code_dir)
|
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,
|
'version': version,
|
||||||
'branch': branch,
|
'branch': branch,
|
||||||
}
|
}
|
||||||
except subprocess.TimeoutExpired:
|
except Exception as e: # noqa: BLE001 - surface any failure
|
||||||
return {'success': False, 'message': 'Git operation timed out.', 'version': None, 'branch': branch}
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception('build_player_files failed')
|
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(
|
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_at': datetime.utcnow().isoformat(timespec='seconds') + 'Z',
|
||||||
'built_by': built_by,
|
'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
|
||||||
|
|||||||
@@ -16,10 +16,17 @@ echo -e "${BLUE}║ DigiServer Automated Deployment
|
|||||||
echo -e "${BLUE}╚════════════════════════════════════════════════════════════════╝${NC}"
|
echo -e "${BLUE}╚════════════════════════════════════════════════════════════════╝${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Check if docker compose is available
|
# Check if docker compose is available. Accept either the modern plugin
|
||||||
if ! docker compose version &> /dev/null; then
|
# (`docker compose`) or the standalone v1 binary (`docker-compose`), since not
|
||||||
|
# every Docker installation ships the Compose plugin.
|
||||||
|
if docker compose version &> /dev/null; then
|
||||||
|
COMPOSE="docker compose"
|
||||||
|
elif command -v docker-compose &> /dev/null; then
|
||||||
|
COMPOSE="docker-compose"
|
||||||
|
echo -e "${YELLOW}⚠️ Using legacy 'docker-compose' (v1); the 'docker compose' plugin is unavailable.${NC}"
|
||||||
|
else
|
||||||
echo -e "${RED}❌ docker compose not found!${NC}"
|
echo -e "${RED}❌ docker compose not found!${NC}"
|
||||||
echo "Please install docker compose first"
|
echo "Please install the docker compose plugin or docker-compose first"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -31,67 +38,133 @@ if [ ! -f "docker-compose.yml" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# INITIALIZATION: Create data directories and copy nginx configs
|
# INITIALIZATION: Create data directories and seed the Caddy config
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
echo -e "${YELLOW}📁 Initializing data directories...${NC}"
|
echo -e "${YELLOW}📁 Initializing data directories...${NC}"
|
||||||
|
|
||||||
# Create necessary data directories
|
# Create necessary data directories
|
||||||
mkdir -p data/instance
|
mkdir -p data/instance
|
||||||
mkdir -p data/uploads
|
mkdir -p data/uploads
|
||||||
mkdir -p data/nginx-ssl
|
mkdir -p data/caddy-data
|
||||||
mkdir -p data/nginx-logs
|
mkdir -p data/caddy-config
|
||||||
mkdir -p data/certbot
|
mkdir -p data/caddy-logs
|
||||||
|
|
||||||
# Copy nginx configuration files from repo root to data folder
|
# Seed the Caddyfile. It is bind-mounted as a FILE, so it MUST exist before
|
||||||
if [ -f "nginx.conf" ]; then
|
# `docker compose up` — otherwise Docker creates a directory in its place and
|
||||||
cp nginx.conf data/nginx.conf
|
# Caddy fails to start.
|
||||||
echo -e " ${GREEN}✓${NC} nginx.conf copied to data/"
|
if [ -f "data/Caddyfile" ]; then
|
||||||
|
echo -e " ${GREEN}✓${NC} data/Caddyfile present"
|
||||||
|
elif [ -f "Caddyfile.example" ]; then
|
||||||
|
cp Caddyfile.example data/Caddyfile
|
||||||
|
echo -e " ${GREEN}✓${NC} data/Caddyfile seeded from Caddyfile.example"
|
||||||
else
|
else
|
||||||
echo -e " ${RED}❌ nginx.conf not found in repo root!${NC}"
|
echo -e " ${RED}❌ Caddyfile.example not found in repo root!${NC}"
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -f "nginx-custom-domains.conf" ]; then
|
|
||||||
cp nginx-custom-domains.conf data/nginx-custom-domains.conf
|
|
||||||
echo -e " ${GREEN}✓${NC} nginx-custom-domains.conf copied to data/"
|
|
||||||
else
|
|
||||||
echo -e " ${RED}❌ nginx-custom-domains.conf not found in repo root!${NC}"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${GREEN}✅ Data directories initialized${NC}"
|
echo -e "${GREEN}✅ Data directories initialized${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# CONFIGURATION VARIABLES
|
# CONFIGURATION VARIABLES
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
HOSTNAME="${HOSTNAME:-digiserver}"
|
# NOTE: do NOT use the name HOSTNAME here — it is a bash built-in that already
|
||||||
DOMAIN="${DOMAIN:-digiserver.sibiusb.harting.intra}"
|
# holds the machine's hostname (e.g. "development"), so `${HOSTNAME:-default}`
|
||||||
IP_ADDRESS="${IP_ADDRESS:-10.76.152.164}"
|
# would silently ignore the default and leak the OS hostname into the Caddyfile.
|
||||||
|
SERVER_HOSTNAME="${SERVER_HOSTNAME:-digiserver}"
|
||||||
EMAIL="${EMAIL:-admin@example.com}"
|
EMAIL="${EMAIL:-admin@example.com}"
|
||||||
PORT="${PORT:-443}"
|
|
||||||
|
# Auto-detect the primary LAN IP unless one was supplied explicitly.
|
||||||
|
if [ -z "${IP_ADDRESS:-}" ]; then
|
||||||
|
IP_ADDRESS="$(ip -4 route get 1.1.1.1 2>/dev/null | grep -oP 'src \K[\d.]+' | head -1)"
|
||||||
|
if [ -z "$IP_ADDRESS" ]; then
|
||||||
|
IP_ADDRESS="$(hostname -I 2>/dev/null | awk '{print $1}')"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ -z "$IP_ADDRESS" ]; then
|
||||||
|
echo -e "${RED}❌ Could not determine the server IP. Set IP_ADDRESS=... and re-run.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# HTTPS_MODE selects how TLS is provided:
|
||||||
|
# internal : Caddy's internal CA, IP-only. Needs NO public DNS and NO ACME.
|
||||||
|
# Correct for an intranet name such as digiserver.sibiusb.harting.intra.
|
||||||
|
# acme : Let's Encrypt — requires DOMAIN to be publicly resolvable.
|
||||||
|
# off : plain HTTP only.
|
||||||
|
HTTPS_MODE="${HTTPS_MODE:-internal}"
|
||||||
|
|
||||||
|
case "$HTTPS_MODE" in
|
||||||
|
internal)
|
||||||
|
# An empty DOMAIN is what makes CaddyConfigGenerator choose the
|
||||||
|
# internal-CA path instead of Let's Encrypt.
|
||||||
|
DOMAIN=""
|
||||||
|
;;
|
||||||
|
acme)
|
||||||
|
if [ -z "${DOMAIN:-}" ]; then
|
||||||
|
echo -e "${RED}❌ HTTPS_MODE=acme requires DOMAIN to be set.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
off)
|
||||||
|
DOMAIN=""
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}❌ Invalid HTTPS_MODE '$HTTPS_MODE' (use internal|acme|off).${NC}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Published ports. These MUST match docker-compose.yml, which maps Caddy's
|
||||||
|
# internal 80/443 to these host ports (HTTP_PORT / HTTPS_PORT).
|
||||||
|
HTTP_PORT="${HTTP_PORT:-80}"
|
||||||
|
HTTPS_PORT="${HTTPS_PORT:-443}"
|
||||||
|
|
||||||
echo -e "${BLUE}Configuration:${NC}"
|
echo -e "${BLUE}Configuration:${NC}"
|
||||||
echo " Hostname: $HOSTNAME"
|
echo " Hostname: $SERVER_HOSTNAME"
|
||||||
echo " Domain: $DOMAIN"
|
echo " HTTPS mode: $HTTPS_MODE"
|
||||||
|
echo " Domain: ${DOMAIN:-(none — internal CA)}"
|
||||||
echo " IP Address: $IP_ADDRESS"
|
echo " IP Address: $IP_ADDRESS"
|
||||||
echo " Email: $EMAIL"
|
echo " Email: $EMAIL"
|
||||||
echo " Port: $PORT"
|
echo " HTTP port: $HTTP_PORT"
|
||||||
|
echo " HTTPS port: $HTTPS_PORT"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# STEP 1: Start containers
|
# STEP 1: Build and start containers
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
echo -e "${YELLOW}📦 [1/6] Starting containers...${NC}"
|
echo -e "${YELLOW}📦 [1/6] Building and starting containers...${NC}"
|
||||||
docker compose up -d
|
|
||||||
|
# Compose v1 refuses to build unless the buildx plugin is >= 0.17:
|
||||||
|
# "compose build requires buildx 0.17.0 or later"
|
||||||
|
# Distro Packaged Docker ships older buildx (or none). Detect that and fall back
|
||||||
|
# to a plain `docker build` + `up --no-build`, which needs no buildx at all.
|
||||||
|
APP_IMAGE="digiserver-v2-digiserver-app:latest"
|
||||||
|
|
||||||
|
BUILDX_VER="$(docker buildx version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+' | head -1)"
|
||||||
|
BUILDX_OK=0
|
||||||
|
if [ -n "$BUILDX_VER" ]; then
|
||||||
|
_bx_major="${BUILDX_VER%%.*}"
|
||||||
|
_bx_minor="${BUILDX_VER##*.}"
|
||||||
|
if [ "$_bx_major" -gt 0 ] || { [ "$_bx_major" -eq 0 ] && [ "$_bx_minor" -ge 17 ]; }; then
|
||||||
|
BUILDX_OK=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$BUILDX_OK" -eq 1 ]; then
|
||||||
|
echo -e " ${GREEN}✓${NC} buildx ${BUILDX_VER} — building via compose"
|
||||||
|
$COMPOSE up -d --build
|
||||||
|
else
|
||||||
|
echo -e " ${YELLOW}⚠${NC} buildx ${BUILDX_VER:-not found} (< 0.17) — falling back to 'docker build'"
|
||||||
|
docker build -t "$APP_IMAGE" .
|
||||||
|
$COMPOSE up -d --no-build
|
||||||
|
fi
|
||||||
|
|
||||||
echo -e "${YELLOW}⏳ Waiting for containers to be healthy...${NC}"
|
echo -e "${YELLOW}⏳ Waiting for containers to be healthy...${NC}"
|
||||||
sleep 10
|
sleep 15
|
||||||
|
|
||||||
# Verify containers are running
|
# Verify containers are running
|
||||||
if ! docker compose ps | grep -q "Up"; then
|
if ! $COMPOSE ps | grep -q "Up"; then
|
||||||
echo -e "${RED}❌ Containers failed to start!${NC}"
|
echo -e "${RED}❌ Containers failed to start!${NC}"
|
||||||
docker compose logs
|
$COMPOSE logs
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo -e "${GREEN}✅ Containers started successfully${NC}"
|
echo -e "${GREEN}✅ Containers started successfully${NC}"
|
||||||
@@ -103,15 +176,15 @@ echo ""
|
|||||||
echo -e "${YELLOW}📊 [2/6] Running database migrations...${NC}"
|
echo -e "${YELLOW}📊 [2/6] Running database migrations...${NC}"
|
||||||
|
|
||||||
echo -e " • Creating https_config table..."
|
echo -e " • Creating https_config table..."
|
||||||
docker compose exec -T digiserver-app python /app/migrations/add_https_config_table.py
|
$COMPOSE exec -T digiserver-app python /app/migrations/add_https_config_table.py
|
||||||
echo -e " • Creating player_user table..."
|
echo -e " • Creating player_user table..."
|
||||||
docker compose exec -T digiserver-app python /app/migrations/add_player_user_table.py
|
$COMPOSE exec -T digiserver-app python /app/migrations/add_player_user_table.py
|
||||||
echo -e " • Adding email to https_config..."
|
echo -e " • Adding email to https_config..."
|
||||||
docker compose exec -T digiserver-app python /app/migrations/add_email_to_https_config.py
|
$COMPOSE exec -T digiserver-app python /app/migrations/add_email_to_https_config.py
|
||||||
echo -e " • Migrating player_user global settings..."
|
echo -e " • Migrating player_user global settings..."
|
||||||
docker compose exec -T digiserver-app python /app/migrations/migrate_player_user_global.py
|
$COMPOSE exec -T digiserver-app python /app/migrations/migrate_player_user_global.py
|
||||||
echo -e " • Adding original_filename to content..."
|
echo -e " • Adding original_filename to content..."
|
||||||
docker compose exec -T digiserver-app python /app/migrations/add_original_filename_to_content.py
|
$COMPOSE exec -T digiserver-app python /app/migrations/add_original_filename_to_content.py
|
||||||
|
|
||||||
echo -e "${GREEN}✅ All database migrations completed${NC}"
|
echo -e "${GREEN}✅ All database migrations completed${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
@@ -119,16 +192,41 @@ echo ""
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
# STEP 3: Configure HTTPS
|
# STEP 3: Configure HTTPS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
echo -e "${YELLOW}🔒 [3/6] Configuring HTTPS...${NC}"
|
echo -e "${YELLOW}🔒 [3/6] Configuring HTTPS (mode: $HTTPS_MODE)...${NC}"
|
||||||
|
|
||||||
docker compose exec -T digiserver-app python /app/https_manager.py enable \
|
# https_manager.py mirrors the Admin UI code path: persist HTTPSConfig →
|
||||||
"$HOSTNAME" \
|
# regenerate the Caddyfile → hot-reload Caddy → verify the TLS listener and
|
||||||
"$DOMAIN" \
|
# automatically fall back to HTTP if it does not come up.
|
||||||
"$EMAIL" \
|
#
|
||||||
"$IP_ADDRESS" \
|
# `bootstrap` (rather than `enable`) is used deliberately: it records provenance
|
||||||
"$PORT"
|
# via HTTPSConfig.updated_by, so an admin's later change in the UI is not
|
||||||
|
# silently overwritten on the next restart.
|
||||||
|
#
|
||||||
|
# The values computed above are injected with -e so this run uses them instead
|
||||||
|
# of whatever happens to be in the container's .env.
|
||||||
|
#
|
||||||
|
# Exit code 2 means "config applied but Caddy did not reload" — not fatal, so it
|
||||||
|
# must not abort the deployment.
|
||||||
|
set +e
|
||||||
|
if [ "$HTTPS_MODE" = "off" ]; then
|
||||||
|
$COMPOSE exec -T digiserver-app python /app/https_manager.py disable
|
||||||
|
else
|
||||||
|
$COMPOSE exec -T \
|
||||||
|
-e HOSTNAME_INTERNAL="$SERVER_HOSTNAME" \
|
||||||
|
-e HOST_IP="$IP_ADDRESS" \
|
||||||
|
-e DOMAIN="$DOMAIN" \
|
||||||
|
-e SSL_EMAIL="$EMAIL" \
|
||||||
|
-e HTTPS_PORT="$HTTPS_PORT" \
|
||||||
|
digiserver-app python /app/https_manager.py bootstrap
|
||||||
|
fi
|
||||||
|
HTTPS_RC=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
echo -e "${GREEN}✅ HTTPS configured successfully${NC}"
|
case "$HTTPS_RC" in
|
||||||
|
0) echo -e "${GREEN}✅ HTTPS configured${NC}" ;;
|
||||||
|
2) echo -e "${YELLOW}⚠️ HTTPS config applied but Caddy did not reload — restart the caddy container.${NC}" ;;
|
||||||
|
*) echo -e "${YELLOW}⚠️ HTTPS not configured (or verification failed and it fell back to HTTP); continuing.${NC}" ;;
|
||||||
|
esac
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -136,20 +234,21 @@ echo ""
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
echo -e "${YELLOW}🔍 [4/6] Verifying database setup...${NC}"
|
echo -e "${YELLOW}🔍 [4/6] Verifying database setup...${NC}"
|
||||||
|
|
||||||
docker compose exec -T digiserver-app python -c "
|
$COMPOSE exec -T digiserver-app python -c "
|
||||||
from app.app import create_app
|
from app.app import create_app
|
||||||
|
from app.extensions import db
|
||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
inspector = inspect(app.extensions.db.engine)
|
inspector = inspect(db.engine)
|
||||||
tables = inspector.get_table_names()
|
tables = inspector.get_table_names()
|
||||||
print(' Database tables:')
|
print(' Database tables:')
|
||||||
for table in sorted(tables):
|
for table in sorted(tables):
|
||||||
print(f' ✓ {table}')
|
print(f' ✓ {table}')
|
||||||
print(f'')
|
print(f'')
|
||||||
print(f' ✅ Total tables: {len(tables)}')
|
print(f' ✅ Total tables: {len(tables)}')
|
||||||
" 2>/dev/null || echo " ⚠️ Database verification skipped"
|
" || echo " ⚠️ Database verification skipped"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -157,7 +256,7 @@ echo ""
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
echo -e "${YELLOW}🔧 [5/6] Verifying Caddy configuration...${NC}"
|
echo -e "${YELLOW}🔧 [5/6] Verifying Caddy configuration...${NC}"
|
||||||
|
|
||||||
docker compose exec -T caddy caddy validate --config /etc/caddy/Caddyfile >/dev/null 2>&1
|
$COMPOSE exec -T caddy caddy validate --config /etc/caddy/Caddyfile >/dev/null 2>&1
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
echo -e " ${GREEN}✅ Caddy configuration is valid${NC}"
|
echo -e " ${GREEN}✅ Caddy configuration is valid${NC}"
|
||||||
else
|
else
|
||||||
@@ -171,7 +270,7 @@ echo ""
|
|||||||
echo -e "${YELLOW}📋 [6/6] Displaying configuration summary...${NC}"
|
echo -e "${YELLOW}📋 [6/6] Displaying configuration summary...${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
docker compose exec -T digiserver-app python /app/https_manager.py status
|
$COMPOSE exec -T digiserver-app python /app/https_manager.py status
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${GREEN}╔════════════════════════════════════════════════════════════════╗${NC}"
|
echo -e "${GREEN}╔════════════════════════════════════════════════════════════════╗${NC}"
|
||||||
@@ -179,21 +278,57 @@ echo -e "${GREEN}║ 🎉 Deployment Complete!
|
|||||||
echo -e "${GREEN}╚════════════════════════════════════════════════════════════════╝${NC}"
|
echo -e "${GREEN}╚════════════════════════════════════════════════════════════════╝${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
# Build host:port suffixes, omitting the default ports for readability.
|
||||||
|
_http_url="http://$IP_ADDRESS"
|
||||||
|
[ "$HTTP_PORT" != "80" ] && _http_url="http://$IP_ADDRESS:$HTTP_PORT"
|
||||||
|
_https_url="https://$IP_ADDRESS"
|
||||||
|
[ "$HTTPS_PORT" != "443" ] && _https_url="https://$IP_ADDRESS:$HTTPS_PORT"
|
||||||
|
|
||||||
echo -e "${BLUE}📍 Access Points:${NC}"
|
echo -e "${BLUE}📍 Access Points:${NC}"
|
||||||
echo " 🔒 https://$HOSTNAME"
|
echo -e " 🌐 ${_http_url} (always available)"
|
||||||
echo " 🔒 https://$IP_ADDRESS"
|
case "$HTTPS_MODE" in
|
||||||
echo " 🔒 https://$DOMAIN"
|
internal)
|
||||||
|
echo -e " 🔒 ${_https_url} (internal CA — see note below)"
|
||||||
|
echo -e " 🔒 https://$SERVER_HOSTNAME (needs DNS or an /etc/hosts entry)"
|
||||||
|
;;
|
||||||
|
acme)
|
||||||
|
echo -e " 🔒 https://$DOMAIN"
|
||||||
|
;;
|
||||||
|
off)
|
||||||
|
echo -e " ℹ️ HTTPS disabled (HTTPS_MODE=off)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
echo -e "${BLUE}📝 Default Credentials:${NC}"
|
if [ "$HTTPS_MODE" = "internal" ]; then
|
||||||
echo " Username: admin"
|
echo -e "${YELLOW}⚠️ Internal CA certificate notice:${NC}"
|
||||||
echo " Password: admin123 (⚠️ CHANGE IN PRODUCTION)"
|
echo " The TLS certificate is signed by Caddy's LOCAL CA, which is not in any"
|
||||||
|
echo " client's trust store. Browsers will warn and players will reject it"
|
||||||
|
echo " unless you either:"
|
||||||
|
echo " a) install the root CA on each device, or"
|
||||||
|
echo " b) use the plain HTTP endpoint above (simplest for players)."
|
||||||
|
echo ""
|
||||||
|
echo " Export the root CA with:"
|
||||||
|
echo " $COMPOSE cp caddy:/data/caddy/pki/authorities/local/root.crt ./caddy-root.crt"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${BLUE}📝 Administrator Account:${NC}"
|
||||||
|
if [ -f ".deployment-credentials" ]; then
|
||||||
|
echo " Credentials are in .deployment-credentials (chmod 600)."
|
||||||
|
else
|
||||||
|
echo " Username: ${ADMIN_USERNAME:-admin}"
|
||||||
|
echo " Password: see ADMIN_PASSWORD in .env (or the container's ADMIN_PASSWORD)"
|
||||||
|
fi
|
||||||
|
if grep -qs '^ADMIN_PASSWORD=admin123' .env 2>/dev/null; then
|
||||||
|
echo -e " ${RED}⚠️ ADMIN_PASSWORD is still the default — change it now!${NC}"
|
||||||
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
echo -e "${BLUE}📚 Documentation:${NC}"
|
echo -e "${BLUE}📚 Documentation:${NC}"
|
||||||
echo " • DEPLOYMENT_COMMANDS.md - Detailed docker exec commands"
|
echo " • docs/07-deployment.md - Deployment + HTTPS details"
|
||||||
echo " • HTTPS_CONFIGURATION.md - HTTPS setup details"
|
echo " • docs/06-utils-services.md - Caddy / https_manager internals"
|
||||||
echo " • setup_https.sh - Manual configuration script"
|
echo " • Caddyfile.example - Caddy template (seeded into data/)"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
echo -e "${YELLOW}Next Steps:${NC}"
|
echo -e "${YELLOW}Next Steps:${NC}"
|
||||||
@@ -204,5 +339,5 @@ echo "4. Configure your players and content"
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
echo -e "${BLUE}📞 Support:${NC}"
|
echo -e "${BLUE}📞 Support:${NC}"
|
||||||
echo "For troubleshooting, see DEPLOYMENT_COMMANDS.md section 7"
|
echo "For troubleshooting, see docs/07-deployment.md"
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -104,8 +104,8 @@ echo ""
|
|||||||
echo "Test certificate:"
|
echo "Test certificate:"
|
||||||
echo " openssl s_client -connect your-domain.com:443 -showcerts"
|
echo " openssl s_client -connect your-domain.com:443 -showcerts"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Check SSL certificate expiry:"
|
echo "Check TLS certificate (Caddy internal CA root):"
|
||||||
echo " openssl x509 -enddate -noout -in data/nginx-ssl/cert.pem"
|
echo " openssl x509 -enddate -noout -in data/caddy-data/caddy/pki/authorities/local/root.crt"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
+47
-3
@@ -15,11 +15,46 @@ services:
|
|||||||
# Only mount persistent data folders:
|
# Only mount persistent data folders:
|
||||||
- ./data/instance:/app/instance
|
- ./data/instance:/app/instance
|
||||||
- ./data/uploads:/app/app/static/uploads
|
- ./data/uploads:/app/app/static/uploads
|
||||||
|
# Staged player source (git clone). Persisted so a rebuild of the app
|
||||||
|
# container does not throw away the ~140 MB staged checkout, and so the
|
||||||
|
# SSH deployment step has something to ship.
|
||||||
|
- ./data/player:/app/data/player
|
||||||
|
# The app GENERATES the Caddyfile (env bootstrap at startup, and the
|
||||||
|
# Admin → HTTPS Configuration page at runtime) then asks Caddy to reload.
|
||||||
|
# It therefore needs write access to the same file Caddy reads, so this
|
||||||
|
# must be mounted here as well as in the caddy service.
|
||||||
|
# The file is host-owned (uid 1000 == appuser), so writes succeed.
|
||||||
|
- ./data/Caddyfile:/etc/caddy/Caddyfile:rw
|
||||||
environment:
|
environment:
|
||||||
- FLASK_ENV=production
|
- FLASK_ENV=production
|
||||||
- SECRET_KEY=${SECRET_KEY:-your-secret-key-change-this}
|
- SECRET_KEY=${SECRET_KEY:-your-secret-key-change-this}
|
||||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# Deploy-time TLS bootstrap (all optional).
|
||||||
|
#
|
||||||
|
# If HOSTNAME_INTERNAL and HOST_IP are BOTH set, the container configures
|
||||||
|
# Caddy for HTTPS using that address at startup — no manual step needed.
|
||||||
|
# If either is missing, the app stays on the plain-HTTP fallback and you
|
||||||
|
# can enable HTTPS later from Admin → HTTPS Configuration (which reloads
|
||||||
|
# Caddy live, no restart required).
|
||||||
|
#
|
||||||
|
# Leave DOMAIN empty for Caddy's internal CA. That needs NO public DNS
|
||||||
|
# and NO ACME, which is the right choice for an intranet name that is not
|
||||||
|
# resolvable from the internet.
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
- HOSTNAME_INTERNAL=${HOSTNAME_INTERNAL:-}
|
||||||
|
- HOST_IP=${HOST_IP:-}
|
||||||
|
- DOMAIN=${DOMAIN:-}
|
||||||
|
- SSL_EMAIL=${SSL_EMAIL:-}
|
||||||
|
# Externally published ports (must match the caddy service mappings below).
|
||||||
|
# They are used to build correct HTTP→HTTPS redirect targets.
|
||||||
|
- HTTP_PORT=${HTTP_PORT:-80}
|
||||||
|
- HTTPS_PORT=${HTTPS_PORT:-443}
|
||||||
|
# Set to "false" to serve TLS only and redirect plain HTTP to HTTPS.
|
||||||
|
- HTTPS_HTTP_FALLBACK=${HTTPS_HTTP_FALLBACK:-true}
|
||||||
|
# Post-bootstrap check: probe HTTPS and fall back to HTTP if it is broken.
|
||||||
|
- HTTPS_VERIFY=${HTTPS_VERIFY:-true}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/').read()"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/').read()"]
|
||||||
@@ -30,14 +65,23 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- digiserver-network
|
- digiserver-network
|
||||||
|
|
||||||
# Caddy reverse proxy — auto-provisions Let's Encrypt certs when a real domain is configured
|
# Caddy reverse proxy.
|
||||||
|
# Port 80 → always answers, for both the IP and the hostname.
|
||||||
|
# Port 443 → HTTPS when configured; plain HTTP is served alongside it by
|
||||||
|
# default so clients that cannot trust the internal CA still work.
|
||||||
|
# Ports are configurable so the stack also works on a host where 80/443 are
|
||||||
|
# already taken (e.g. HTTP_PORT=8080 HTTPS_PORT=8443).
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2-alpine
|
image: caddy:2-alpine
|
||||||
container_name: digiserver-caddy
|
container_name: digiserver-caddy
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "${HTTP_PORT:-80}:80"
|
||||||
- "8443:443"
|
- "${HTTPS_PORT:-443}:443"
|
||||||
volumes:
|
volumes:
|
||||||
|
# The app container regenerates this file on startup (env bootstrap) and
|
||||||
|
# whenever HTTPS is changed in the Admin UI, then hot-reloads Caddy via
|
||||||
|
# its admin API (http://caddy:2019/load). Because the file on disk is
|
||||||
|
# always current, a plain restart of Caddy picks up the latest config.
|
||||||
- ./data/Caddyfile:/etc/caddy/Caddyfile:rw
|
- ./data/Caddyfile:/etc/caddy/Caddyfile:rw
|
||||||
- ./data/caddy-data:/data
|
- ./data/caddy-data:/data
|
||||||
- ./data/caddy-config:/config
|
- ./data/caddy-config:/config
|
||||||
|
|||||||
+133
-15
@@ -3,50 +3,168 @@ set -e
|
|||||||
|
|
||||||
echo "Starting DigiServer v2..."
|
echo "Starting DigiServer v2..."
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pin the database explicitly.
|
||||||
|
#
|
||||||
|
# The migration scripts and the application must target the SAME SQLite file.
|
||||||
|
# Without this, they do not: the config classes default to different files
|
||||||
|
# (dev.db for DevelopmentConfig, dashboard.db for ProductionConfig), and most
|
||||||
|
# migration scripts call create_app() without an argument, which selects the
|
||||||
|
# *development* config. The app itself runs as create_app('production').
|
||||||
|
# Exporting DATABASE_URL makes every code path below resolve to the same
|
||||||
|
# database, regardless of which config gets loaded.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
export DATABASE_URL="${DATABASE_URL:-sqlite:////app/instance/dashboard.db}"
|
||||||
|
export FLASK_ENV="${FLASK_ENV:-production}"
|
||||||
|
|
||||||
|
echo "Database: ${DATABASE_URL}"
|
||||||
|
|
||||||
# Create necessary directories
|
# Create necessary directories
|
||||||
mkdir -p /app/instance
|
mkdir -p /app/instance
|
||||||
mkdir -p /app/app/static/uploads
|
mkdir -p /app/app/static/uploads
|
||||||
|
# Staged player source (bind-mounted from ./data/player). Created here so the
|
||||||
|
# container works even if the volume was not pre-created on the host.
|
||||||
|
mkdir -p /app/data/player
|
||||||
|
|
||||||
# Initialize database if it doesn't exist
|
# ---------------------------------------------------------------------------
|
||||||
if [ ! -f /app/instance/dashboard.db ]; then
|
# Ensure the schema exists and bootstrap the admin user.
|
||||||
echo "Initializing database..."
|
#
|
||||||
python -c "
|
# Both operations are idempotent, so this runs on every container start:
|
||||||
|
# db.create_all() only creates missing tables, and the admin block creates the
|
||||||
|
# user if absent or otherwise refreshes its password from the environment.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo "Ensuring database schema and admin user..."
|
||||||
|
python -c "
|
||||||
from app.app import create_app
|
from app.app import create_app
|
||||||
from app.extensions import db, bcrypt
|
from app.extensions import db, bcrypt
|
||||||
from app.models import User
|
from app.models import User
|
||||||
|
import os
|
||||||
|
|
||||||
app = create_app('production')
|
app = create_app('production')
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
db.create_all()
|
db.create_all()
|
||||||
|
|
||||||
# Create or update admin user from environment variables
|
|
||||||
import os
|
|
||||||
admin_username = os.getenv('ADMIN_USERNAME', 'admin')
|
admin_username = os.getenv('ADMIN_USERNAME', 'admin')
|
||||||
admin_password = os.getenv('ADMIN_PASSWORD', 'admin123')
|
admin_password = os.getenv('ADMIN_PASSWORD', 'admin123')
|
||||||
|
|
||||||
admin = User.query.filter_by(username=admin_username).first()
|
admin = User.query.filter_by(username=admin_username).first()
|
||||||
|
hashed = bcrypt.generate_password_hash(admin_password).decode('utf-8')
|
||||||
if not admin:
|
if not admin:
|
||||||
hashed = bcrypt.generate_password_hash(admin_password).decode('utf-8')
|
|
||||||
admin = User(username=admin_username, password=hashed, role='admin')
|
admin = User(username=admin_username, password=hashed, role='admin')
|
||||||
db.session.add(admin)
|
db.session.add(admin)
|
||||||
db.session.commit()
|
|
||||||
print(f'✅ Admin user created ({admin_username})')
|
print(f'✅ Admin user created ({admin_username})')
|
||||||
else:
|
else:
|
||||||
# Update password if it exists
|
# Keep the stored password in sync with the environment.
|
||||||
hashed = bcrypt.generate_password_hash(admin_password).decode('utf-8')
|
|
||||||
admin.password = hashed
|
admin.password = hashed
|
||||||
db.session.commit()
|
|
||||||
print(f'✅ Admin user password updated ({admin_username})')
|
print(f'✅ Admin user password updated ({admin_username})')
|
||||||
|
db.session.commit()
|
||||||
"
|
"
|
||||||
echo "Database initialized!"
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Apply schema migrations.
|
||||||
|
#
|
||||||
|
# Every migration script is idempotent (it guards against duplicate columns and
|
||||||
|
# already-migrated tables), so this is safe to run on every start and upgrades
|
||||||
|
# databases created by older releases.
|
||||||
|
#
|
||||||
|
# ORDER MATTERS: migrations that create/repair a table must run before the ones
|
||||||
|
# that alter that table (e.g. add_player_user_table.py before
|
||||||
|
# migrate_player_user_global.py, add_https_config_table.py before
|
||||||
|
# add_email_to_https_config.py).
|
||||||
|
#
|
||||||
|
# Migrations are deliberately NON-FATAL: a failure is logged and startup
|
||||||
|
# continues, so one bad migration cannot strand the container in a restart
|
||||||
|
# loop. Look for the WARNING lines in the logs if something looks wrong.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
MIGRATIONS=(
|
||||||
|
"add_https_config_table.py"
|
||||||
|
"add_player_user_table.py"
|
||||||
|
"add_email_to_https_config.py"
|
||||||
|
"migrate_player_user_global.py"
|
||||||
|
"add_url_to_content.py"
|
||||||
|
"add_original_filename_to_content.py"
|
||||||
|
"add_deployment_fields_to_player.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "Running database migrations..."
|
||||||
|
FAILED_MIGRATIONS=()
|
||||||
|
|
||||||
|
for migration in "${MIGRATIONS[@]}"; do
|
||||||
|
migration_path="/app/migrations/${migration}"
|
||||||
|
|
||||||
|
if [ ! -f "$migration_path" ]; then
|
||||||
|
echo "⚠️ Skipping missing migration: ${migration}"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " • ${migration}"
|
||||||
|
if ! python "$migration_path"; then
|
||||||
|
echo "⚠️ WARNING: migration failed: ${migration}"
|
||||||
|
FAILED_MIGRATIONS+=("${migration}")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ${#FAILED_MIGRATIONS[@]} -gt 0 ]; then
|
||||||
|
echo "⚠️ WARNING: ${#FAILED_MIGRATIONS[@]} migration(s) failed: ${FAILED_MIGRATIONS[*]}"
|
||||||
|
echo "⚠️ Starting anyway — check the errors above."
|
||||||
|
else
|
||||||
|
echo "✅ All database migrations applied."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bootstrap HTTPS from environment variables.
|
||||||
|
#
|
||||||
|
# HOSTNAME_INTERNAL + HOST_IP (compose → env) configure Caddy for HTTPS at
|
||||||
|
# startup, so a fresh deployment is reachable over TLS without a manual step.
|
||||||
|
# When either is unset this is a deliberate NO-OP: the app stays on plain HTTP
|
||||||
|
# and HTTPS can be enabled later from Admin → HTTPS Configuration, which
|
||||||
|
# reloads Caddy live.
|
||||||
|
#
|
||||||
|
# ORDERING MATTERS: gunicorn is started FIRST (below) and only then is HTTPS
|
||||||
|
# configured. The bootstrap probes https://…/api/health to decide whether the
|
||||||
|
# certificate really works; if it ran before the app was listening, Caddy would
|
||||||
|
# return 502 and the probe would wrongly conclude HTTPS was broken.
|
||||||
|
#
|
||||||
|
# Non-fatal: if this fails the app still runs, and the admin page remains the
|
||||||
|
# fallback path for configuring HTTPS.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
bootstrap_https() {
|
||||||
|
echo "Checking HTTPS bootstrap..."
|
||||||
|
if ! python /app/https_manager.py bootstrap; then
|
||||||
|
echo "⚠️ WARNING: HTTPS bootstrap failed — continuing; configure HTTPS from the admin UI."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# Start the application
|
# Start the application
|
||||||
|
# --timeout is a safety net for any remaining synchronous long operation. The
|
||||||
|
# player build no longer blocks a worker (it runs in a background thread), but
|
||||||
|
# a generous margin avoids surprise worker kills during large uploads or
|
||||||
|
# dependency installs triggered from the admin UI.
|
||||||
echo "Starting Gunicorn..."
|
echo "Starting Gunicorn..."
|
||||||
exec gunicorn \
|
gunicorn \
|
||||||
--bind 0.0.0.0:5000 \
|
--bind 0.0.0.0:5000 \
|
||||||
--workers 4 \
|
--workers 4 \
|
||||||
--timeout 120 \
|
--timeout 300 \
|
||||||
--access-logfile - \
|
--access-logfile - \
|
||||||
--error-logfile - \
|
--error-logfile - \
|
||||||
"app.app:create_app('production')"
|
"app.app:create_app('production')" &
|
||||||
|
GUNICORN_PID=$!
|
||||||
|
|
||||||
|
# Wait for the app to answer before configuring HTTPS, then run the bootstrap.
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
if python -c "
|
||||||
|
import sys, urllib.request
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen('http://127.0.0.1:5000/health', timeout=2)
|
||||||
|
except Exception:
|
||||||
|
sys.exit(1)
|
||||||
|
" 2>/dev/null; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
bootstrap_https
|
||||||
|
|
||||||
|
# Keep the container attached to gunicorn so signals and healthchecks behave.
|
||||||
|
wait $GUNICORN_PID
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ Graphify assigns each node a **level** (0 = entry/global → 3 = utility):
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **L0 — Entry / Global** | 0 | `create_app()`, `app.py`, config classes, error handlers, CLI commands |
|
| **L0 — Entry / Global** | 0 | `create_app()`, `app.py`, config classes, error handlers, CLI commands |
|
||||||
| **L1 — Strategic / Core** | 1 | Blueprint route handlers (players, content, api, admin), model classes |
|
| **L1 — Strategic / Core** | 1 | Blueprint route handlers (players, content, api, admin), model classes |
|
||||||
| **L2 — Implementation** | 2 | Playlist/group management helpers, processing helpers, model methods |
|
| **L2 — Implementation** | 2 | Playlist management helpers, processing helpers, model methods |
|
||||||
| **L3 — Utility** | 3 | `logger.py`, `ssh_deploy.py`, `caddy_manager.py`, `uploads.py`, `pptx_converter.py`, migrations |
|
| **L3 — Utility** | 3 | `logger.py`, `ssh_deploy.py`, `caddy_manager.py`, `uploads.py`, `pptx_converter.py`, migrations |
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -81,7 +81,6 @@ flowchart TD
|
|||||||
log["log_action()"]
|
log["log_action()"]
|
||||||
up["uploads.py"]
|
up["uploads.py"]
|
||||||
pptx["pptx_converter.py"]
|
pptx["pptx_converter.py"]
|
||||||
nginx["NginxConfigReader"]
|
|
||||||
end
|
end
|
||||||
create_app --> bp
|
create_app --> bp
|
||||||
create_app --> models
|
create_app --> models
|
||||||
@@ -102,14 +101,14 @@ Graphify clustered the code into **41 communities**. The 12 meaningful ones are
|
|||||||
|
|
||||||
| Community | Domain (derived) | Files | Role |
|
| Community | Domain (derived) | Files | Role |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **C0** (90) | **Authentication + legacy groups** | `blueprints/auth.py`, `blueprints/content_old.py`, `models/server_log.py`, `utils/logger.py`, `utils/group_player_management.py`, `old_code_documentation/blueprint_groups.py` | Auth flows + audit logging + (legacy) group features |
|
| **C0** (90) | **Authentication + audit** | `blueprints/auth.py`, `models/server_log.py`, `utils/logger.py`, `old_code_documentation/blueprint_groups.py` | Auth flows + audit logging |
|
||||||
| **C1** (80) | **Admin + HTTPS + player users** | `blueprints/admin.py`, `models/https_config.py`, `models/player_user.py`, `utils/caddy_manager.py`, `migrations/add_player_user_table.py` | Admin panel, Caddy HTTPS generation, editing-user registry |
|
| **C1** (80) | **Admin + HTTPS + player users** | `blueprints/admin.py`, `models/https_config.py`, `models/player_user.py`, `utils/caddy_manager.py`, `migrations/add_player_user_table.py` | Admin panel, Caddy HTTPS generation, editing-user registry |
|
||||||
| **C2** (68) | **Playlist & content workflows** | `blueprints/content.py`, `blueprints/playlist.py`, `models/playlist.py` | Modern playlist-centric content management + legacy redirects |
|
| **C2** (68) | **Playlist & content workflows** | `blueprints/content.py`, `blueprints/playlist.py`, `models/playlist.py` | Modern playlist-centric content management + legacy redirects |
|
||||||
| **C3** (62) | **Player API + edits** | `blueprints/api.py`, `models/player_edit.py` | Player-facing REST, edited-media pipeline |
|
| **C3** (62) | **Player API + edits** | `blueprints/api.py`, `models/player_edit.py` | Player-facing REST, edited-media pipeline |
|
||||||
| **C4** (55) | **Application core** | `app.py`, `config.py`, `models/user.py`, `utils/portal_sso.py`, `utils/script_name_fix.py` | App factory, config, auth identity, middleware |
|
| **C4** (55) | **Application core** | `app.py`, `config.py`, `models/user.py`, `utils/portal_sso.py`, `utils/script_name_fix.py` | App factory, config, auth identity, middleware |
|
||||||
| **C5** (54) | **Content & player models** | `models/content.py`, `models/player.py`, `models/player_feedback.py`, `blueprints/main.py` | Media model, player model, feedback, dashboard |
|
| **C5** (54) | **Content & player models** | `models/content.py`, `models/player.py`, `models/player_feedback.py`, `blueprints/main.py` | Media model, player model, feedback, dashboard |
|
||||||
| **C6** (51) | **Player management UI** | `blueprints/players.py`, `migrations/add_https_config_table.py` | Player CRUD, manage page, deployment polling |
|
| **C6** (51) | **Player management UI** | `blueprints/players.py`, `migrations/add_https_config_table.py` | Player CRUD, manage page, deployment polling |
|
||||||
| **C7** (42) | **Groups (legacy)** | `models/group.py`, `utils/nginx_config_reader.py` | Archived groups feature + legacy nginx reader |
|
| **C7** (42) | ***(removed)*** | — | Groups model + legacy nginx reader were deleted during sanitization |
|
||||||
| **C8** (24) | **Dev tooling** | `old_code_documentation/test_edit_media_api.py`, `Colors`, integrity checker | Test/analysis utilities |
|
| **C8** (24) | **Dev tooling** | `old_code_documentation/test_edit_media_api.py`, `Colors`, integrity checker | Test/analysis utilities |
|
||||||
| **C9** (21) | **Upload processing** | `utils/uploads.py` | Upload progress, video/image processing |
|
| **C9** (21) | **Upload processing** | `utils/uploads.py` | Upload progress, video/image processing |
|
||||||
| **C10** (20) | **Deployment** | `utils/background_tasks.py`, `utils/ssh_deploy.py` | Background SSH deployment engine |
|
| **C10** (20) | **Deployment** | `utils/background_tasks.py`, `utils/ssh_deploy.py` | Background SSH deployment engine |
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ These are the most-connected nodes (graph centrality). They are the architectura
|
|||||||
|
|
||||||
Graphify groups related code into communities. See [01-architecture.md §3](01-architecture.md#3-component-map-files--communities) for the full mapping. The largest:
|
Graphify groups related code into communities. See [01-architecture.md §3](01-architecture.md#3-component-map-files--communities) for the full mapping. The largest:
|
||||||
|
|
||||||
- **C0 · Auth & logging** (90 nodes) — login/logout/register + audit logging + legacy groups
|
- **C0 · Auth & logging** (90 nodes) — login/logout/register + audit logging
|
||||||
- **C1 · Admin & HTTPS** (80 nodes) — admin panel, Caddy generation, editing users
|
- **C1 · Admin & HTTPS** (80 nodes) — admin panel, Caddy generation, editing users
|
||||||
- **C2 · Playlists & content** (68 nodes) — the modern content/playlist workflow
|
- **C2 · Playlists & content** (68 nodes) — the modern content/playlist workflow
|
||||||
- **C3 · Player API & edits** (62 nodes) — player-facing REST + edited-media pipeline
|
- **C3 · Player API & edits** (62 nodes) — player-facing REST + edited-media pipeline
|
||||||
|
|||||||
+8
-12
@@ -15,7 +15,6 @@ erDiagram
|
|||||||
content ||--o{ player_edit : "edited (cascade)"
|
content ||--o{ player_edit : "edited (cascade)"
|
||||||
content ||--o{ player_feedback : "playing"
|
content ||--o{ player_feedback : "playing"
|
||||||
playlist ||--o{ content : "playlist_content M2M (position,duration,muted,edit_on_player_enabled)"
|
playlist ||--o{ content : "playlist_content M2M (position,duration,muted,edit_on_player_enabled)"
|
||||||
content }o--o{ group : "group_content M2M (legacy)"
|
|
||||||
player_user ||--o{ player_edit : "user_code"
|
player_user ||--o{ player_edit : "user_code"
|
||||||
https_config ||--o| https_config : "singleton row"
|
https_config ||--o| https_config : "singleton row"
|
||||||
```
|
```
|
||||||
@@ -74,8 +73,8 @@ Methods: `is_online` (5-min window), `update_status()`, `set_password()/check_pa
|
|||||||
| `description` | Text | nullable |
|
| `description` | Text | nullable |
|
||||||
| `uploaded_at` | DateTime | NOT NULL, indexed |
|
| `uploaded_at` | DateTime | NOT NULL, indexed |
|
||||||
|
|
||||||
Relationships: `playlists` (M2M via `playlist_content`), `groups` (M2M via `group_content`).
|
Relationships: `playlists` (M2M via `playlist_content`).
|
||||||
Properties/methods: `file_size_mb`, `group_count`, `original_display_name`, `original_media_path`, `current_media_path`, `is_image()/is_video()/is_pdf()/is_weblink()`, `has_player_edits`.
|
Properties/methods: `file_size_mb`, `original_display_name`, `original_media_path`, `current_media_path`, `is_image()/is_video()/is_pdf()/is_weblink()`, `has_player_edits`.
|
||||||
|
|
||||||
### `playlist` — ordered collections of content
|
### `playlist` — ordered collections of content
|
||||||
| Column | Type | Notes |
|
| Column | Type | Notes |
|
||||||
@@ -168,15 +167,13 @@ Classmethods: `log_info`, `log_warning`, `log_error`.
|
|||||||
|
|
||||||
Classmethods: `get_config()` (first row), `create_or_update(...)`. Method: `to_dict()`.
|
Classmethods: `get_config()` (first row), `create_or_update(...)`. Method: `to_dict()`.
|
||||||
|
|
||||||
### `group` + `group_content` — **ARCHIVED / LEGACY**
|
### `group` + `group_content` — **REMOVED**
|
||||||
| Column | Type | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `id` | Integer | PK |
|
|
||||||
| `name` | String(100) | unique, NOT NULL, indexed |
|
|
||||||
| `description` | Text | nullable |
|
|
||||||
| `created_at` / `updated_at` | DateTime | NOT NULL |
|
|
||||||
|
|
||||||
`group_content(group_id, content_id)` composite-PK M2M. The current `Player` model has **no** `group_id` column — group features are archived (see [09 · Legacy](09-legacy-and-migrations.md)).
|
The `Group` model, the `group_content` association table, `Content.groups` /
|
||||||
|
`Content.group_count`, and the group-management utility functions were **deleted**
|
||||||
|
during the code sanitization pass (the feature was archived and the table had no
|
||||||
|
rows). `Player` never had a `group_id` column. See
|
||||||
|
[SANITIZATION-REVIEW.md](SANITIZATION-REVIEW.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -185,7 +182,6 @@ Classmethods: `get_config()` (first row), `create_or_update(...)`. Method: `to_d
|
|||||||
| Relationship | Cardinality | FK / Mechanism |
|
| Relationship | Cardinality | FK / Mechanism |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `playlist` → `content` | M:N | `playlist_content` (positioned, with extras) |
|
| `playlist` → `content` | M:N | `playlist_content` (positioned, with extras) |
|
||||||
| `group` → `content` | M:N | `group_content` (legacy) |
|
|
||||||
| `player` → `playlist` | N:1 | `player.playlist_id` (ON DELETE SET NULL) |
|
| `player` → `playlist` | N:1 | `player.playlist_id` (ON DELETE SET NULL) |
|
||||||
| `player` → `player_feedback` | 1:N | `player_feedback.player_id` (cascade) |
|
| `player` → `player_feedback` | 1:N | `player_feedback.player_id` (cascade) |
|
||||||
| `player` → `player_edit` | 1:N | `player_edit.player_id` (cascade) |
|
| `player` → `player_edit` | 1:N | `player_edit.player_id` (cascade) |
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ def register_blueprints(app):
|
|||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
> Note: `app.blueprints.content_old` is **not** imported — it is dead/legacy code.
|
> Note: `content_old.py` (legacy content routes) was **deleted** during the code
|
||||||
|
> sanitization pass — see [SANITIZATION-REVIEW.md](SANITIZATION-REVIEW.md). The
|
||||||
|
> legacy `playlist.py` blueprint remains (redirect-only).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -153,9 +155,8 @@ app/templates/
|
|||||||
├── admin/ admin.html, user_management.html, leftover_media.html,
|
├── admin/ admin.html, user_management.html, leftover_media.html,
|
||||||
│ dependencies.html, customize_logos.html, editing_users.html,
|
│ dependencies.html, customize_logos.html, editing_users.html,
|
||||||
│ https_config.html, build_player.html
|
│ https_config.html, build_player.html
|
||||||
├── content/ content_list.html (legacy), content_list_new.html (modern),
|
├── content/ content_list_new.html, media_library.html,
|
||||||
│ media_library.html, upload_content.html (legacy),
|
│ upload_media.html, manage_playlist_content.html
|
||||||
│ upload_media.html, manage_playlist_content.html, edit_content.html
|
|
||||||
├── players/ players_list.html, add_player.html, edit_player.html,
|
├── players/ players_list.html, add_player.html, edit_player.html,
|
||||||
│ manage_player.html, player_page.html, player_fullscreen.html,
|
│ manage_player.html, player_page.html, player_fullscreen.html,
|
||||||
│ edited_media.html, edited_media_report.html, _deploy_badge.html
|
│ edited_media.html, edited_media_report.html, _deploy_badge.html
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 05 · Blueprints & REST API
|
# 05 · Blueprints & REST API
|
||||||
|
|
||||||
DigiServer v2 registers **7 active blueprints** (`content_old.py` is legacy dead code and is not registered).
|
DigiServer v2 registers **7 active blueprints**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -203,7 +203,7 @@ Redirects/legacy — kept for compatibility:
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `/player-feedback` | POST | `receive_player_feedback` | Status (playing/paused/error/restarting); infers player; **auto-marks deployment `deployed`** |
|
| `/player-feedback` | POST | `receive_player_feedback` | Status (playing/paused/error/restarting); infers player; **auto-marks deployment `deployed`** |
|
||||||
| `/player-status/<player_id>` | GET | `get_player_status` | Online (5-min), latest feedback |
|
| `/player-status/<player_id>` | GET | `get_player_status` | Online (5-min), latest feedback |
|
||||||
| `/system-info` | GET | — | Counts: players online/total, groups, content, 24h logs |
|
| `/system-info` | GET | — | Counts: players online/total, content, 24h logs |
|
||||||
| `/content` | GET | — | List content with counts |
|
| `/content` | GET | — | List content with counts |
|
||||||
| `/logs` | GET | `get_logs` | Query logs (`limit`/`level`/`since`) |
|
| `/logs` | GET | `get_logs` | Query logs (`limit`/`level`/`since`) |
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ All shared services live in `app/utils/`. This document details each module, its
|
|||||||
| Module | Community | Responsibility | Key symbols |
|
| Module | Community | Responsibility | Key symbols |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `logger.py` | C0 | DB-backed audit logging | `log_action()`, `get_recent_logs()`, `clear_old_logs()` |
|
| `logger.py` | C0 | DB-backed audit logging | `log_action()`, `get_recent_logs()`, `clear_old_logs()` |
|
||||||
| `group_player_management.py` | C0 | Group/player stats (legacy) | `get_player_status_info()`, `assign_player_to_group()`, `get_online_players_count()` |
|
| `group_player_management.py` | C0 | Player status reporting | `get_player_status_info()` |
|
||||||
| `caddy_manager.py` | C1 | HTTPS Caddyfile generation | `CaddyConfigGenerator`, `write_caddyfile()`, `reload_caddy()` |
|
| `caddy_manager.py` | C1 | HTTPS Caddyfile generation | `CaddyConfigGenerator`, `write_caddyfile()`, `reload_caddy()` |
|
||||||
| `background_tasks.py` | C10 | Async task execution | `run_background_task()`, `background_player_deployment()` |
|
| `background_tasks.py` | C10 | Async task execution | `run_background_task()`, `background_player_deployment()` |
|
||||||
| `ssh_deploy.py` | C10 | Remote player provisioning | `deploy_player_to_host()`, `test_ssh_connection()`, `generate_player_config()` |
|
| `ssh_deploy.py` | C10 | Remote player provisioning | `deploy_player_to_host()`, `test_ssh_connection()`, `generate_player_config()` |
|
||||||
@@ -18,7 +18,9 @@ All shared services live in `app/utils/`. This document details each module, its
|
|||||||
| `uploads.py` | C9 | Upload progress + file ops | `get/set/clear_upload_progress()`, `save_uploaded_file()`, `process_video_file()` |
|
| `uploads.py` | C9 | Upload progress + file ops | `get/set/clear_upload_progress()`, `save_uploaded_file()`, `process_video_file()` |
|
||||||
| `portal_sso.py` | C4 | SSO auto-login | `init_portal_sso()`, `_get_or_create_user()` |
|
| `portal_sso.py` | C4 | SSO auto-login | `init_portal_sso()`, `_get_or_create_user()` |
|
||||||
| `script_name_fix.py` | C4 | WSGI sub-path middleware | `ScriptNameFix` |
|
| `script_name_fix.py` | C4 | WSGI sub-path middleware | `ScriptNameFix` |
|
||||||
| `nginx_config_reader.py` | C7 | Legacy nginx status parsing | `NginxConfigReader`, `get_nginx_status()` |
|
|
||||||
|
> `nginx_config_reader.py` (C7) was **removed** during the sanitization pass — the
|
||||||
|
> reverse proxy is Caddy. See [SANITIZATION-REVIEW.md](SANITIZATION-REVIEW.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -81,11 +83,31 @@ Other helpers:
|
|||||||
|
|
||||||
| Method | Purpose |
|
| Method | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `generate_caddyfile(config)` | Pick template by mode: **HTTP-only** (`:80`), **domain** (Let's Encrypt), or **IP** (internal CA self-signed). Includes `reverse_proxy digiserver-app:5000`, 2 GB body limit, gzip, security headers |
|
| `generate_caddyfile(config, http_fallback=True)` | Pick template by mode: **HTTP-only** (`:80`), **domain** (Let's Encrypt), or **IP-only** (internal CA `tls internal`). Includes `reverse_proxy digiserver-app:5000`, 2 GB body limit, gzip, security headers. `http_fallback` also serves plain HTTP alongside internal-CA TLS so clients that cannot trust the local CA still work |
|
||||||
| `write_caddyfile(content, path=/etc/caddy/Caddyfile)` | Write to disk |
|
| `write_caddyfile(content, path=/etc/caddy/Caddyfile)` | Write to disk |
|
||||||
| `reload_caddy()` | POST to Caddy admin API `http://caddy:2019/load` |
|
| `reload_caddy()` | POST to Caddy admin API `http://caddy:2019/load` |
|
||||||
|
|
||||||
Triggered from `admin.update_https_config` after saving `HTTPSConfig`.
|
Triggered from `admin.update_https_config` (Admin UI) **or** `https_manager.py` (CLI),
|
||||||
|
both of which save `HTTPSConfig` first so the two paths stay in sync.
|
||||||
|
|
||||||
|
> **Internal CA vs Let's Encrypt:** an intranet name (e.g. `*.harting.intra`) is not
|
||||||
|
> resolvable publicly, so ACME challenges cannot succeed. Leaving `domain` empty selects
|
||||||
|
> `tls internal`, which needs no DNS and no external service. See
|
||||||
|
> [07 · Deployment §6](07-deployment.md#6-https-setup-caddy).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4b. `https_manager.py` — HTTPS CLI (repo root)
|
||||||
|
|
||||||
|
Command-line equivalent of the Admin HTTPS page, used by `deploy.sh`.
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `enable <hostname> <domain> <email> <ip> [port]` | Persist `HTTPSConfig`, regenerate + write the Caddyfile, hot-reload Caddy. Empty `<domain>` → internal CA. `--redirect-only` to disable the HTTP fallback; `--no-https` for HTTP only |
|
||||||
|
| `disable` | Turn HTTPS off (HTTP only) |
|
||||||
|
| `status` | Print the stored configuration and resolved mode |
|
||||||
|
|
||||||
|
Exit codes: `0` success · `1` bad args/config · `2` config applied but Caddy did not reload.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -131,9 +153,16 @@ Used by the upload pipeline: **PPTX → PDF → PNG slides (Full HD)**.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. `nginx_config_reader.py` — Legacy (informational)
|
## 9. `group_player_management.py` — Player Status
|
||||||
|
|
||||||
`NginxConfigReader` parses an `nginx.conf` and reports `ssl_enabled`, ports, upstreams, `server_names`, `ssl_protocols`, `client_max_body_size`, `gzip`. **Legacy** — the current reverse proxy is Caddy; retained for reference and the old deployment stack.
|
Only `get_player_status_info(player_id)` remains: it returns the online flag
|
||||||
|
(5-minute window), status, last-seen plus a humanised "time ago", and the latest
|
||||||
|
`PlayerFeedback`. Used by `players.list` and `players.manage_player`.
|
||||||
|
|
||||||
|
The group helpers (`get_group_statistics`, `assign_player_to_group`,
|
||||||
|
`bulk_assign_players_to_group`) and the status-list helpers
|
||||||
|
(`get_online_players_count`, `get_players_by_status`) were **removed** with the
|
||||||
|
archived Group subsystem.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+217
-23
@@ -63,40 +63,232 @@ flowchart LR
|
|||||||
|
|
||||||
## 4. `docker-entrypoint.sh`
|
## 4. `docker-entrypoint.sh`
|
||||||
|
|
||||||
1. Create `/app/instance` and `/app/app/static/uploads`.
|
1. **Pin the database**: export `DATABASE_URL` (default `sqlite:////app/instance/dashboard.db`) so migrations and the app resolve to the *same* file. Required because the config classes default to different files (`dev.db` vs `dashboard.db`) and most migration scripts call `create_app()` without an argument (→ development config), while the app runs `create_app('production')`.
|
||||||
2. If `dashboard.db` is missing: create app + `db.create_all()`, then create/update the admin user from `ADMIN_USERNAME` / `ADMIN_PASSWORD`.
|
2. Create `/app/instance` and `/app/app/static/uploads`.
|
||||||
3. Start **Gunicorn**: `--bind 0.0.0.0:5000 --workers 4 --timeout 120 app.app:create_app('production')`.
|
3. Ensure schema + admin user — runs on **every** start (idempotent): `db.create_all()`, then create the admin from `ADMIN_USERNAME` / `ADMIN_PASSWORD` or refresh its password.
|
||||||
|
4. Run the **migration chain** (idempotent, ordered — table-creating migrations run before those that alter them):
|
||||||
|
|
||||||
|
```
|
||||||
|
add_https_config_table.py
|
||||||
|
add_player_user_table.py
|
||||||
|
add_email_to_https_config.py
|
||||||
|
migrate_player_user_global.py
|
||||||
|
add_url_to_content.py
|
||||||
|
add_original_filename_to_content.py
|
||||||
|
add_deployment_fields_to_player.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrations are **non-fatal**: failures are logged as `⚠️ WARNING` and startup continues, so one bad migration can't strand the container in a restart loop.
|
||||||
|
|
||||||
|
5. Start **Gunicorn**: `--bind 0.0.0.0:5000 --workers 4 --timeout 120 app.app:create_app('production')`.
|
||||||
|
|
||||||
|
> Because migrations now run automatically on startup, `deploy.sh` step 4 is redundant (harmless — the scripts are idempotent).
|
||||||
|
|
||||||
|
### Data layout (bind mounts)
|
||||||
|
|
||||||
|
| Host path | Container path | Contents |
|
||||||
|
|---|---|---|
|
||||||
|
| `data/instance` | `/app/instance` | SQLite DB (`dashboard.db`), `player_build.json` |
|
||||||
|
| `data/uploads` | `/app/app/static/uploads` | Media files + `edited_media/` |
|
||||||
|
| `data/Caddyfile` | `/etc/caddy/Caddyfile` | Reverse-proxy config (**a file, not a directory**) |
|
||||||
|
| `data/caddy-data` | `/data` | Caddy state (instance UUID, certificates) |
|
||||||
|
| `data/caddy-config` | `/config` | Caddy autosave |
|
||||||
|
| `data/caddy-logs` | `/var/log/caddy` | Access logs |
|
||||||
|
|
||||||
|
> ⚠️ **`data/Caddyfile` must exist before `docker compose up`.** It is bind-mounted as a *file*;
|
||||||
|
> if it is missing, Docker creates a **directory** in its place and Caddy fails to start.
|
||||||
|
> `deploy.sh` seeds it from the version-controlled `Caddyfile.example`. For a manual start:
|
||||||
|
> ```
|
||||||
|
> mkdir -p data/instance data/uploads data/caddy-data data/caddy-config data/caddy-logs
|
||||||
|
> cp Caddyfile.example data/Caddyfile
|
||||||
|
> docker compose up -d --build
|
||||||
|
> ```
|
||||||
|
|
||||||
|
> ℹ️ The legacy `data/nginx-*` and `data/certbot` folders are **obsolete** — the reverse proxy is
|
||||||
|
> Caddy. They are no longer created by `deploy.sh`.
|
||||||
|
|
||||||
|
### Clean start (wipe all runtime data)
|
||||||
|
|
||||||
|
`data/` is **gitignored**, so wiping it is irreversible. To reset to a pristine deployment:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose down
|
||||||
|
docker rmi digiserver-v2-digiserver-app:latest # drop stale image
|
||||||
|
docker image prune -f && docker builder prune -a -f # reclaim build cache
|
||||||
|
rm -rf data # WIPES db, uploads, certs
|
||||||
|
mkdir -p data/instance data/uploads data/caddy-data data/caddy-config data/caddy-logs
|
||||||
|
cp Caddyfile.example data/Caddyfile
|
||||||
|
./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ Files under `data/caddy-*` are created **root-owned** by the Caddy container, so a plain
|
||||||
|
> `rm -rf data` may fail with *Permission denied*. Remove them via a helper container:
|
||||||
|
> ```
|
||||||
|
> docker run --rm -v "$PWD/data:/data" caddy:2-alpine sh -c 'rm -rf /data/caddy-config /data/caddy-data'
|
||||||
|
> ```
|
||||||
|
> Avoid `docker system prune --volumes` — this host also holds volumes for **other** projects.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. `deploy.sh` (One-Shot Deployment)
|
## 5. `deploy.sh` (One-Shot Deployment)
|
||||||
|
|
||||||
```
|
```
|
||||||
1. Validate compose + project; create data/ subdirs; copy nginx configs
|
1. Detect compose: `docker compose` (plugin) or `docker-compose` (v1 fallback)
|
||||||
2. docker compose up -d + verify containers "Up"
|
— stored in $COMPOSE and used for every subsequent call
|
||||||
3. Run migration scripts (add_https_config_table, add_player_user_table,
|
2. Create data/ subdirs (instance, uploads, caddy-data, caddy-config, caddy-logs)
|
||||||
|
and seed data/Caddyfile from Caddyfile.example
|
||||||
|
3. $COMPOSE up -d + verify containers "Up"
|
||||||
|
4. Run migration scripts (add_https_config_table, add_player_user_table,
|
||||||
add_email_to_https_config, migrate_player_user_global,
|
add_email_to_https_config, migrate_player_user_global,
|
||||||
add_original_filename_to_content)
|
add_original_filename_to_content)
|
||||||
4. Run /app/https_manager.py enable <hostname> <domain> <email> <ip> <port>
|
↳ NOTE: the container entrypoint already applies all seven on startup.
|
||||||
⚠ https_manager.py is NOT in the current repo — this step needs attention
|
This step is idempotent and therefore redundant.
|
||||||
5. Verify DB tables via SQLAlchemy inspector
|
5. /app/https_manager.py enable <hostname> <domain> <email> <ip> <port>
|
||||||
6. caddy validate + https_manager.py status; print access URLs + default creds
|
↳ Exit code 2 ("config applied, Caddy not reloaded") is non-fatal.
|
||||||
|
6. Verify DB tables via SQLAlchemy inspector; caddy validate;
|
||||||
|
https_manager.py status; print access URLs + default creds
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Configuration variables
|
||||||
|
|
||||||
|
| Variable | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `HOSTNAME` | `digiserver` | Display hostname |
|
||||||
|
| `HTTPS_MODE` | `internal` | `internal` \| `acme` \| `off` |
|
||||||
|
| `DOMAIN` | *(empty)* | Required only when `HTTPS_MODE=acme` |
|
||||||
|
| `IP_ADDRESS` | **auto-detected** | Primary LAN IP (override if needed) |
|
||||||
|
| `EMAIL` | `admin@example.com` | ACME account email (unused by internal CA) |
|
||||||
|
| `PORT` | `8443` | Externally published HTTPS port |
|
||||||
|
|
||||||
|
> If `IP_ADDRESS` is unset, `deploy.sh` auto-detects it
|
||||||
|
> (`ip -4 route get 1.1.1.1` → `src` address, falling back to `hostname -I`).
|
||||||
|
> The old hard-coded defaults (`10.76.152.164`, a `.intra` domain) were wrong for
|
||||||
|
> most hosts and have been removed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. HTTPS Setup (Caddy)
|
## 6. HTTPS Setup (Caddy)
|
||||||
|
|
||||||
HTTPS is configured through the **Admin → HTTPS Configuration** page, which:
|
Three equivalent entry points drive the **same** code path
|
||||||
|
(`HTTPSConfig` + `CaddyConfigGenerator`) so CLI, env bootstrap and UI cannot diverge:
|
||||||
|
|
||||||
|
1. **Env bootstrap (deploy time)** — the container entrypoint runs
|
||||||
|
`python /app/https_manager.py bootstrap`, which reads `HOSTNAME_INTERNAL` and
|
||||||
|
`HOST_IP` from the environment and configures Caddy for HTTPS automatically.
|
||||||
|
2. **CLI** — `python /app/https_manager.py enable … | verify | status | disable`
|
||||||
|
3. **UI** — *Admin → HTTPS Configuration* (reloads Caddy live; the ongoing source of truth)
|
||||||
|
|
||||||
|
### Addressing model — one HTTP endpoint, one HTTPS endpoint
|
||||||
|
|
||||||
|
| Port | What it does |
|
||||||
|
|---|---|
|
||||||
|
| **80** | Always answers. A catch-all `:80` block serves **any** Host header, plus explicit blocks for the IP and hostname so both work. |
|
||||||
|
| **443** | HTTPS for the same names, using the internal CA (or ACME for a public domain). |
|
||||||
|
|
||||||
|
If HTTPS is disabled or never configured, port 80 simply serves the app — there is
|
||||||
|
no separate "HTTP mode" to set.
|
||||||
|
|
||||||
|
> ⚠️ **`default_sni` is required for IP access.** Browsers send **no SNI** when the
|
||||||
|
> URL is an IP address (an IP is not a valid SNI hostname). Caddy then identifies the
|
||||||
|
> connection by the container's own internal IP and aborts the handshake with
|
||||||
|
> `no certificate available for '<container-ip>'`. To prevent this, the generator emits
|
||||||
|
> `default_sni <ip>` whenever internal-CA mode is used, so `https://<ip>` works in a
|
||||||
|
> plain browser. This was found by end-to-end testing — see
|
||||||
|
> `docs/tools/test_http_https_runtime.sh`.
|
||||||
|
|
||||||
|
### Mode selection
|
||||||
|
|
||||||
|
| Condition | Result |
|
||||||
|
|---|---|
|
||||||
|
| HTTPS off, or no IP/domain | Plain HTTP on port 80 |
|
||||||
|
| HTTPS on + `ip_address` / `hostname` | `tls internal` per name (no DNS, no ACME) |
|
||||||
|
| HTTPS on + `domain` set | Let's Encrypt for that name |
|
||||||
|
|
||||||
|
### Automatic fallback if HTTPS does not work
|
||||||
|
|
||||||
|
After applying a config, `https_manager.py` probes the HTTPS endpoint
|
||||||
|
(`/api/health`, certificate validation deliberately disabled). If the TLS listener
|
||||||
|
does not come up, the configuration is **automatically reverted to plain HTTP** so a
|
||||||
|
failed certificate can never make the site unreachable:
|
||||||
|
|
||||||
|
```
|
||||||
|
enable HTTPS → reload Caddy → probe https://<host>:<port>/api/health
|
||||||
|
├─ OK → keep HTTPS
|
||||||
|
└─ FAIL → revert to HTTP-only, log a warning
|
||||||
|
```
|
||||||
|
|
||||||
|
Disable the probe with `HTTPS_VERIFY=false` (or `enable --no-verify`).
|
||||||
|
Re-check at any time with `python /app/https_manager.py verify`.
|
||||||
|
|
||||||
|
### Deploy-time bootstrap via `.env`
|
||||||
|
|
||||||
|
Copy `.env.example` → `.env` and set the host address. `docker-compose.yml`
|
||||||
|
forwards these to the app container:
|
||||||
|
|
||||||
|
| Variable | Effect |
|
||||||
|
|---|---|
|
||||||
|
| `HOSTNAME_INTERNAL` | Hostname served (both HTTP and HTTPS) |
|
||||||
|
| `HOST_IP` | IP served and certified |
|
||||||
|
| `DOMAIN` | **Leave empty for an intranet name** → internal CA. Set only for Let's Encrypt |
|
||||||
|
| `SSL_EMAIL` | ACME contact (ignored by internal CA) |
|
||||||
|
| `HTTP_PORT` / `HTTPS_PORT` | Host ports mapped to Caddy's 80/443 (default `80`/`443`) |
|
||||||
|
| `HTTPS_HTTP_FALLBACK` | `true` (default) also serves plain HTTP; `false` redirects instead |
|
||||||
|
| `HTTPS_VERIFY` | `true` (default) probe HTTPS and auto-fall back on failure |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# set HOSTNAME_INTERNAL and HOST_IP
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
> **If either `HOSTNAME_INTERNAL` or `HOST_IP` is missing the bootstrap is a no-op** —
|
||||||
|
> the app starts on plain HTTP and stays reachable. HTTPS can then be enabled from
|
||||||
|
> *Admin → HTTPS Configuration*, which regenerates the Caddyfile and reloads Caddy
|
||||||
|
> **without a restart**.
|
||||||
|
|
||||||
|
### Who owns the config (env vs admin UI)
|
||||||
|
|
||||||
|
The admin UI is the **ongoing source of truth**. The bootstrap runs on every container
|
||||||
|
start, so it guards against silently overwriting an admin's change by tracking
|
||||||
|
provenance in `HTTPSConfig.updated_by`:
|
||||||
|
|
||||||
|
| Current config | Bootstrap behaviour |
|
||||||
|
|---|---|
|
||||||
|
| *(none — first deploy)* | Apply from env ✅ |
|
||||||
|
| Written by env/CLI (`updated_by='deploy.sh'`) | Apply from env ✅ — so editing `HOST_IP` and redeploying works |
|
||||||
|
| Written by a user (`updated_by='<username>'`) | **Skip** — the admin's setting is preserved |
|
||||||
|
|
||||||
|
So an admin change made in the UI survives restarts even while the env vars remain set.
|
||||||
|
|
||||||
|
### Why internal CA (and not Let's Encrypt) for `.intra`
|
||||||
|
|
||||||
|
An intranet name such as `digiserver.sibiusb.harting.intra` is **not resolvable from the
|
||||||
|
public internet**, so Let's Encrypt's HTTP-01/TLS-ALPN challenge cannot succeed. Setting
|
||||||
|
`DOMAIN=` empty makes Caddy sign the certificate itself with its local CA — no external
|
||||||
|
dependency at all.
|
||||||
|
|
||||||
|
**Trust caveat:** the internal CA is not in any client trust store, so browsers show a
|
||||||
|
warning and a Kivy player with `verify_ssl: true` will **reject the connection**. Options:
|
||||||
|
|
||||||
|
1. **Use HTTP** — port 80 always serves the app, so players need no trust configuration.
|
||||||
|
2. **Install the root CA** on each device:
|
||||||
|
```
|
||||||
|
docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./caddy-root.crt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ports
|
||||||
|
|
||||||
|
| Host → Container | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `80 → 80` | HTTP (always available) |
|
||||||
|
| `443 → 443` | HTTPS |
|
||||||
|
| `5000 → 5000` | Direct app access (bypasses Caddy; dev/testing) |
|
||||||
|
|
||||||
|
> Ports are configurable via `HTTP_PORT`/`HTTPS_PORT` so the stack also works where
|
||||||
|
> 80/443 are already taken (e.g. `HTTP_PORT=8080 HTTPS_PORT=8443`).
|
||||||
|
|
||||||
|
|
||||||
1. Saves `HTTPSConfig` (hostname, domain, IP, email, port, enabled).
|
|
||||||
2. `CaddyConfigGenerator.generate_caddyfile(config)` picks a template:
|
|
||||||
- **HTTP-only** → `:80` reverse proxy
|
|
||||||
- **Domain** → automatic Let's Encrypt
|
|
||||||
- **IP** → internal CA self-signed
|
|
||||||
3. Writes `/etc/caddy/Caddyfile` and reloads Caddy via `POST http://caddy:2019/load`.
|
|
||||||
|
|
||||||
The current `data/Caddyfile` (HTTP mode) includes: admin API on `0.0.0.0:2019`, `:80` block → `digiserver-app:5000`, 2 GB body limit, gzip, security headers, access log.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -105,17 +297,19 @@ The current `data/Caddyfile` (HTTP mode) includes: admin API on `0.0.0.0:2019`,
|
|||||||
Sections checked (pass/fail/warn counters):
|
Sections checked (pass/fail/warn counters):
|
||||||
- git status
|
- git status
|
||||||
- `.env` / `.env.example`
|
- `.env` / `.env.example`
|
||||||
- Docker + Compose versions + `compose config` syntax
|
- Docker + Compose versions (plugin **or** v1) + `compose config` syntax
|
||||||
- Dockerfile best practices (HEALTHCHECK, non-root, slim base)
|
- Dockerfile best practices (HEALTHCHECK, non-root, slim base)
|
||||||
- `requirements.txt` critical packages + versions
|
- `requirements.txt` critical packages + versions
|
||||||
- migrations directory
|
- migrations directory
|
||||||
- **SSL cert expiry** (openssl)
|
- **TLS certificate** — Caddy internal CA expiry (`data/caddy-data/caddy/pki/authorities/local/root.crt`)
|
||||||
- Flask config (`ProductionConfig`, `SESSION_COOKIE_SECURE`)
|
- Flask config (`ProductionConfig`, `SESSION_COOKIE_SECURE`)
|
||||||
- nginx.conf checks
|
- `data/Caddyfile` checks (reverse_proxy, admin API, TLS mode)
|
||||||
- runtime container health
|
- runtime container health + live HTTP/HTTPS endpoint probes
|
||||||
- security best practices
|
- security best practices
|
||||||
|
|
||||||
> ⚠ Note: the script still references `docker-compose` (v1) and `digiserver-nginx` — the current stack uses Compose v2 + Caddy.
|
> The script now detects `docker compose` (plugin) or `docker-compose` (v1) and warns when
|
||||||
|
> buildx is too old for compose v1 builds — matching `deploy.sh`, which then falls back to
|
||||||
|
> `docker build`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -33,21 +33,24 @@ add_original_filename_to_content.py
|
|||||||
|
|
||||||
| Component | Status | Notes |
|
| Component | Status | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `app/blueprints/content_old.py` | **Dead code** | Legacy per-player content routes (`/`, `/upload`, `/<id>/edit`, `/bulk/delete`, `/upload-progress`, `/preview`, `/statistics`, `/check-duplicates`, `/<id>/groups`). Not imported by `create_app`. |
|
| `app/blueprints/content_old.py` | **DELETED** | Legacy per-player content routes. Removed in the sanitization pass together with its templates (`content_list.html`, `edit_content.html`, `upload_content.html`). |
|
||||||
| `app/blueprints/playlist.py` | **Active but legacy** | Per-player playlist routes kept as redirects to the modern content workflow. |
|
| `app/blueprints/playlist.py` | **Active but legacy** | Per-player playlist routes kept as redirects to the modern content workflow. |
|
||||||
| `Group` model + group routes | **Archived** | `Player` no longer has `group_id`; group routes commented out; `group_player_management.py` and `group_content` association remain for reference. |
|
| `Group` model + group routes | **DELETED** | `models/group.py`, the `group_content` association, `Content.groups` / `Content.group_count`, and the group utility functions were removed. `Player` never had a `group_id` column. |
|
||||||
| `nginx` stack | **Replaced by Caddy** | `data/nginx.conf`, `data/nginx-custom-domains.conf`, `data/nginx-logs/`, `data/nginx-ssl/` retained. `utils/nginx_config_reader.py` still parses it. |
|
| `utils/nginx_config_reader.py` | **DELETED** | Legacy nginx parsing — the reverse proxy is Caddy. |
|
||||||
|
| `nginx` stack | **Replaced by Caddy** | `data/nginx.conf`, `data/nginx-custom-domains.conf`, `data/nginx-logs/`, `data/nginx-ssl/` removed with the Caddy migration. `migrate_network.sh` no longer generates self-signed certs — Caddy issues them. |
|
||||||
| `https_manager.py` | **Missing** | Referenced by `deploy.sh` but not in repo — likely merged into `CaddyConfigGenerator`. |
|
| `https_manager.py` | **Missing** | Referenced by `deploy.sh` but not in repo — likely merged into `CaddyConfigGenerator`. |
|
||||||
| `old_code_documentation/` | **Archive** | Full legacy docs, old scripts (`blueprint_groups.py`, `add_muted_column.py`, `fix_player_user_schema.py`, `test_edit_media_*.py`, `check_fix_player.py`, `migrate_add_edit_enabled.py`), deployment guides, HTTPS analysis, player analysis. |
|
| `old_code_documentation/` | **Archive** | Full legacy docs, old scripts (`blueprint_groups.py`, `add_muted_column.py`, `fix_player_user_schema.py`, `test_edit_media_*.py`, `check_fix_player.py`, `migrate_add_edit_enabled.py`), deployment guides, HTTPS analysis, player analysis. |
|
||||||
| `QUICK_DEPLOYMENT.md`, `deployment-commands-reference.sh` | **Active reference** | Manual deployment notes. |
|
| `QUICK_DEPLOYMENT.md`, `deployment-commands-reference.sh` | **Active reference** | Manual deployment notes. |
|
||||||
|
| `docs/legacy code/` | **Snapshot** | Full pre-sanitization copy of the codebase. Excluded from the Docker build via `.dockerignore`. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Recommended Cleanup (optional)
|
## 3. Recommended Cleanup (optional)
|
||||||
|
|
||||||
- Remove `content_old.py` and `old_code_documentation/*.py` scripts that are no longer needed (keep the `.md` docs).
|
- Remove `old_code_documentation/*.py` scripts that are no longer needed (keep the `.md` docs).
|
||||||
- Resolve the missing `https_manager.py` in `deploy.sh` (use `CaddyConfigGenerator` equivalents).
|
- Resolve the missing `https_manager.py` in `deploy.sh` — **done**: `https_manager.py` now exists at the repo root.
|
||||||
- Update `verify-deployment.sh` to reference Compose v2 and Caddy instead of `docker-compose`/nginx.
|
- Update `verify-deployment.sh` to reference Caddy instead of nginx — **done**.
|
||||||
|
- Consider removing the legacy `playlist.py` blueprint (see [SANITIZATION-REVIEW.md](SANITIZATION-REVIEW.md) batch B1).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -51,7 +51,7 @@ graphify-out/
|
|||||||
| Application type | Digital signage content/playlist/player management |
|
| Application type | Digital signage content/playlist/player management |
|
||||||
| Graph size | **631 nodes · 1162 edges · 41 communities** |
|
| Graph size | **631 nodes · 1162 edges · 41 communities** |
|
||||||
| Blueprints | 7 active (`main`, `auth`, `admin`, `players`, `content`, `playlist`, `api`) |
|
| Blueprints | 7 active (`main`, `auth`, `admin`, `players`, `content`, `playlist`, `api`) |
|
||||||
| Database tables | 10 (`user`, `player`, `player_edit`, `player_feedback`, `player_user`, `content`, `group`, `playlist`, `server_log`, `https_config`) |
|
| Database tables | 9 (`user`, `player`, `player_edit`, `player_feedback`, `player_user`, `content`, `playlist`, `server_log`, `https_config`) |
|
||||||
| Reverse proxy | Caddy 2 (automatic HTTPS / Let's Encrypt) |
|
| Reverse proxy | Caddy 2 (automatic HTTPS / Let's Encrypt) |
|
||||||
| Deployment | Docker Compose (app + Caddy) + remote SSH player provisioning |
|
| Deployment | Docker Compose (app + Caddy) + remote SSH player provisioning |
|
||||||
| Key externals | LibreOffice, Poppler (pdf2image), FFmpeg, sshpass/rsync |
|
| Key externals | LibreOffice, Poppler (pdf2image), FFmpeg, sshpass/rsync |
|
||||||
@@ -100,7 +100,6 @@ erDiagram
|
|||||||
content ||--o{ player_edit : "cascade"
|
content ||--o{ player_edit : "cascade"
|
||||||
content ||--o{ player_feedback : ""
|
content ||--o{ player_feedback : ""
|
||||||
playlist ||--o{ content : "playlist_content (M2M)"
|
playlist ||--o{ content : "playlist_content (M2M)"
|
||||||
content }o--o{ group : "group_content (M2M)"
|
|
||||||
player_user ||--o{ player_edit : "user_code"
|
player_user ||--o{ player_edit : "user_code"
|
||||||
https_config ||--|| https_config : "single row config"
|
https_config ||--|| https_config : "single row config"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# DigiServer v2 — Code Sanitization Review
|
||||||
|
|
||||||
|
**Generated:** 2026-09-10
|
||||||
|
**Snapshot:** `docs/legacy code/` (1.7 MB, 171 files, 49 Python files) — full restore point.
|
||||||
|
|
||||||
|
Analysed **42 Python files / 240 functions / 104 routes / 32 templates** under `app/` and `migrations/`.
|
||||||
|
|
||||||
|
> Review each section below and reply with the IDs you want deleted (e.g. `A1, A2, B1`).
|
||||||
|
> Nothing is deleted until you confirm. Everything is recoverable from `docs/legacy code/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Status — Applied 2026-09-10
|
||||||
|
|
||||||
|
**Removed (A1, A2, D1, D2):**
|
||||||
|
|
||||||
|
| ID | Removed | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| A1 | `app/blueprints/content_old.py` | + 3 templates orphaned by its removal |
|
||||||
|
| A2 | `app/utils/nginx_config_reader.py` | |
|
||||||
|
| D1 | 5 group functions + their `__init__.py` exports | `get_player_status_info` kept (live) |
|
||||||
|
| D2 | `app/models/group.py`, `Content.groups`, `Content.group_count` | |
|
||||||
|
|
||||||
|
**Extra cleanup triggered by A1/D2:**
|
||||||
|
- Deleted orphaned templates: `upload_content.html` (278), `edit_content.html` (11)
|
||||||
|
- Removed the dead `groups` key from `/api/system-info` and `group_count` from `/api/content`
|
||||||
|
- Removed the `'group_id': getattr(player, 'group_id', None)` compat shim from `/api/player-status`
|
||||||
|
- Updated 8 documentation files
|
||||||
|
|
||||||
|
**Also removed (B1, B2) — applied in a second pass:**
|
||||||
|
|
||||||
|
| ID | Removed | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| B1 | `app/blueprints/playlist.py` (310 LOC) + its registration in `app.py` | Whole legacy blueprint; its only real route redirected to `content.manage_playlist_content` |
|
||||||
|
| B2 | 3 routes in `players.py`: `reorder_content`, `reorder_playlist`, `remove_from_playlist` (~103 LOC) | Two queried nonexistent columns (`Content.player_id`, `Content.position`, `Player.playlist_version`) → guaranteed 500s |
|
||||||
|
|
||||||
|
**Extra cleanup triggered by B1:**
|
||||||
|
- Deleted `content_list.html` (202 lines) — its only remaining reference was `url_for('playlist.manage_playlist')`. It was **already orphaned** (no Python file rendered it) after `content_old.py` was deleted, so it has been removed for real this time.
|
||||||
|
- Deleted `players/player_page.html` (227 lines) — it was **never rendered** by any view (the `players.player_page` route redirects to `manage_player`), so it was dead UI. Corrects the earlier "false positive" note below.
|
||||||
|
|
||||||
|
> ⚠️ **Functional note:** `players.regenerate_auth_code` (`POST /players/<id>/regenerate-auth`) is now
|
||||||
|
> referenced by **no template** — `player_page.html` was its only caller. The endpoint still works if
|
||||||
|
> invoked directly. The equivalent control lives in `manage_player.html` via the quickconnect flow.
|
||||||
|
> Restore `player_page.html` from `docs/legacy code/` if you want that button back.
|
||||||
|
|
||||||
|
**Result (A + B + D combined):**
|
||||||
|
```
|
||||||
|
42 → 38 Python modules 9,311 → 8,296 LOC
|
||||||
|
104 → 82 routes 32 → 28 templates
|
||||||
|
7 → 6 blueprints dead modules: 2 → 0, orphan templates: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verified:** all files compile; smoke test passes on a fresh DB (every API endpoint + key UI route returns
|
||||||
|
< 500); `db.metadata` no longer registers `group`/`group_content`; app boots against a migrated copy of the
|
||||||
|
real `dashboard.db` with all data intact; `app.blueprints` no longer contains `playlist`.
|
||||||
|
|
||||||
|
> ⚠️ **Still open:** the real `data/instance/dashboard.db` predates the `original_filename` migration.
|
||||||
|
> On startup the entrypoint now applies it automatically. To fix locally, run:
|
||||||
|
> ```
|
||||||
|
> DATABASE_URL=sqlite:///$PWD/data/instance/dashboard.db ./.venv/bin/python migrations/add_original_filename_to_content.py
|
||||||
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section A — Dead modules (zero importers)
|
||||||
|
|
||||||
|
| ID | Target | LOC | Evidence | Risk |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **A1** | `app/blueprints/content_old.py` | 500 | `app.py` imports `content.py`; this file's `content_bp` is **never registered**. Superseded "old" content workflow. | **Low** |
|
||||||
|
| **A2** | `app/utils/nginx_config_reader.py` | 120 | Never imported anywhere. Stack migrated nginx → Caddy, so the reader is obsolete. | **Low** |
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
app_py["app.py<br/>register_blueprints()"] --> content["content.py<br/>content_bp ✅ ACTIVE"]
|
||||||
|
content_old["content_old.py<br/>content_bp ❌ DEAD"] -.->|never imported| x1[" "]
|
||||||
|
nginx["nginx_config_reader.py<br/>❌ DEAD"] -.->|never imported| x2[" "]
|
||||||
|
caddy["caddy_manager.py<br/>✅ ACTIVE"] --> app_py
|
||||||
|
style content_old fill:#7f1d1d,color:#fff
|
||||||
|
style nginx fill:#7f1d1d,color:#fff
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section B — Legacy duplicate route surface — ✅ **REMOVED**
|
||||||
|
|
||||||
|
Two blueprints exposed **parallel implementations of the same operations**. Only the `content.*`
|
||||||
|
versions were wired to the UI; the legacy ones had no template references.
|
||||||
|
|
||||||
|
| ID | Target | LOC | Evidence | Risk |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **B1** ✅ | `app/blueprints/playlist.py` (whole file + registration) | 310 | Entire file was legacy per-player playlist. Its `manage_playlist` route did nothing but **redirect** to the modern `content.manage_playlist_content`. The other 6 routes had **no template reference**. | **Low–Med** |
|
||||||
|
| **B2** ✅ | 3 routes in `players.py`: `reorder_content`, `reorder_playlist`, `remove_from_playlist` | ~103 | Superseded by `content.*`. Two queried nonexistent columns (`Content.player_id`, `.position`, `Player.playlist_version`) → guaranteed HTTP 500 if called. | **Low** |
|
||||||
|
|
||||||
|
**Duplicate operation matrix**
|
||||||
|
|
||||||
|
| Operation | Modern (LIVE) | Legacy (DEAD) |
|
||||||
|
|---|---|---|
|
||||||
|
| Add content | `content.add_content_to_playlist` | `playlist.add_to_playlist` |
|
||||||
|
| Remove content | `content.remove_content_from_playlist` | `playlist.remove_from_playlist`, `players.remove_from_playlist` ⚠️broken |
|
||||||
|
| Reorder | `content.reorder_playlist_content` | `playlist.reorder_playlist`, `players.reorder_content` ⚠️broken, `players.reorder_playlist` ⚠️broken |
|
||||||
|
| Set duration | `content.update_playlist_content_duration` | `playlist.update_duration` |
|
||||||
|
| Mute audio | `content.update_playlist_content_muted` | `playlist.update_muted` |
|
||||||
|
| Toggle edit | `content.update_playlist_content_edit_enabled` | — |
|
||||||
|
| Clear | — | `playlist.clear_playlist` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section C — Broken code: references to columns that do not exist
|
||||||
|
|
||||||
|
Confirmed against the live DB schema. These raised `AttributeError`/`OperationalError` at runtime.
|
||||||
|
**All resolved by deleting A1 + B2.**
|
||||||
|
|
||||||
|
| ID | Location | Broken reference | Reachable? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **C1** ✅ | `players.py:768,775` (`remove_from_playlist`) | `player.playlist_version` | Via route only (no UI link) |
|
||||||
|
| **C2** ✅ | `players.py:~700` (`reorder_playlist`) | `Content.player_id`, `Content.position` | Via route only (no UI link) |
|
||||||
|
| **C3** | `content_old.py:189-190` | `player.playlist_version` | No (dead module) |
|
||||||
|
| **C4** | `content_old.py:50,57` | `content.player_id`, `player.group` | No (dead module) |
|
||||||
|
|
||||||
|
> ✅ **Resolved by deleting A1 + B2.** If you keep those files, they must be rewritten.
|
||||||
|
|
||||||
|
**Not a bug (verified by hand):** `Content._playlist_duration` /
|
||||||
|
`._playlist_position` / `._playlist_muted` in `api.py` are set **dynamically** by
|
||||||
|
`Playlist.get_content_ordered()`. These are intentional and work correctly — the
|
||||||
|
static analyzer flags them because it only sees model class attributes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section D — Legacy groups subsystem
|
||||||
|
|
||||||
|
Groups are fully deprecated (0 rows, `/api/groups` already commented out), but the code lingers.
|
||||||
|
|
||||||
|
| ID | Target | LOC | Evidence | Risk |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **D1** | 5 group functions in `app/utils/group_player_management.py`: `get_group_statistics`, `assign_player_to_group`, `bulk_assign_players_to_group`, `get_online_players_count`, `get_players_by_status` + their `__init__.py` exports | ~130 | **Zero callers outside the module.** All three group functions reference `player.group_id`, **which does not exist** → broken. | **Low** |
|
||||||
|
| **D2** | `app/models/group.py` (Group model + `group_content` table) | 71 | Retained only because `Content.groups` FK relationship and `Content.group_count` reference it. Requires touching the Content model. | **Medium** |
|
||||||
|
|
||||||
|
> ⚠️ **Keep:** `get_player_status_info()` (top of the same file) is **live** — used at
|
||||||
|
> `players.py:28` and `players.py:432`. Only the group functions should go.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section E — Legacy redirect stubs
|
||||||
|
|
||||||
|
Thin compatibility shims that only redirect to the modern UI. They're harmless but keep dead
|
||||||
|
URL surface alive.
|
||||||
|
|
||||||
|
| ID | Target | Evidence |
|
||||||
|
|---|---|---|
|
||||||
|
| **E1** | `players.player_page` (`/players/<id>`) | Body is a single `redirect(url_for('players.manage_player'))`. Still `url_for`-referenced by 2 templates, so keeping it is fine. |
|
||||||
|
| **E2** | `playlist.manage_playlist` (`/playlist/<id>`) | Part of B1 — already covered by deleting that file. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section F — Orphan templates / assets
|
||||||
|
|
||||||
|
| ID | Target | LOC | Evidence |
|
||||||
|
|---|---|---|---|
|
||||||
|
| — | (none) | — | After the B1/B2 pass the codebase has **0 orphan templates**. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended batches — ✅ **ALL APPLIED**
|
||||||
|
|
||||||
|
| Batch | Contents | Total removed | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Batch 1 — Safe clean** | **A1, A2** | ~620 LOC | ✅ done |
|
||||||
|
| **Batch 2 — Legacy playlist** | **B1, B2** | ~413 LOC | ✅ done |
|
||||||
|
| **Batch 3 — Groups** | **D1** | ~130 LOC | ✅ done |
|
||||||
|
| **Batch 4 — Group model** | **D2** | ~71 LOC | ✅ done |
|
||||||
|
|
||||||
|
**Every batch was followed by:** compile-all + app-factory boot + route smoke test, so hidden
|
||||||
|
dependencies were caught before moving on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How I verified this (so you can trust it)
|
||||||
|
|
||||||
|
| Check | Method |
|
||||||
|
|---|---|
|
||||||
|
| Module reachability | AST import extraction + `register_blueprint` cross-reference |
|
||||||
|
| Route usage | `url_for('endpoint')` **and** literal path matching against all templates/JS |
|
||||||
|
| Broken columns | Compared every `var.attr` access against live SQLite `PRAGMA table_info` |
|
||||||
|
| Dynamic attributes | Manually inspected `get_content_ordered()` to rule out false positives |
|
||||||
|
| Duplicate bodies | `ast.dump` body hashing across all functions (result: 0 exact duplicates) |
|
||||||
|
|
||||||
|
**Reproduce anytime:**
|
||||||
|
```
|
||||||
|
./.venv/bin/python docs/tools/sanitize_report.py # dead code + broken refs
|
||||||
|
./.venv/bin/python docs/tools/sanitize_audit.py # full function inventory
|
||||||
|
./.venv/bin/python docs/tools/sanitize_templates.py # orphan templates
|
||||||
|
./.venv/bin/python docs/tools/smoke_test.py # post-change smoke test
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Rollback / hygiene notes
|
||||||
|
|
||||||
|
1. **`docs/legacy code/` is excluded from the Docker image** (via `legacy code/` and
|
||||||
|
`**/legacy code/` in `.dockerignore`) so it never
|
||||||
|
ships to production or bloats the build.
|
||||||
|
2. It is **not** git-ignored, so it will appear in `git status`. Decide:
|
||||||
|
- commit it as a recovery point, or
|
||||||
|
- add `legacy code/` to `.gitignore` if you'd rather rely on git history.
|
||||||
|
3. Deleting files listed here is **not** recoverable from git unless committed first — the
|
||||||
|
`docs/legacy code/` copy is your safety net.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user