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
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
"""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,
|
||||
}
|
||||
Reference in New Issue
Block a user