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:
@@ -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'))
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}')
|
||||
|
||||
Reference in New Issue
Block a user