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}')