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:
ske087
2026-06-29 20:04:11 +03:00
parent f674330b93
commit 4f4e017ad2
16 changed files with 1222 additions and 38 deletions
+144
View File
@@ -936,3 +936,147 @@ def https_config_status():
except Exception as e:
log_action('error', f'Error getting HTTPS status: {str(e)}')
return jsonify({'error': str(e)}), 500
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')
# Prefer the server's real LAN IP over 'localhost' from the proxy host header.
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'))
+5 -1
View File
@@ -404,13 +404,17 @@ def get_cached_playlist(player_id: int) -> List[Dict]:
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,
'file_name': content.filename, # Player expects 'file_name' not 'filename'
'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)
})
+79 -2
View File
@@ -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,72 @@ def add_content_to_playlist(playlist_id: int):
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
@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 +348,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()
+43 -4
View File
@@ -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
@@ -115,9 +115,45 @@ def add_player():
try:
from app.utils.background_tasks import background_player_deployment, run_background_task
# Get server URL for player configuration
# Determine the server address the player should contact.
# The player talks to the DigiServer API directly at
# {scheme}://{host}:{port}/api/... (no '/digiserver' path prefix),
# so prefer the address admins set on the "Build player files" page,
# then the configured HTTPS domain/IP, then the umbrella host.
from flask import request as flask_request
server_url = f"{flask_request.scheme}://{flask_request.host}/digiserver"
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:
# Fallback: derive from the current request host, but never
# ship 'localhost'/127.0.0.1 to a remote player — substitute
# this server's real LAN IP so the player can reach it.
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
@@ -133,7 +169,10 @@ def add_player():
player_id=new_player.id,
port=ssh_port,
server_url=server_url,
server_api_key=api_key
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}')
+12
View File
@@ -22,6 +22,18 @@ class Config:
UPLOAD_FOLDER = os.path.join(_basedir, 'static', 'uploads')
UPLOAD_FOLDERLOGO = os.path.join(_basedir, 'static', 'resurse')
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'mp4', 'avi', 'mkv', 'mov', 'webm', 'pdf', 'ppt', 'pptx'}
# Pre-staged player code location (admins build/refresh this; SSH deploy ships it).
# Defaults to <repo>/data/player both in the container (/app/data/player) and in dev.
PLAYER_CODE_DIR = os.getenv(
'PLAYER_CODE_DIR',
os.path.abspath(os.path.join(_basedir, '..', 'data', 'player'))
)
# Default git repository for the player source code.
PLAYER_REPO_URL = os.getenv(
'PLAYER_REPO_URL',
'https://gitea.moto-adv.com/ske087/Kiwy-Signage.git'
)
# Session
PERMANENT_SESSION_LIFETIME = timedelta(minutes=30)
+7
View File
@@ -24,6 +24,9 @@ class Content(db.Model):
content_type = db.Column(db.String(50), nullable=False, index=True)
duration = db.Column(db.Integer, default=10, nullable=True)
file_size = db.Column(db.BigInteger, nullable=True)
# For 'weblink' content this holds the web page URL to display on the player.
# NULL for file-based content (image/video/pdf).
url = db.Column(db.String(2048), nullable=True)
description = db.Column(db.Text, nullable=True)
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow,
nullable=False, index=True)
@@ -61,3 +64,7 @@ class Content(db.Model):
def is_pdf(self) -> bool:
"""Check if content is a PDF."""
return self.content_type == 'pdf'
def is_weblink(self) -> bool:
"""Check if content is a web link (URL) rather than an uploaded file."""
return self.content_type == 'weblink'
@@ -116,6 +116,17 @@
</a>
</div>
</div>
<!-- Build Player Files Card (Admin Only) -->
<div class="card management-card" style="background: linear-gradient(135deg, #0ba360 0%, #3cba92 100%);">
<h2>🧰 Build Player Files</h2>
<p>Pull the latest player code and bake its server config for deployment</p>
<div class="card-actions">
<a href="{{ url_for('admin.build_player') }}" class="btn btn-primary">
Build Player Files
</a>
</div>
</div>
{% endif %}
<!-- Quick Actions Card -->
@@ -0,0 +1,130 @@
{% extends "base.html" %}
{% block title %}Build Player Files - DigiServer v2{% endblock %}
{% block content %}
<div class="container">
<div class="page-header">
<a href="{{ url_for('admin.admin_panel') }}" class="back-link">← Back to Admin Panel</a>
<h1>🧰 Build Player Files for Deployment</h1>
<p style="color: #6c757d; margin-top: 6px;">
Pull the latest player source from a repository onto this server and bake the
configuration it needs to talk to this server. SSH deployments ship exactly
this staged copy, so the version you build here is what players run.
</p>
</div>
<!-- Current staged code status -->
<div class="card status-card">
<h2>Current Staged Player Code</h2>
{% if code_status.available %}
<p><span class="badge badge-success">✅ Ready</span></p>
<ul style="line-height: 1.8;">
<li><strong>Version (git):</strong> <code>{{ code_status.version }}</code></li>
<li><strong>Size:</strong> {{ code_status.size }}</li>
{% if code_status.updated_str %}
<li><strong>Last updated:</strong> {{ code_status.updated_str }}</li>
{% endif %}
<li><strong>Path:</strong> <code>{{ code_status.path }}</code></li>
</ul>
{% else %}
<p>
<span class="badge badge-warning">⚠️ Not staged</span>
{{ code_status.reason }}
</p>
<p style="color: #6c757d;">Path: <code>{{ code_status.path }}</code></p>
{% endif %}
{% if settings.built_at %}
<p style="color: #6c757d; margin-top: 8px;">
Last build saved: {{ settings.built_at }}
{% if settings.built_by %}by {{ settings.built_by }}{% endif %}
</p>
{% endif %}
</div>
<form method="POST" action="{{ url_for('admin.build_player_action') }}">
<!-- Repository -->
<div class="card">
<h2>1. Player Files Repository</h2>
<div class="form-group">
<label for="repo_url">Git Repository URL</label>
<input type="text" id="repo_url" name="repo_url" class="form-control"
value="{{ settings.repo_url }}"
placeholder="https://gitea.example.com/org/Kiwy-Signage.git">
<small style="color: #6c757d;">Cloned/refreshed into the staged directory on this server.</small>
</div>
<div class="form-group">
<label for="branch">Branch</label>
<input type="text" id="branch" name="branch" class="form-control"
value="{{ settings.branch }}" placeholder="main">
</div>
</div>
<!-- Server configuration -->
<div class="card">
<h2>2. Player → Server Configuration</h2>
<p style="color: #6c757d;">
The player contacts the DigiServer API directly at
<code>{scheme}://{server}:{port}/api/...</code> (no <code>/digiserver</code> path).
Enter the address players can reach this server on.
</p>
<div class="form-group">
<label for="server_ip">Server IP / Domain</label>
<input type="text" id="server_ip" name="server_ip" class="form-control"
value="{{ settings.server_ip }}" placeholder="signage.example.com or 192.168.0.50">
</div>
<div class="form-group">
<label for="port">Port</label>
<input type="number" id="port" name="port" class="form-control"
value="{{ settings.port }}" min="1" max="65535">
<small style="color: #6c757d;">Use 443 for HTTPS, 80 for plain HTTP, or your custom port (e.g. 8080).</small>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" name="use_https" {% if settings.use_https %}checked{% endif %}>
Use HTTPS
</label>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" name="verify_ssl" {% if settings.verify_ssl %}checked{% endif %}>
Verify SSL certificate
</label>
<small style="color: #6c757d;">Leave off for self-signed certificates.</small>
</div>
<div class="form-group">
<label for="orientation">Orientation</label>
<select id="orientation" name="orientation" class="form-control">
<option value="Landscape" {% if settings.orientation == 'Landscape' %}selected{% endif %}>Landscape</option>
<option value="Portrait" {% if settings.orientation == 'Portrait' %}selected{% endif %}>Portrait</option>
</select>
</div>
<div class="form-group">
<label for="max_resolution">Max Resolution</label>
<input type="text" id="max_resolution" name="max_resolution" class="form-control"
value="{{ settings.max_resolution }}" placeholder="1920x1080">
</div>
<p style="color: #6c757d; font-size: 13px;">
️ The per-player <strong>screen name</strong> and <strong>quick-connect code</strong>
are filled in automatically for each device during SSH deployment.
</p>
</div>
<!-- Actions -->
<div class="card">
<h2>3. Build</h2>
<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">
⬇️ Build files &amp; write config
</button>
<button type="submit" name="action" value="build_files" class="btn btn-secondary">
Build files only
</button>
<button type="submit" name="action" value="save_config" class="btn btn-secondary">
Write config only
</button>
</div>
</div>
</form>
</div>
{% endblock %}
@@ -314,11 +314,18 @@
</td>
<td><span class="drag-handle">⋮⋮</span></td>
<td>{{ loop.index }}</td>
<td>{{ content.filename }}</td>
<td>
{% if content.content_type == 'weblink' %}
<a href="{{ content.url }}" target="_blank" rel="noopener noreferrer">{{ content.url }}</a>
{% else %}
{{ content.filename }}
{% endif %}
</td>
<td>
{% if content.content_type == 'image' %}📷 Image
{% elif content.content_type == 'video' %}🎥 Video
{% elif content.content_type == 'pdf' %}📄 PDF
{% elif content.content_type == 'weblink' %}🔗 Web Link
{% else %}📁 Other{% endif %}
</td>
<td>
@@ -400,7 +407,23 @@
<div class="card">
<h2 style="margin-bottom: 20px;"> Add Content</h2>
<div style="margin-bottom: 24px; padding-bottom: 24px; border-bottom: 1px solid #e0e0e0;">
<h3 style="margin-bottom: 12px; font-size: 16px;">🔗 Add Web Link</h3>
<form method="POST"
action="{{ url_for('content.add_weblink_to_playlist', playlist_id=playlist.id) }}">
<input type="url" name="url" required
placeholder="https://example.com/dashboard"
style="width: 100%; padding: 8px; margin-bottom: 8px; box-sizing: border-box;">
<div style="display: flex; gap: 8px; align-items: center;">
<label style="font-size: 13px; color: #666;">Duration (s):</label>
<input type="number" name="duration" value="30" min="1"
style="width: 80px; padding: 6px;">
<button type="submit" class="btn btn-primary btn-sm">+ Add Link</button>
</div>
</form>
</div>
{% if available_content %}
<div class="available-content">
{% for content in available_content %}
@@ -543,7 +566,7 @@ function changeDuration(contentId, change) {
// Save to server
const playlistId = {{ playlist.id }};
const url = `/content/playlist/${playlistId}/update-duration/${contentId}`;
const url = `{{ request.script_root }}/content/playlist/${playlistId}/update-duration/${contentId}`;
const formData = new FormData();
formData.append('duration', newDuration);
@@ -579,7 +602,7 @@ function changeDuration(contentId, change) {
function toggleAudio(contentId, enabled) {
const muted = !enabled; // Checkbox is "enabled audio", but backend stores "muted"
const playlistId = {{ playlist.id }};
const url = `/content/playlist/${playlistId}/update-muted/${contentId}`;
const url = `{{ request.script_root }}/content/playlist/${playlistId}/update-muted/${contentId}`;
const formData = new FormData();
formData.append('muted', muted ? 'true' : 'false');
@@ -610,7 +633,7 @@ function toggleAudio(contentId, enabled) {
function toggleEdit(contentId, enabled) {
const playlistId = {{ playlist.id }};
const url = `/content/playlist/${playlistId}/update-edit-enabled/${contentId}`;
const url = `{{ request.script_root }}/content/playlist/${playlistId}/update-edit-enabled/${contentId}`;
const formData = new FormData();
formData.append('edit_enabled', enabled ? 'true' : 'false');
+14 -2
View File
@@ -39,7 +39,11 @@ def background_player_deployment(
player_id: int,
port: int = 22,
server_url: str = None,
server_api_key: str = None
server_api_key: str = None,
player_hostname: str = None,
quickconnect_code: str = None,
orientation: str = 'Landscape',
verify_ssl: bool = False
) -> None:
"""
Deploy player code to host in background.
@@ -53,6 +57,10 @@ def background_player_deployment(
port: SSH port
server_url: DigiServer URL for player
server_api_key: API key for player
player_hostname: Player screen identity used for auth (Player.hostname)
quickconnect_code: Quick connect code used for auth
orientation: Player orientation (Landscape/Portrait)
verify_ssl: Whether the player should verify the server TLS certificate
"""
from app.utils.ssh_deploy import deploy_player_to_host
from app.models import Player
@@ -68,7 +76,11 @@ def background_player_deployment(
player_name=player_name,
port=port,
server_url=server_url,
server_api_key=server_api_key
server_api_key=server_api_key,
player_hostname=player_hostname,
quickconnect_code=quickconnect_code,
orientation=orientation,
verify_ssl=verify_ssl
)
# Update player with deployment status
+223
View File
@@ -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,
}
+190 -23
View File
@@ -12,34 +12,39 @@ logger = logging.getLogger(__name__)
LOCAL_PLAYER_CODE_DIR = '/app/data/player'
def get_local_player_code_status() -> Dict[str, Any]:
def get_local_player_code_status(player_code_dir: Optional[str] = None) -> Dict[str, Any]:
"""
Check status of pre-staged player code.
Args:
player_code_dir: Optional override for the staged code path. Defaults to
``LOCAL_PLAYER_CODE_DIR`` (the container location).
Returns:
Dict with availability, version, and path info
"""
code_dir = player_code_dir or LOCAL_PLAYER_CODE_DIR
try:
if not os.path.isdir(LOCAL_PLAYER_CODE_DIR):
if not os.path.isdir(code_dir):
return {
'available': False,
'reason': 'Directory not found',
'path': LOCAL_PLAYER_CODE_DIR
'path': code_dir
}
# Check if git repository
git_dir = os.path.join(LOCAL_PLAYER_CODE_DIR, '.git')
git_dir = os.path.join(code_dir, '.git')
if not os.path.isdir(git_dir):
return {
'available': False,
'reason': 'Not a git repository',
'path': LOCAL_PLAYER_CODE_DIR
'path': code_dir
}
# Get current git version
try:
result = subprocess.run(
['git', '-C', LOCAL_PLAYER_CODE_DIR, 'rev-parse', '--short', 'HEAD'],
['git', '-C', code_dir, 'rev-parse', '--short', 'HEAD'],
capture_output=True,
text=True,
timeout=5
@@ -51,7 +56,7 @@ def get_local_player_code_status() -> Dict[str, Any]:
# Get directory size
try:
result = subprocess.run(
['du', '-sh', LOCAL_PLAYER_CODE_DIR],
['du', '-sh', code_dir],
capture_output=True,
text=True,
timeout=5
@@ -62,7 +67,7 @@ def get_local_player_code_status() -> Dict[str, Any]:
return {
'available': True,
'path': LOCAL_PLAYER_CODE_DIR,
'path': code_dir,
'version': version,
'size': size,
'updated': os.path.getmtime(git_dir),
@@ -73,7 +78,7 @@ def get_local_player_code_status() -> Dict[str, Any]:
return {
'available': False,
'reason': f'Status check failed: {str(e)}',
'path': LOCAL_PLAYER_CODE_DIR
'path': code_dir
}
@@ -200,6 +205,109 @@ def generate_player_config(
return json.dumps(config, indent=2)
def detect_server_ip() -> Optional[str]:
"""Best-effort detection of this server's primary LAN IP address.
Opens a UDP socket toward a public address (no packets are actually sent)
and reads the local socket address, which resolves to the IP of the
interface used for outbound traffic. Returns None on failure.
"""
import socket
s = None
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(('8.8.8.8', 80))
ip = s.getsockname()[0]
if ip and not ip.startswith('127.'):
return ip
except Exception:
pass
finally:
if s is not None:
try:
s.close()
except Exception:
pass
# Fallback via hostname resolution.
try:
ip = socket.gethostbyname(socket.gethostname())
if ip and not ip.startswith('127.'):
return ip
except Exception:
pass
return None
def parse_server_address(server_url: str) -> Dict[str, Any]:
"""Derive the values the player needs from a DigiServer URL.
The player's config/app_config.json stores server_ip + port + use_https and
builds requests as ``{scheme}://{server_ip}:{port}/api/...`` (it does NOT use
any URL path prefix such as ``/digiserver``). This helper extracts the host,
port and scheme from a server URL and drops any path component.
Args:
server_url: e.g. ``https://signage.example.com/digiserver`` or
``http://192.168.0.50:8080``
Returns:
Dict with ``server_ip`` (str), ``port`` (str) and ``use_https`` (bool).
"""
from urllib.parse import urlparse
parsed = urlparse(server_url if '://' in (server_url or '') else f'//{server_url}')
use_https = (parsed.scheme or 'https') == 'https'
host = parsed.hostname or ''
port = parsed.port
if port is None:
port = 443 if use_https else 80
return {'server_ip': host, 'port': str(port), 'use_https': use_https}
def generate_app_config(
server_ip: str,
port: str,
screen_name: str,
quickconnect_code: str,
orientation: str = 'Landscape',
use_https: bool = True,
verify_ssl: bool = False,
max_resolution: str = '1920x1080',
) -> str:
"""Generate the config/app_config.json the player actually reads.
This is what binds a deployed player to the real server and its assigned
playlist: the player authenticates with ``screen_name`` + ``quickconnect_code``
and the server returns the playlist assigned to that player.
Args:
server_ip: Server IP or domain the player should contact.
port: Server port as a string.
screen_name: Player hostname / screen identity (matches Player.hostname).
quickconnect_code: Quick connect code (matches Player.quickconnect_code).
orientation: Landscape or Portrait.
use_https: Whether the player should use HTTPS.
verify_ssl: Whether the player should verify the TLS certificate.
max_resolution: Maximum playback resolution.
Returns:
JSON configuration string.
"""
config = {
'server_ip': server_ip,
'port': str(port),
'screen_name': screen_name,
'quickconnect_key': quickconnect_code,
'orientation': orientation or 'Landscape',
'touch': 'True',
'max_resolution': max_resolution,
'edit_feature_enabled': True,
'use_https': bool(use_https),
'verify_ssl': bool(verify_ssl),
}
return json.dumps(config, indent=2)
def deploy_player_to_host(
hostname: str,
username: str,
@@ -209,7 +317,11 @@ def deploy_player_to_host(
deploy_path: str = None, # Default: /home/[user]/kiwy-signage
port: int = 22,
server_url: str = None, # DigiServer URL for player to connect to
server_api_key: str = None # API key for player authentication
server_api_key: str = None, # API key for player authentication
player_hostname: str = None, # Player screen identity (Player.hostname)
quickconnect_code: str = None, # Player quick connect code
orientation: str = 'Landscape', # Player orientation
verify_ssl: bool = False, # Whether the player should verify TLS
) -> Dict[str, Any]:
"""
Deploy player code to remote host.
@@ -224,6 +336,10 @@ def deploy_player_to_host(
port: SSH port (default 22)
server_url: DigiServer URL for player connection
server_api_key: API key for player authentication
player_hostname: Player screen identity used for auth (Player.hostname)
quickconnect_code: Quick connect code used for auth (Player.quickconnect_code)
orientation: Player orientation (Landscape/Portrait)
verify_ssl: Whether the player should verify the server TLS certificate
Returns:
Dict with deployment status and output
@@ -384,32 +500,82 @@ def deploy_player_to_host(
'steps': steps
}
# Step 3.5: Generate player configuration
# Step 3.5: Generate player configuration (config/app_config.json)
# This is the file the player actually reads to learn the server address
# and its screen identity. Authenticating with that identity is what binds
# the player to its assigned playlist on the real server.
install_env_prefix = ''
try:
if server_url and server_api_key:
config_content = generate_player_config(
player_name=player_name,
server_url=server_url,
api_key=server_api_key
screen_name = player_hostname or player_name
if server_url and screen_name and quickconnect_code:
addr = parse_server_address(server_url)
app_config_content = generate_app_config(
server_ip=addr['server_ip'],
port=addr['port'],
screen_name=screen_name,
quickconnect_code=quickconnect_code,
orientation=orientation or 'Landscape',
use_https=addr['use_https'],
verify_ssl=verify_ssl,
)
# Build an env prefix so install.sh's configure_player() also runs
# (single, consistent configuration path on the player side).
import shlex
env_pairs = {
'KIWY_SERVER_IP': addr['server_ip'],
'KIWY_PORT': addr['port'],
'KIWY_SCREEN_NAME': screen_name,
'KIWY_QUICKCONNECT': quickconnect_code,
'KIWY_ORIENTATION': orientation or 'Landscape',
'KIWY_USE_HTTPS': 'true' if addr['use_https'] else 'false',
'KIWY_VERIFY_SSL': 'true' if verify_ssl else 'false',
}
install_env_prefix = ' '.join(
f'{k}={shlex.quote(str(v))}' for k, v in env_pairs.items()
) + ' '
# Write config/app_config.json directly (robust even if install.sh
# is missing or fails), and clear any stale baked-in auth.
remote_cmd = (
f'mkdir -p {deploy_path}/config && '
f"cat > {deploy_path}/config/app_config.json << 'EOF'\n"
f'{app_config_content}\n'
f'EOF\n'
f'rm -f {deploy_path}/player_auth.json {deploy_path}/src/player_auth.json 2>/dev/null || true'
)
# Write config file to remote host
write_config_cmd = [
'sshpass', '-p', password,
'ssh', '-o', 'StrictHostKeyChecking=no',
'-p', str(port),
f'{username}@{hostname}',
f'cat > {deploy_path}/config.json << \'EOF\'\n{config_content}\nEOF'
remote_cmd
]
result = subprocess.run(write_config_cmd, capture_output=True, text=True, timeout=30)
steps.append({
'step': 'Configure Player',
'status': 'completed' if result.returncode == 0 else 'warning',
'message': f'Player configuration created',
'message': (
f'Wrote config/app_config.json '
f'(server {addr["server_ip"]}:{addr["port"]}, screen {screen_name})'
),
'timestamp': datetime.now().isoformat()
})
else:
steps.append({
'step': 'Configure Player',
'status': 'skipped',
'message': 'Missing server_url / player hostname / quickconnect; player not auto-configured',
'timestamp': datetime.now().isoformat()
})
except Exception as e:
logger.warning(f'Failed to create player config: {str(e)}')
steps.append({
'step': 'Configure Player',
'status': 'warning',
'message': f'Failed to write player config: {str(e)}',
'timestamp': datetime.now().isoformat()
})
# Step 4: Run installation script
try:
@@ -436,13 +602,14 @@ def deploy_player_to_host(
install_script = find_result.stdout.strip().split('\n')[0]
if install_script:
# Run the install script
# Run the install script (passing KIWY_* so its
# configure_player() section writes config/app_config.json too)
install_cmd = [
'sshpass', '-p', password,
'ssh', '-o', 'StrictHostKeyChecking=no',
'-p', str(port),
f'{username}@{hostname}',
f'cd {deploy_path} && bash {install_script} 2>&1'
f'cd {deploy_path} && {install_env_prefix}bash {install_script} 2>&1'
]
result = subprocess.run(install_cmd, capture_output=True, text=True, timeout=300)
steps.append({