Files
enterprise_digital-platform/digiserver-v2/app/utils/player_build.py
T
ske087 4f4e017ad2 fix: player connectivity and media download pipeline
- 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
2026-06-29 20:04:11 +03:00

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,
}