fix: player connectivity and media download pipeline
- nginx: add /api/ shortcut block (no portal auth, X-Script-Name /digiserver, Host $http_host) so players can reach DigiServer API without /digiserver prefix - nginx: use $http_host in /api/ block so Flask host_url includes port — fixes media download URLs missing :8080 (was http://ip/digiserver/... not http://ip:8080/...) - player main.py: fix double-port bug when server_ip already contains a port (e.g. 192.168.0.230:8080 was producing http://192.168.0.230:8080:80) - get_playlists_v2.py: force re-sync when server version differs OR local media files are missing on disk — fixes stale playlist after server reset - digiserver api.py: playlist endpoint builds full media URLs using script_root from X-Script-Name header set by nginx - weblink support, player build/deploy improvements, manage-playlist AJAX prefix fix
This commit is contained in:
@@ -0,0 +1,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.
|
||||
@@ -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}')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 & 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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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/;
|
||||
|
||||
Reference in New Issue
Block a user