feat: add weblink playlists, SSH player deployment, Caddy HTTPS, build player page
- Content model: add url column + is_weblink() for web page content items - Player model: add deployment tracking fields (status, timestamps, message) - Content blueprint: add add_weblink and add_weblink_to_playlist routes; weblinks auto-deleted when removed from playlist - Players blueprint: add SSH deploy mode in add_player; weblink-aware playlist - API blueprint: weblink URL served directly in playlist response; add /api/deploy/test-ssh and /api/deploy/player endpoints - Admin blueprint: add build-player page (clone from Gitea, write base config); replace nginx status card with Caddy status on HTTPS config page - caddy_manager: rewritten to generate proper HTTPS/internal-CA Caddyfiles and reload Caddy via admin API (/load) for live config updates - ssh_deploy, background_tasks, player_build: new utils for SSH deployment - background_tasks: push Flask app context into background thread so DB updates after deployment complete correctly - ssh_deploy: robust install script detection with passwordless sudo injection (uses SSH credentials, cleaned up after install) - Dockerfile: add git, sshpass, openssh-client, rsync - docker-compose: switch nginx to Caddy on ports 80/443; add port 5000 for dev - Templates: add_player deploy mode UI, weblink form in playlist/upload pages, build_player admin page, Caddy status on HTTPS config page - Migrations: add_url_to_content, add_deployment_fields_to_player - app.py: call db.create_all() on startup for schema bootstrap - config.py: add PLAYER_CODE_DIR and PLAYER_REPO_URL settings
This commit is contained in:
+147
-6
@@ -11,7 +11,6 @@ from app.extensions import db, bcrypt
|
||||
from app.models import User, Player, Content, ServerLog, Playlist, HTTPSConfig
|
||||
from app.utils.logger import log_action
|
||||
from app.utils.caddy_manager import CaddyConfigGenerator
|
||||
from app.utils.nginx_config_reader import get_nginx_status
|
||||
|
||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
@@ -871,14 +870,10 @@ def https_config():
|
||||
db.session.commit()
|
||||
log_action('info', f'HTTPS status auto-corrected to enabled (detected from request)')
|
||||
|
||||
# Get Nginx configuration status
|
||||
nginx_status = get_nginx_status()
|
||||
|
||||
return render_template('admin/https_config.html',
|
||||
config=config,
|
||||
is_https_active=is_https_active,
|
||||
current_host=current_host,
|
||||
nginx_status=nginx_status)
|
||||
current_host=current_host)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading HTTPS config page: {str(e)}')
|
||||
flash('Error loading HTTPS configuration page.', 'danger')
|
||||
@@ -1015,3 +1010,149 @@ def https_config_status():
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting HTTPS status: {str(e)}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Player Build & Deployment Routes
|
||||
# ============================================================================
|
||||
|
||||
def _player_build_meta_path() -> str:
|
||||
"""Path to the persisted player-build settings (in the instance folder)."""
|
||||
from app.utils.player_build import BUILD_META_FILENAME
|
||||
return os.path.join(current_app.instance_path, BUILD_META_FILENAME)
|
||||
|
||||
|
||||
@admin_bp.route('/build-player', methods=['GET'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def build_player():
|
||||
"""Display the 'Build player files for deployment' admin page."""
|
||||
from app.utils.ssh_deploy import get_local_player_code_status
|
||||
from app.utils.player_build import load_build_settings
|
||||
|
||||
player_code_dir = current_app.config['PLAYER_CODE_DIR']
|
||||
settings = load_build_settings(_player_build_meta_path()) or {}
|
||||
|
||||
# Prefill server address from saved settings, else from HTTPS config.
|
||||
if not settings.get('server_ip'):
|
||||
https_cfg = HTTPSConfig.get_config()
|
||||
if https_cfg and (https_cfg.domain or https_cfg.ip_address):
|
||||
settings.setdefault('server_ip', https_cfg.domain or https_cfg.ip_address)
|
||||
settings.setdefault('port', str(https_cfg.port or 443))
|
||||
settings.setdefault('use_https', bool(https_cfg.https_enabled))
|
||||
|
||||
# Sensible defaults.
|
||||
settings.setdefault('repo_url', current_app.config.get('PLAYER_REPO_URL', ''))
|
||||
settings.setdefault('branch', 'main')
|
||||
default_host = request.host.split(':')[0]
|
||||
if default_host in ('localhost', '127.0.0.1', '') or default_host.startswith('127.'):
|
||||
from app.utils.ssh_deploy import detect_server_ip
|
||||
default_host = detect_server_ip() or default_host
|
||||
settings.setdefault('server_ip', default_host)
|
||||
settings.setdefault('port', '443')
|
||||
settings.setdefault('use_https', True)
|
||||
settings.setdefault('verify_ssl', False)
|
||||
settings.setdefault('orientation', 'Landscape')
|
||||
settings.setdefault('max_resolution', '1920x1080')
|
||||
|
||||
code_status = get_local_player_code_status(player_code_dir)
|
||||
if code_status.get('updated'):
|
||||
code_status['updated_str'] = datetime.fromtimestamp(
|
||||
code_status['updated']).strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
return render_template(
|
||||
'admin/build_player.html',
|
||||
settings=settings,
|
||||
code_status=code_status,
|
||||
player_code_dir=player_code_dir,
|
||||
)
|
||||
|
||||
|
||||
@admin_bp.route('/build-player', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def build_player_action():
|
||||
"""Build/refresh the staged player code and/or write its base config."""
|
||||
from app.utils.player_build import (
|
||||
build_player_files, write_base_config, get_short_head,
|
||||
save_build_settings, make_build_record,
|
||||
)
|
||||
|
||||
player_code_dir = current_app.config['PLAYER_CODE_DIR']
|
||||
action = request.form.get('action', 'build_and_config')
|
||||
|
||||
repo_url = request.form.get('repo_url', '').strip()
|
||||
branch = request.form.get('branch', 'main').strip() or 'main'
|
||||
server_ip = request.form.get('server_ip', '').strip()
|
||||
port = request.form.get('port', '443').strip()
|
||||
use_https = request.form.get('use_https') == 'on'
|
||||
verify_ssl = request.form.get('verify_ssl') == 'on'
|
||||
orientation = request.form.get('orientation', 'Landscape').strip() or 'Landscape'
|
||||
max_resolution = request.form.get('max_resolution', '1920x1080').strip() or '1920x1080'
|
||||
|
||||
# Validation
|
||||
errors = []
|
||||
if action in ('build_files', 'build_and_config') and not repo_url:
|
||||
errors.append('Repository URL is required to build player files.')
|
||||
if action in ('save_config', 'build_and_config') and not server_ip:
|
||||
errors.append('Server IP / domain is required for the player configuration.')
|
||||
try:
|
||||
port_num = int(port)
|
||||
if port_num < 1 or port_num > 65535:
|
||||
errors.append('Port must be between 1 and 65535.')
|
||||
except ValueError:
|
||||
errors.append('Port must be a valid number.')
|
||||
|
||||
if errors:
|
||||
for err in errors:
|
||||
flash(err, 'warning')
|
||||
return redirect(url_for('admin.build_player'))
|
||||
|
||||
messages = []
|
||||
success = True
|
||||
version = None
|
||||
|
||||
# Step 1: build/refresh files from the repository.
|
||||
if action in ('build_files', 'build_and_config'):
|
||||
result = build_player_files(player_code_dir, repo_url, branch)
|
||||
version = result.get('version')
|
||||
messages.append(result['message'])
|
||||
if not result['success']:
|
||||
success = False
|
||||
log_action('error', f'Player build failed by {current_user.username}: {result["message"]}')
|
||||
|
||||
# Step 2: write the base config (only if the previous step didn't fail).
|
||||
if success and action in ('save_config', 'build_and_config'):
|
||||
cfg_result = write_base_config(
|
||||
player_code_dir=player_code_dir,
|
||||
server_ip=server_ip,
|
||||
port=port,
|
||||
use_https=use_https,
|
||||
verify_ssl=verify_ssl,
|
||||
orientation=orientation,
|
||||
max_resolution=max_resolution,
|
||||
)
|
||||
messages.append(cfg_result['message'])
|
||||
if not cfg_result['success']:
|
||||
success = False
|
||||
|
||||
# Persist settings so deployment uses the same server address.
|
||||
if version is None:
|
||||
version = get_short_head(player_code_dir)
|
||||
save_build_settings(
|
||||
_player_build_meta_path(),
|
||||
make_build_record(
|
||||
repo_url=repo_url, branch=branch, server_ip=server_ip, port=port,
|
||||
use_https=use_https, verify_ssl=verify_ssl, orientation=orientation,
|
||||
max_resolution=max_resolution, version=version, built_by=current_user.username,
|
||||
),
|
||||
)
|
||||
|
||||
summary = ' '.join(messages) if messages else 'No action performed.'
|
||||
if success:
|
||||
log_action('info', f'Player files built by {current_user.username} (version {version})')
|
||||
flash(f'✅ {summary}', 'success')
|
||||
else:
|
||||
flash(f'⚠️ {summary}', 'danger')
|
||||
|
||||
return redirect(url_for('admin.build_player'))
|
||||
|
||||
+126
-3
@@ -3,6 +3,7 @@ from flask import Blueprint, request, jsonify, current_app
|
||||
from functools import wraps
|
||||
from datetime import datetime, timedelta
|
||||
import secrets
|
||||
import hashlib
|
||||
import bcrypt
|
||||
from typing import Optional, Dict, List
|
||||
|
||||
@@ -396,9 +397,13 @@ def get_cached_playlist(player_id: int) -> List[Dict]:
|
||||
for idx, content in enumerate(content_list, start=1):
|
||||
# Generate full URL for content
|
||||
from flask import request as current_request
|
||||
# Get server base URL
|
||||
server_base = current_request.host_url.rstrip('/')
|
||||
content_url = f"{server_base}/static/uploads/{content.filename}"
|
||||
script_root = current_request.script_root.rstrip('/')
|
||||
content_url = f"{server_base}{script_root}/static/uploads/{content.filename}"
|
||||
|
||||
# Web links carry the page URL directly instead of a file download URL.
|
||||
is_weblink = content.content_type == 'weblink'
|
||||
item_url = content.url if is_weblink else content_url
|
||||
|
||||
playlist_data.append({
|
||||
'id': content.id,
|
||||
@@ -406,7 +411,7 @@ def get_cached_playlist(player_id: int) -> List[Dict]:
|
||||
'type': content.content_type,
|
||||
'duration': content._playlist_duration or content.duration or 10,
|
||||
'position': content._playlist_position or idx,
|
||||
'url': content_url, # Full URL for downloads
|
||||
'url': item_url, # Web page URL for weblinks, file download URL otherwise
|
||||
'description': content.description,
|
||||
'edit_on_player': getattr(content, '_playlist_edit_on_player_enabled', False)
|
||||
})
|
||||
@@ -857,6 +862,124 @@ def receive_edited_media():
|
||||
return jsonify({'error': 'Internal server error'}), 500
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# SSH/Deployment Endpoints - For player provisioning and code deployment
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@api_bp.route('/deploy/test-ssh', methods=['POST'])
|
||||
@rate_limit(max_requests=30, window=60)
|
||||
def test_ssh_connection():
|
||||
"""Test SSH connection to a remote host.
|
||||
|
||||
Request JSON:
|
||||
hostname: Target hostname or IP (required)
|
||||
username: SSH username (required)
|
||||
password: SSH password (required)
|
||||
port: SSH port (default: 22)
|
||||
|
||||
Returns:
|
||||
JSON with connection test result
|
||||
"""
|
||||
try:
|
||||
from app.utils.ssh_deploy import test_ssh_connection as test_ssh
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
hostname = data.get('hostname', '').strip()
|
||||
username = data.get('username', '').strip()
|
||||
password = data.get('password', '').strip()
|
||||
port = data.get('port', 22)
|
||||
|
||||
if not hostname or not username or not password:
|
||||
return jsonify({'error': 'hostname, username, and password are required'}), 400
|
||||
|
||||
result = test_ssh(hostname, username, password, port)
|
||||
|
||||
log_action('info', f'SSH test for {username}@{hostname}: {result["message"]}')
|
||||
|
||||
return jsonify(result), 200 if result['success'] else 400
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error testing SSH connection: {str(e)}')
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'message': f'SSH test error: {str(e)}'
|
||||
}), 500
|
||||
|
||||
|
||||
@api_bp.route('/deploy/player', methods=['POST'])
|
||||
@rate_limit(max_requests=20, window=60)
|
||||
def deploy_player():
|
||||
"""Deploy player code to a remote host via SSH.
|
||||
|
||||
Request JSON:
|
||||
hostname: Target hostname or IP (required)
|
||||
username: SSH username (required)
|
||||
password: SSH password (required)
|
||||
player_name: Name for the player instance (required)
|
||||
port: SSH port (default: 22)
|
||||
deploy_path: Deployment path on remote host
|
||||
repo_url: Git repository URL
|
||||
|
||||
Returns:
|
||||
JSON with deployment status and step details
|
||||
"""
|
||||
try:
|
||||
from app.utils.ssh_deploy import deploy_player_to_host
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
hostname = data.get('hostname', '').strip()
|
||||
username = data.get('username', '').strip()
|
||||
password = data.get('password', '').strip()
|
||||
player_name = data.get('player_name', '').strip()
|
||||
port = data.get('port', 22)
|
||||
deploy_path = data.get('deploy_path', None)
|
||||
repo_url = data.get('repo_url', 'https://gitea.moto-adv.com/ske087/Kiwy-Signage.git').strip()
|
||||
|
||||
if not hostname or not username or not password:
|
||||
return jsonify({'error': 'hostname, username, and password are required'}), 400
|
||||
|
||||
if not player_name:
|
||||
return jsonify({'error': 'player_name is required'}), 400
|
||||
|
||||
scheme = request.headers.get('X-Forwarded-Proto', request.scheme)
|
||||
host = request.headers.get('X-Forwarded-Host', request.host)
|
||||
server_url = f"{scheme}://{host}"
|
||||
|
||||
api_key = hashlib.sha256(f'{player_name}:{hostname}'.encode()).hexdigest()[:32]
|
||||
|
||||
result = deploy_player_to_host(
|
||||
hostname=hostname,
|
||||
username=username,
|
||||
password=password,
|
||||
player_name=player_name,
|
||||
repo_url=repo_url,
|
||||
deploy_path=deploy_path,
|
||||
port=port,
|
||||
server_url=server_url,
|
||||
server_api_key=api_key
|
||||
)
|
||||
|
||||
log_action('info', f'Player deployment for {player_name} on {hostname}: success={result["success"]}')
|
||||
|
||||
return jsonify(result), 200 if result['success'] else 400
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error deploying player: {str(e)}')
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'message': f'Deployment error: {str(e)}',
|
||||
'steps': []
|
||||
}), 500
|
||||
|
||||
|
||||
@api_bp.errorhandler(404)
|
||||
def api_not_found(error):
|
||||
"""Handle 404 errors in API."""
|
||||
|
||||
+159
-2
@@ -6,6 +6,9 @@ from werkzeug.utils import secure_filename
|
||||
from typing import Optional
|
||||
import os
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from app.extensions import db, cache
|
||||
from app.models import Content, Playlist, Player
|
||||
@@ -201,8 +204,10 @@ def manage_playlist_content(playlist_id: int):
|
||||
# Get content in playlist (ordered)
|
||||
playlist_content = playlist.get_content_ordered()
|
||||
|
||||
# Get all available content not in this playlist
|
||||
all_content = Content.query.all()
|
||||
# Get all available content not in this playlist.
|
||||
# Web links are created on demand per playlist, so they are not offered
|
||||
# as reusable library items here.
|
||||
all_content = Content.query.filter(Content.content_type != 'weblink').all()
|
||||
playlist_content_ids = {c.id for c in playlist_content}
|
||||
available_content = [c for c in all_content if c.id not in playlist_content_ids]
|
||||
|
||||
@@ -262,6 +267,152 @@ def add_content_to_playlist(playlist_id: int):
|
||||
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
|
||||
|
||||
|
||||
@content_bp.route('/add-weblink', methods=['POST'])
|
||||
@login_required
|
||||
def add_weblink():
|
||||
"""Create a web link content item from the Upload Media page.
|
||||
|
||||
Optionally adds it directly to a playlist if playlist_id is supplied.
|
||||
Returns JSON when the request carries Accept: application/json, otherwise
|
||||
redirects back to the upload page.
|
||||
"""
|
||||
use_json = 'application/json' in request.accept_mimetypes.best or \
|
||||
request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
||||
|
||||
try:
|
||||
web_url = (request.form.get('url') or '').strip()
|
||||
duration = request.form.get('duration', type=int, default=30)
|
||||
description = (request.form.get('description') or '').strip() or None
|
||||
playlist_id = request.form.get('playlist_id', type=int)
|
||||
|
||||
parsed = urlparse(web_url)
|
||||
if parsed.scheme.lower() not in ('http', 'https') or not parsed.netloc:
|
||||
if use_json:
|
||||
return jsonify({'success': False, 'error': 'Please enter a valid http:// or https:// web address.'}), 400
|
||||
flash('Please enter a valid http:// or https:// web address.', 'warning')
|
||||
return redirect(url_for('content.upload_media_page'))
|
||||
|
||||
if not duration or duration < 1:
|
||||
duration = 30
|
||||
|
||||
content = Content(
|
||||
filename=f'weblink-{uuid.uuid4().hex[:12]}',
|
||||
content_type='weblink',
|
||||
url=web_url,
|
||||
duration=duration,
|
||||
description=description or web_url,
|
||||
uploaded_at=datetime.utcnow(),
|
||||
)
|
||||
db.session.add(content)
|
||||
db.session.flush()
|
||||
|
||||
if playlist_id:
|
||||
playlist = Playlist.query.get(playlist_id)
|
||||
if playlist:
|
||||
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
|
||||
db.session.execute(
|
||||
playlist_content.insert().values(
|
||||
playlist_id=playlist_id,
|
||||
content_id=content.id,
|
||||
position=max_pos + 1,
|
||||
duration=duration,
|
||||
)
|
||||
)
|
||||
playlist.increment_version()
|
||||
log_action('info', f'Web link "{web_url}" added to playlist "{playlist.name}"')
|
||||
else:
|
||||
log_action('warning', f'Web link "{web_url}" created; playlist {playlist_id} not found')
|
||||
else:
|
||||
log_action('info', f'Web link "{web_url}" added to media library')
|
||||
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
|
||||
if use_json:
|
||||
return jsonify({'success': True, 'content_id': content.id, 'message': 'Web link added successfully.'})
|
||||
flash('Web link added successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error adding web link: {str(e)}')
|
||||
if use_json:
|
||||
return jsonify({'success': False, 'error': 'Failed to add web link.'}), 500
|
||||
flash('Error adding web link.', 'danger')
|
||||
|
||||
return redirect(url_for('content.upload_media_page'))
|
||||
|
||||
|
||||
@content_bp.route('/playlist/<int:playlist_id>/add-weblink', methods=['POST'])
|
||||
@login_required
|
||||
def add_weblink_to_playlist(playlist_id: int):
|
||||
"""Create a web link content item and add it to the playlist."""
|
||||
playlist = Playlist.query.get_or_404(playlist_id)
|
||||
|
||||
try:
|
||||
web_url = (request.form.get('url') or '').strip()
|
||||
duration = request.form.get('duration', type=int, default=30)
|
||||
description = (request.form.get('description') or '').strip() or None
|
||||
|
||||
# Validate the URL: only http/https schemes are allowed (avoid file://, etc.)
|
||||
parsed = urlparse(web_url)
|
||||
if parsed.scheme.lower() not in ('http', 'https') or not parsed.netloc:
|
||||
flash('Please enter a valid http:// or https:// web address.', 'warning')
|
||||
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
|
||||
|
||||
if duration is None or duration < 1:
|
||||
duration = 30
|
||||
|
||||
# Create a weblink Content row. filename is a synthetic unique label
|
||||
# (no file on disk); the real target lives in the url column.
|
||||
content = Content(
|
||||
filename=f'weblink-{uuid.uuid4().hex[:12]}',
|
||||
content_type='weblink',
|
||||
url=web_url,
|
||||
duration=duration,
|
||||
description=description or web_url,
|
||||
uploaded_at=datetime.utcnow(),
|
||||
)
|
||||
db.session.add(content)
|
||||
db.session.flush() # assign content.id
|
||||
|
||||
# Append to the end of the playlist
|
||||
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
|
||||
|
||||
db.session.execute(
|
||||
playlist_content.insert().values(
|
||||
playlist_id=playlist_id,
|
||||
content_id=content.id,
|
||||
position=max_pos + 1,
|
||||
duration=duration,
|
||||
)
|
||||
)
|
||||
|
||||
playlist.increment_version()
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Added web link "{web_url}" to playlist "{playlist.name}"')
|
||||
flash('Web link added to playlist.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error adding web link to playlist: {str(e)}')
|
||||
flash('Error adding web link to playlist.', 'danger')
|
||||
|
||||
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
|
||||
|
||||
|
||||
@content_bp.route('/playlist/<int:playlist_id>/remove-content/<int:content_id>', methods=['POST'])
|
||||
@login_required
|
||||
def remove_content_from_playlist(playlist_id: int, content_id: int):
|
||||
@@ -277,6 +428,12 @@ def remove_content_from_playlist(playlist_id: int, content_id: int):
|
||||
(playlist_content.c.content_id == content_id)
|
||||
)
|
||||
db.session.execute(stmt)
|
||||
|
||||
# Web link items are playlist-specific and have no media-library
|
||||
# presence, so delete the orphan Content row when it is removed.
|
||||
content = db.session.get(Content, content_id)
|
||||
if content is not None and content.content_type == 'weblink':
|
||||
db.session.delete(content)
|
||||
|
||||
playlist.increment_version()
|
||||
db.session.commit()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Players blueprint for player management and display."""
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app
|
||||
from flask_login import login_required
|
||||
from werkzeug.security import generate_password_hash
|
||||
import secrets
|
||||
@@ -41,7 +41,7 @@ def list():
|
||||
@players_bp.route('/add', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def add_player():
|
||||
"""Add a new player."""
|
||||
"""Add a new player with optional SSH deployment."""
|
||||
if request.method == 'GET':
|
||||
playlists = Playlist.query.filter_by(is_active=True).order_by(Playlist.name).all()
|
||||
return render_template('players/add_player.html', playlists=playlists)
|
||||
@@ -55,6 +55,13 @@ def add_player():
|
||||
orientation = request.form.get('orientation', 'Landscape')
|
||||
playlist_id = request.form.get('playlist_id', '').strip()
|
||||
|
||||
# Get SSH deployment info if provided
|
||||
ssh_hostname = request.form.get('ssh_hostname', '').strip()
|
||||
ssh_username = request.form.get('ssh_username', '').strip()
|
||||
ssh_password = request.form.get('ssh_password', '').strip()
|
||||
ssh_port = int(request.form.get('ssh_port', '22')) if request.form.get('ssh_port') else 22
|
||||
deploy_player = request.form.get('deploy_player', '').strip()
|
||||
|
||||
# Validation
|
||||
if not name or len(name) < 3:
|
||||
flash('Player name must be at least 3 characters long.', 'warning')
|
||||
@@ -102,14 +109,82 @@ def add_player():
|
||||
|
||||
log_action('info', f'Player "{name}" (hostname: {hostname}) created')
|
||||
|
||||
# If deployment requested and SSH credentials provided, trigger background deployment
|
||||
deployment_initiated = False
|
||||
if deploy_player and ssh_hostname and ssh_username and ssh_password:
|
||||
try:
|
||||
from app.utils.background_tasks import background_player_deployment, run_background_task
|
||||
|
||||
# Determine the server address the player should contact.
|
||||
from flask import request as flask_request
|
||||
from app.models.https_config import HTTPSConfig
|
||||
|
||||
server_url = None
|
||||
try:
|
||||
import os
|
||||
from app.utils.player_build import get_player_server_settings, BUILD_META_FILENAME
|
||||
meta_path = os.path.join(current_app.instance_path, BUILD_META_FILENAME)
|
||||
build_srv = get_player_server_settings(meta_path)
|
||||
if build_srv:
|
||||
scheme = 'https' if build_srv['use_https'] else 'http'
|
||||
server_url = f"{scheme}://{build_srv['server_ip']}:{build_srv['port']}"
|
||||
except Exception:
|
||||
server_url = None
|
||||
|
||||
if not server_url:
|
||||
https_cfg = HTTPSConfig.get_config()
|
||||
if https_cfg and https_cfg.https_enabled and (https_cfg.domain or https_cfg.ip_address):
|
||||
host = https_cfg.domain or https_cfg.ip_address
|
||||
cfg_port = https_cfg.port or 443
|
||||
server_url = f"https://{host}:{cfg_port}"
|
||||
else:
|
||||
host = flask_request.host
|
||||
hostname_only = host.split(':')[0]
|
||||
if hostname_only in ('localhost', '127.0.0.1', '') or hostname_only.startswith('127.'):
|
||||
from app.utils.ssh_deploy import detect_server_ip
|
||||
detected_ip = detect_server_ip()
|
||||
if detected_ip:
|
||||
port_part = host.split(':', 1)[1] if ':' in host else ''
|
||||
host = f"{detected_ip}:{port_part}" if port_part else detected_ip
|
||||
server_url = f"{flask_request.scheme}://{host}"
|
||||
|
||||
# Generate API key for player authentication
|
||||
import hashlib
|
||||
api_key = hashlib.sha256(f'{name}:{hostname}'.encode()).hexdigest()[:32]
|
||||
|
||||
# Start deployment in background thread
|
||||
run_background_task(
|
||||
background_player_deployment,
|
||||
hostname=ssh_hostname,
|
||||
username=ssh_username,
|
||||
password=ssh_password,
|
||||
player_name=name,
|
||||
player_id=new_player.id,
|
||||
port=ssh_port,
|
||||
server_url=server_url,
|
||||
server_api_key=api_key,
|
||||
player_hostname=hostname,
|
||||
quickconnect_code=quickconnect_code,
|
||||
orientation=orientation
|
||||
)
|
||||
deployment_initiated = True
|
||||
log_action('info', f'Background deployment initiated for player "{name}" on {ssh_hostname}')
|
||||
except Exception as deploy_err:
|
||||
log_action('error', f'Failed to initiate background deployment for player "{name}": {str(deploy_err)}')
|
||||
|
||||
# Flash detailed success message
|
||||
success_msg = f'''
|
||||
Player "{name}" created successfully!<br>
|
||||
<strong>Auth Code:</strong> {auth_code}<br>
|
||||
<strong>Auth Code:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{auth_code}</code><br>
|
||||
<strong>Hostname:</strong> {hostname}<br>
|
||||
<strong>Quick Connect:</strong> {quickconnect_code}<br>
|
||||
<small>Configure the player with these credentials in app_config.json</small>
|
||||
'''
|
||||
|
||||
if deployment_initiated:
|
||||
success_msg += f'<strong style="color: #0275d8;">⌛ Deployment in Progress</strong> Deploying to {ssh_hostname} in background...<br>'
|
||||
success_msg += '<small>Check player status to see deployment completion</small><br>'
|
||||
|
||||
success_msg += '<small>Configure the player with these credentials in app_config.json</small>'
|
||||
flash(success_msg, 'success')
|
||||
|
||||
return redirect(url_for('players.list'))
|
||||
@@ -426,9 +501,14 @@ def get_player_playlist(player_id: int) -> List[dict]:
|
||||
# Build playlist
|
||||
playlist = []
|
||||
for content in ordered_content:
|
||||
# For weblinks, serve the actual URL directly; for files, serve static path
|
||||
if content.content_type == 'weblink' and content.url:
|
||||
item_url = content.url
|
||||
else:
|
||||
item_url = url_for('static', filename=f'uploads/{content.filename}')
|
||||
playlist.append({
|
||||
'id': content.id,
|
||||
'url': url_for('static', filename=f'uploads/{content.filename}'),
|
||||
'url': item_url,
|
||||
'type': content.content_type,
|
||||
'duration': getattr(content, '_playlist_duration', content.duration or 10),
|
||||
'position': getattr(content, '_playlist_position', 0),
|
||||
|
||||
Reference in New Issue
Block a user