4f4e017ad2
- nginx: add /api/ shortcut block (no portal auth, X-Script-Name /digiserver, Host $http_host) so players can reach DigiServer API without /digiserver prefix - nginx: use $http_host in /api/ block so Flask host_url includes port — fixes media download URLs missing :8080 (was http://ip/digiserver/... not http://ip:8080/...) - player main.py: fix double-port bug when server_ip already contains a port (e.g. 192.168.0.230:8080 was producing http://192.168.0.230:8080:80) - get_playlists_v2.py: force re-sync when server version differs OR local media files are missing on disk — fixes stale playlist after server reset - digiserver api.py: playlist endpoint builds full media URLs using script_root from X-Script-Name header set by nginx - weblink support, player build/deploy improvements, manage-playlist AJAX prefix fix
224 lines
8.5 KiB
Python
224 lines
8.5 KiB
Python
"""Utilities for building/staging the player files on the server.
|
|
|
|
Admins use the "Build player files" admin page to:
|
|
* clone/refresh the player source code from a git repository into a local
|
|
staged directory (``PLAYER_CODE_DIR``), and
|
|
* write a base ``config/app_config.json`` so the staged code already knows how
|
|
to reach this server.
|
|
|
|
The SSH deployment flow then ships this staged directory to player devices, so
|
|
the version admins build here is exactly what gets deployed.
|
|
"""
|
|
import os
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import Any, Dict, Optional
|
|
|
|
from app.utils.ssh_deploy import generate_app_config
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Metadata file name stored in the Flask instance folder.
|
|
BUILD_META_FILENAME = 'player_build.json'
|
|
|
|
|
|
def _run_git(args, cwd=None, timeout=300) -> subprocess.CompletedProcess:
|
|
return subprocess.run(
|
|
['git'] + args,
|
|
cwd=cwd,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
)
|
|
|
|
|
|
def get_short_head(player_code_dir: str) -> str:
|
|
"""Return the short git commit of the staged code, or 'unknown'."""
|
|
try:
|
|
result = _run_git(['-C', player_code_dir, 'rev-parse', '--short', 'HEAD'], timeout=10)
|
|
if result.returncode == 0:
|
|
return result.stdout.strip()
|
|
except Exception:
|
|
pass
|
|
return 'unknown'
|
|
|
|
|
|
def build_player_files(player_code_dir: str, repo_url: str, branch: str = 'main') -> Dict[str, Any]:
|
|
"""Clone or refresh the player source into ``player_code_dir``.
|
|
|
|
If the directory is already a git checkout of ``repo_url`` it is updated in
|
|
place (fetch + hard reset to the chosen branch). Otherwise it is cloned
|
|
fresh (an existing non-git directory is replaced).
|
|
|
|
Returns a dict: ``success`` (bool), ``message`` (str), ``version`` (str),
|
|
``branch`` (str).
|
|
"""
|
|
branch = (branch or 'main').strip()
|
|
repo_url = (repo_url or '').strip()
|
|
if not repo_url:
|
|
return {'success': False, 'message': 'Repository URL is required.', 'version': None, 'branch': branch}
|
|
|
|
try:
|
|
git_dir = os.path.join(player_code_dir, '.git')
|
|
is_git_repo = os.path.isdir(git_dir)
|
|
|
|
if is_git_repo:
|
|
# Update existing checkout in place.
|
|
fetch = _run_git(['-C', player_code_dir, 'fetch', '--prune', 'origin'])
|
|
if fetch.returncode != 0:
|
|
return {
|
|
'success': False,
|
|
'message': f'git fetch failed: {fetch.stderr.strip() or fetch.stdout.strip()}',
|
|
'version': get_short_head(player_code_dir),
|
|
'branch': branch,
|
|
}
|
|
# Point origin at the requested URL in case it changed.
|
|
_run_git(['-C', player_code_dir, 'remote', 'set-url', 'origin', repo_url])
|
|
checkout = _run_git(['-C', player_code_dir, 'checkout', branch])
|
|
if checkout.returncode != 0:
|
|
return {
|
|
'success': False,
|
|
'message': f'git checkout {branch} failed: {checkout.stderr.strip()}',
|
|
'version': get_short_head(player_code_dir),
|
|
'branch': branch,
|
|
}
|
|
reset = _run_git(['-C', player_code_dir, 'reset', '--hard', f'origin/{branch}'])
|
|
if reset.returncode != 0:
|
|
return {
|
|
'success': False,
|
|
'message': f'git reset failed: {reset.stderr.strip()}',
|
|
'version': get_short_head(player_code_dir),
|
|
'branch': branch,
|
|
}
|
|
action = 'Updated'
|
|
else:
|
|
# Fresh clone. Replace any existing (non-git) directory.
|
|
parent = os.path.dirname(player_code_dir.rstrip('/'))
|
|
os.makedirs(parent, exist_ok=True)
|
|
if os.path.exists(player_code_dir):
|
|
shutil.rmtree(player_code_dir)
|
|
clone = _run_git(['clone', '--branch', branch, repo_url, player_code_dir])
|
|
if clone.returncode != 0:
|
|
return {
|
|
'success': False,
|
|
'message': f'git clone failed: {clone.stderr.strip() or clone.stdout.strip()}',
|
|
'version': None,
|
|
'branch': branch,
|
|
}
|
|
action = 'Cloned'
|
|
|
|
version = get_short_head(player_code_dir)
|
|
logger.info('%s player code from %s (%s) -> %s', action, repo_url, branch, version)
|
|
return {
|
|
'success': True,
|
|
'message': f'{action} player code from {branch} (version {version}).',
|
|
'version': version,
|
|
'branch': branch,
|
|
}
|
|
except subprocess.TimeoutExpired:
|
|
return {'success': False, 'message': 'Git operation timed out.', 'version': None, 'branch': branch}
|
|
except Exception as e:
|
|
logger.exception('build_player_files failed')
|
|
return {'success': False, 'message': f'Build failed: {str(e)}', 'version': None, 'branch': branch}
|
|
|
|
|
|
def write_base_config(
|
|
player_code_dir: str,
|
|
server_ip: str,
|
|
port: str,
|
|
use_https: bool = True,
|
|
verify_ssl: bool = False,
|
|
orientation: str = 'Landscape',
|
|
max_resolution: str = '1920x1080',
|
|
) -> Dict[str, Any]:
|
|
"""Write a base ``config/app_config.json`` into the staged player code.
|
|
|
|
``screen_name`` and ``quickconnect_key`` are left blank on purpose: they are
|
|
per-player and get filled in by the SSH deploy step for each device.
|
|
"""
|
|
try:
|
|
config_dir = os.path.join(player_code_dir, 'config')
|
|
os.makedirs(config_dir, exist_ok=True)
|
|
content = generate_app_config(
|
|
server_ip=server_ip,
|
|
port=str(port),
|
|
screen_name='',
|
|
quickconnect_code='',
|
|
orientation=orientation,
|
|
use_https=use_https,
|
|
verify_ssl=verify_ssl,
|
|
max_resolution=max_resolution,
|
|
)
|
|
config_path = os.path.join(config_dir, 'app_config.json')
|
|
with open(config_path, 'w', encoding='utf-8') as f:
|
|
f.write(content)
|
|
logger.info('Wrote base player config -> %s', config_path)
|
|
return {'success': True, 'message': 'Base player config written.', 'path': config_path}
|
|
except Exception as e:
|
|
logger.exception('write_base_config failed')
|
|
return {'success': False, 'message': f'Failed to write config: {str(e)}'}
|
|
|
|
|
|
def load_build_settings(meta_path: str) -> Optional[Dict[str, Any]]:
|
|
"""Load saved build settings from ``meta_path`` (or None if absent/invalid)."""
|
|
try:
|
|
if os.path.isfile(meta_path):
|
|
with open(meta_path, 'r', encoding='utf-8') as f:
|
|
return json.load(f)
|
|
except Exception as e:
|
|
logger.warning('Could not read build settings: %s', e)
|
|
return None
|
|
|
|
|
|
def save_build_settings(meta_path: str, data: Dict[str, Any]) -> bool:
|
|
"""Persist build settings to ``meta_path``."""
|
|
try:
|
|
os.makedirs(os.path.dirname(meta_path), exist_ok=True)
|
|
with open(meta_path, 'w', encoding='utf-8') as f:
|
|
json.dump(data, f, indent=2)
|
|
return True
|
|
except Exception as e:
|
|
logger.warning('Could not save build settings: %s', e)
|
|
return False
|
|
|
|
|
|
def get_player_server_settings(meta_path: str) -> Optional[Dict[str, Any]]:
|
|
"""Return the saved server address settings for deployment, if available.
|
|
|
|
Returns a dict with ``server_ip``, ``port`` (str), ``use_https`` (bool) and
|
|
``verify_ssl`` (bool), or None when no usable build settings are saved.
|
|
"""
|
|
settings = load_build_settings(meta_path)
|
|
if not settings:
|
|
return None
|
|
server_ip = (settings.get('server_ip') or '').strip()
|
|
if not server_ip:
|
|
return None
|
|
return {
|
|
'server_ip': server_ip,
|
|
'port': str(settings.get('port') or ('443' if settings.get('use_https', True) else '80')),
|
|
'use_https': bool(settings.get('use_https', True)),
|
|
'verify_ssl': bool(settings.get('verify_ssl', False)),
|
|
}
|
|
|
|
|
|
def make_build_record(repo_url, branch, server_ip, port, use_https, verify_ssl,
|
|
orientation, max_resolution, version, built_by) -> Dict[str, Any]:
|
|
"""Assemble the metadata record to persist after a build."""
|
|
return {
|
|
'repo_url': repo_url,
|
|
'branch': branch,
|
|
'server_ip': server_ip,
|
|
'port': str(port),
|
|
'use_https': bool(use_https),
|
|
'verify_ssl': bool(verify_ssl),
|
|
'orientation': orientation,
|
|
'max_resolution': max_resolution,
|
|
'built_version': version,
|
|
'built_at': datetime.utcnow().isoformat(timespec='seconds') + 'Z',
|
|
'built_by': built_by,
|
|
}
|