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
+246
View File
@@ -0,0 +1,246 @@
# Web Link Playlist Items — Player Integration Guide
This document describes the changes required on the **Kiwy-Signage player**
(<https://gitea.moto-adv.com/ske087/Kiwy-Signage.git>) to support a new
playlist item type: **`weblink`** (display a live web page / URL instead of an
uploaded media file).
> The DigiServer (this repo, `digiserver-v2`) side **is now implemented**: it
> stores web links (`content_type='weblink'`, page URL in `content.url`) and
> emits `weblink` items from `GET /api/playlists`. The **player does not yet
> support them** — use this guide to implement the player side.
---
## 1. Background — how items flow today
```
DigiServer API ──JSON──▶ player sync (get_playlists_v2.py) ──▶ playlist.json ──▶ main.py renders
/api/playlists downloads files to media/ by file extension
```
Each playlist item the server returns currently looks like:
```json
{
"id": 42,
"file_name": "promo.jpg",
"type": "image",
"duration": 10,
"position": 1,
"url": "https://server/digiserver/static/uploads/promo.jpg",
"edit_on_player": false
}
```
The player:
1. **Syncs** (`src/get_playlists_v2.py``download_media_files()`): downloads
`url` into the local `media/` directory, then rewrites each item keeping only
`file_name`, `url` (now a **local relative path**), `duration`,
`edit_on_player`. **Note: the `type` field is currently discarded here.**
2. **Renders** (`src/main.py``play_current_media()`): opens the local file
and chooses a Kivy widget purely by **file extension**
(`.mp4/.avi/...``Video`, `.jpg/.png/...``AsyncImage`). Unknown
extensions are skipped as "unsupported".
A web link breaks all three assumptions: there is no file to download, no
extension to switch on, and no widget that renders a web page.
---
## 2. New server contract (what DigiServer will send)
A web-link playlist item will look like this:
```json
{
"id": 91,
"file_name": "weblink-3f9c1a2b",
"type": "weblink",
"duration": 30,
"position": 4,
"url": "https://example.com/dashboard",
"edit_on_player": false
}
```
Key differences vs. a file item:
| Field | File item | Web-link item |
|--------------|-----------------------------------|----------------------------------------|
| `type` | `image` / `video` | **`weblink`** |
| `url` | Path to a file on the server | **The web page to display (the link)** |
| `file_name` | Real filename on disk | Synthetic id (`weblink-<uuid>`), **no file exists** |
The player must branch on `type == "weblink"` and treat `url` as the page to
open — **never** try to download it as a file.
---
## 3. Required player changes
### 3.1 Sync step — `src/get_playlists_v2.py`
**Function:** `download_media_files(playlist, media_dir, ...)`
1. **Skip download for web links.** At the top of the per-item loop, detect
`media.get('type') == 'weblink'` and do **not** call `session.get()` / write
any file for it.
2. **Preserve `type` and the original `url`.** The `updated_media` dict that is
appended to `updated_playlist` currently drops `type` and rewrites `url` to a
local path. It must now carry `type` through, and for web links keep `url`
as the original web address (do not convert to a local relative path).
Suggested shape of the per-item logic:
```python
item_type = media.get('type', '')
if item_type == 'weblink':
# No file to download — pass the web link through unchanged.
updated_playlist.append({
'file_name': media.get('file_name', ''),
'type': 'weblink',
'url': media.get('url', ''), # the actual web page
'duration': media.get('duration', 10),
'edit_on_player': False,
})
continue
# ... existing download logic for file items ...
updated_playlist.append({
'file_name': file_name,
'type': item_type, # <-- now preserved
'url': os.path.relpath(local_path, os.path.dirname(media_dir)),
'duration': duration,
'edit_on_player': media.get('edit_on_player', False),
})
```
3. **`delete_unused_media()`** walks `media/` using `file_name`. Web links have
no file, so they simply won't match anything on disk — no change strictly
required, but make sure a missing local file for a `weblink` item does not
trigger a re-download or an error elsewhere.
### 3.2 Render step — `src/main.py`
**Function:** `play_current_media(self, force_reload=False)`
The current logic builds `media_path = os.path.join(self.media_dir, file_name)`
and then does `os.stat(media_path)` — which will fail for a web link (no file).
Add a **web-link branch before the file-existence check**:
```python
media_item = self.playlist[self.current_index]
file_name = media_item.get('file_name', '')
duration = media_item.get('duration', 10)
# NEW: handle web links before any file/path handling
if media_item.get('type') == 'weblink':
self.play_weblink(media_item.get('url', ''), duration)
return
# ... existing file existence check + extension branching ...
```
Then add a new method `play_weblink(self, url, duration)`:
- Validate the scheme is `http`/`https` (reject anything else, e.g. `file://`).
- Open the page for `duration` seconds, then advance with `self.next_media()`.
- Wrap in `try/except`; on failure increment `self.consecutive_errors` and call
`self.next_media()`, matching the existing error-handling pattern.
- Make sure the previous widget (`self.current_widget`) is removed/stopped just
like the image/video paths do.
#### Rendering approach (pick one)
Kivy has **no production-grade embedded web view**, especially on Raspberry Pi.
Recommended options, in order of robustness:
1. **Chromium kiosk overlay (recommended).** Launch Chromium over the Kivy
window for the item's duration, then close it and return to Kivy:
```python
import subprocess, shutil
from urllib.parse import urlparse
from kivy.clock import Clock
def play_weblink(self, url, duration):
scheme = urlparse(url).scheme.lower()
if scheme not in ('http', 'https'):
Logger.warning(f"SignagePlayer: Refusing non-http(s) weblink: {url}")
self.next_media()
return
try:
browser = shutil.which('chromium-browser') or shutil.which('chromium')
self._weblink_proc = subprocess.Popen([
browser,
'--kiosk', '--app=' + url,
'--noerrdialogs', '--disable-infobars',
'--incognito', '--no-first-run',
'--check-for-update-interval=31536000',
])
Clock.schedule_once(lambda dt: self._close_weblink_and_next(), duration)
except Exception as e:
Logger.error(f"SignagePlayer: Error opening weblink: {e}")
self.consecutive_errors += 1
self.next_media()
def _close_weblink_and_next(self):
proc = getattr(self, '_weblink_proc', None)
if proc and proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=5)
except Exception:
proc.kill()
self._weblink_proc = None
self.next_media()
```
Requirements / notes:
- Install Chromium on the player image (`chromium-browser` on Raspberry Pi OS).
- Ensure Chromium gets window focus over Kivy and is fully killed before the
next item, including on pause/stop/restart paths and on app shutdown
(`on_stop`) so no stray browser window is left behind.
- On Wayland/X11 the player already sets `SDL_VIDEODRIVER`; verify Chromium
launches on the same display/session.
2. **Embedded web view widget** (`kivy_garden.webview`, WebKit/GTK, or WebView2).
Cleaner UX (stays inside the Kivy widget tree) but fragile and poorly
supported on Pi/Wayland — only pursue if option 1 is unacceptable.
3. **Server-side screenshot fallback (no player change).** If embedding a live
browser is not desirable, DigiServer can periodically screenshot the URL and
store it as a normal `image` item; the player then needs no changes. This
loses live/animated content. Documented here for completeness only.
---
## 4. Checklist for the player update
- [ ] `get_playlists_v2.py`: skip download when `type == 'weblink'`.
- [ ] `get_playlists_v2.py`: preserve `type` in the rewritten playlist items
(fixes the current loss of `type`).
- [ ] `get_playlists_v2.py`: keep the original web `url` for weblink items.
- [ ] `main.py` `play_current_media()`: branch to `play_weblink()` **before** the
`os.stat()` file check.
- [ ] `main.py`: implement `play_weblink(url, duration)` (Chromium kiosk).
- [ ] `main.py`: validate scheme is `http`/`https`; reject others.
- [ ] Kill/cleanup the browser process on next item, pause, stop, restart, and
`on_stop`.
- [ ] Install Chromium on the player image / document it in the player README.
- [ ] Test: mixed playlist (image → video → weblink → image) cycles correctly
and respects per-item `duration`.
---
## 5. Security notes
- Only allow `http`/`https` schemes on both server and player; never open
`file://`, `chrome://`, etc.
- The server validates and stores the URL when the operator adds it; the player
should still re-validate the scheme before launching the browser (defence in
depth).
- Consider running Chromium with `--incognito` (no persistent cookies/cache) as
shown above.
+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):
@@ -278,6 +349,12 @@ def remove_content_from_playlist(playlist_id: int, content_id: int):
)
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()
cache.clear()
+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
@@ -23,6 +23,18 @@ class Config:
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)
SESSION_COOKIE_SECURE = False # Set to True in production with HTTPS
+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>
@@ -401,6 +408,22 @@
<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,
}
+188 -21
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,
)
# Write config file to remote host
# 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_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({
@@ -0,0 +1,34 @@
"""Add url column to content table for web link playlist items.
Web link items are stored as Content rows with content_type='weblink' and the
target web page URL in the new 'url' column (NULL for file-based content).
Run with: python migrations/add_weblink_url_to_content.py
"""
import sys
sys.path.insert(0, '/app')
from app.app import create_app
from app.extensions import db
from sqlalchemy import text
app = create_app()
with app.app_context():
print("Adding url column to content table...")
try:
inspector = db.inspect(db.engine)
columns = [col['name'] for col in inspector.get_columns('content')]
if 'url' not in columns:
with db.engine.connect() as conn:
conn.execute(text('ALTER TABLE content ADD COLUMN url VARCHAR(2048)'))
conn.commit()
print("\u2713 url column added to content table!")
else:
print("\u2713 url column already exists in content table.")
except Exception as e:
print(f"Error adding url column: {str(e)}")
sys.exit(1)
+23
View File
@@ -57,6 +57,29 @@ http {
proxy_read_timeout 60s;
}
# ── DigiServer Player device API shortcut (no portal auth) ────────────
# Players that call /api/ directly (without the /digiserver/ prefix)
# are routed here. Must be declared before the catch-all location /.
# X-Script-Name tells Flask its script_root so media URLs in playlist
# responses are built as /digiserver/static/uploads/... (routable by nginx).
location /api/ {
proxy_pass http://digiserver_upstream/api/;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Script-Name /digiserver;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
client_max_body_size 256M;
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# ── DigiServer Player device API (no portal auth) ──────────────────
# Players use their own Bearer token auth; portal JWT is not involved.
# This block MUST be declared before the broader /digiserver/ block.
+33 -1
View File
@@ -112,7 +112,7 @@ fi
# ── Generate runtime nginx config ────────────────────────────────────────────
NGINX_CONF="$ROOT/nginx/.dev-nginx.conf"
NGINX_LOGS="$ROOT/.dev-logs/nginx"
mkdir -p "$NGINX_LOGS"
mkdir -p "$NGINX_LOGS" "$NGINX_LOGS/body" "$NGINX_LOGS/proxy" "$NGINX_LOGS/fastcgi" "$NGINX_LOGS/scgi" "$NGINX_LOGS/uwsgi"
info "Writing nginx config → $NGINX_CONF"
cat > "$NGINX_CONF" <<NGINXEOF
@@ -128,6 +128,17 @@ http {
sendfile on;
keepalive_timeout 65;
# Run as an unprivileged user, so buffer request bodies / proxy temp files
# in a user-writable location instead of the root-owned /var/lib/nginx defaults.
client_body_temp_path $NGINX_LOGS/body;
proxy_temp_path $NGINX_LOGS/proxy;
fastcgi_temp_path $NGINX_LOGS/fastcgi;
scgi_temp_path $NGINX_LOGS/scgi;
uwsgi_temp_path $NGINX_LOGS/uwsgi;
# Allow large media uploads by default (overridden per-location as needed).
client_max_body_size 2048M;
access_log $NGINX_LOGS/access.log;
limit_req_zone \$binary_remote_addr zone=login:10m rate=10r/m;
@@ -144,6 +155,8 @@ http {
location = /portal-verify {
internal;
# Don't reject large upload bodies in the auth subrequest (body isn't forwarded).
client_max_body_size 2048M;
proxy_pass http://portal_upstream/api/verify-token;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
@@ -167,6 +180,25 @@ http {
proxy_read_timeout 60s;
}
# DigiServer — player API shortcut (no portal auth, no prefix)
# X-Script-Name tells Flask its script_root so media URLs in playlist
# responses are built as /digiserver/static/uploads/... (routable by nginx).
location /api/ {
proxy_pass http://digiserver_upstream/api/;
proxy_set_header Host \$http_host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Script-Name /digiserver;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
client_max_body_size 256M;
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# DigiServer — player API (no portal auth)
location /digiserver/api/ {
proxy_pass http://digiserver_upstream/api/;