diff --git a/digiserver-v2/PLAYER_WEBLINK_INTEGRATION.md b/digiserver-v2/PLAYER_WEBLINK_INTEGRATION.md new file mode 100644 index 0000000..89b3523 --- /dev/null +++ b/digiserver-v2/PLAYER_WEBLINK_INTEGRATION.md @@ -0,0 +1,246 @@ +# Web Link Playlist Items — Player Integration Guide + +This document describes the changes required on the **Kiwy-Signage player** +() 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-`), **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. diff --git a/digiserver-v2/app/blueprints/admin.py b/digiserver-v2/app/blueprints/admin.py index 8bad469..387251b 100644 --- a/digiserver-v2/app/blueprints/admin.py +++ b/digiserver-v2/app/blueprints/admin.py @@ -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')) + diff --git a/digiserver-v2/app/blueprints/api.py b/digiserver-v2/app/blueprints/api.py index 7feb437..f9893da 100644 --- a/digiserver-v2/app/blueprints/api.py +++ b/digiserver-v2/app/blueprints/api.py @@ -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) }) diff --git a/digiserver-v2/app/blueprints/content.py b/digiserver-v2/app/blueprints/content.py index ff45392..6ce77d5 100644 --- a/digiserver-v2/app/blueprints/content.py +++ b/digiserver-v2/app/blueprints/content.py @@ -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//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//remove-content/', 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() diff --git a/digiserver-v2/app/blueprints/players.py b/digiserver-v2/app/blueprints/players.py index 2f62a9a..02b14c1 100644 --- a/digiserver-v2/app/blueprints/players.py +++ b/digiserver-v2/app/blueprints/players.py @@ -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}') diff --git a/digiserver-v2/app/config.py b/digiserver-v2/app/config.py index 1842ba1..86cb4d7 100644 --- a/digiserver-v2/app/config.py +++ b/digiserver-v2/app/config.py @@ -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 /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) diff --git a/digiserver-v2/app/models/content.py b/digiserver-v2/app/models/content.py index d2644f9..f9cd31d 100644 --- a/digiserver-v2/app/models/content.py +++ b/digiserver-v2/app/models/content.py @@ -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' diff --git a/digiserver-v2/app/templates/admin/admin.html b/digiserver-v2/app/templates/admin/admin.html index 70845d5..34567c2 100644 --- a/digiserver-v2/app/templates/admin/admin.html +++ b/digiserver-v2/app/templates/admin/admin.html @@ -116,6 +116,17 @@ + + +
+

🧰 Build Player Files

+

Pull the latest player code and bake its server config for deployment

+ +
{% endif %} diff --git a/digiserver-v2/app/templates/admin/build_player.html b/digiserver-v2/app/templates/admin/build_player.html new file mode 100644 index 0000000..b91a9fe --- /dev/null +++ b/digiserver-v2/app/templates/admin/build_player.html @@ -0,0 +1,130 @@ +{% extends "base.html" %} + +{% block title %}Build Player Files - DigiServer v2{% endblock %} + +{% block content %} +
+ + + +
+

Current Staged Player Code

+ {% if code_status.available %} +

✅ Ready

+
    +
  • Version (git): {{ code_status.version }}
  • +
  • Size: {{ code_status.size }}
  • + {% if code_status.updated_str %} +
  • Last updated: {{ code_status.updated_str }}
  • + {% endif %} +
  • Path: {{ code_status.path }}
  • +
+ {% else %} +

+ ⚠️ Not staged + {{ code_status.reason }} +

+

Path: {{ code_status.path }}

+ {% endif %} + {% if settings.built_at %} +

+ Last build saved: {{ settings.built_at }} + {% if settings.built_by %}by {{ settings.built_by }}{% endif %} +

+ {% endif %} +
+ +
+ +
+

1. Player Files Repository

+
+ + + Cloned/refreshed into the staged directory on this server. +
+
+ + +
+
+ + +
+

2. Player → Server Configuration

+

+ The player contacts the DigiServer API directly at + {scheme}://{server}:{port}/api/... (no /digiserver path). + Enter the address players can reach this server on. +

+
+ + +
+
+ + + Use 443 for HTTPS, 80 for plain HTTP, or your custom port (e.g. 8080). +
+
+ +
+
+ + Leave off for self-signed certificates. +
+
+ + +
+
+ + +
+

+ ℹ️ The per-player screen name and quick-connect code + are filled in automatically for each device during SSH deployment. +

+
+ + +
+

3. Build

+
+ + + +
+
+
+
+{% endblock %} diff --git a/digiserver-v2/app/templates/content/manage_playlist_content.html b/digiserver-v2/app/templates/content/manage_playlist_content.html index 3d3e3b8..7c1a1c5 100644 --- a/digiserver-v2/app/templates/content/manage_playlist_content.html +++ b/digiserver-v2/app/templates/content/manage_playlist_content.html @@ -314,11 +314,18 @@ ⋮⋮ {{ loop.index }} - {{ content.filename }} + + {% if content.content_type == 'weblink' %} + {{ content.url }} + {% else %} + {{ content.filename }} + {% endif %} + {% 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 %} @@ -400,7 +407,23 @@

➕ Add Content

- + +
+

🔗 Add Web Link

+
+ +
+ + + +
+
+
+ {% if 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'); diff --git a/digiserver-v2/app/utils/background_tasks.py b/digiserver-v2/app/utils/background_tasks.py index cad3093..5ce5ad1 100644 --- a/digiserver-v2/app/utils/background_tasks.py +++ b/digiserver-v2/app/utils/background_tasks.py @@ -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 diff --git a/digiserver-v2/app/utils/player_build.py b/digiserver-v2/app/utils/player_build.py new file mode 100644 index 0000000..3086237 --- /dev/null +++ b/digiserver-v2/app/utils/player_build.py @@ -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, + } diff --git a/digiserver-v2/app/utils/ssh_deploy.py b/digiserver-v2/app/utils/ssh_deploy.py index 5e9133c..f26a0b8 100644 --- a/digiserver-v2/app/utils/ssh_deploy.py +++ b/digiserver-v2/app/utils/ssh_deploy.py @@ -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({ diff --git a/digiserver-v2/migrations/add_weblink_url_to_content.py b/digiserver-v2/migrations/add_weblink_url_to_content.py new file mode 100644 index 0000000..dc831cc --- /dev/null +++ b/digiserver-v2/migrations/add_weblink_url_to_content.py @@ -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) diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 3eb22d1..f753c23 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -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. diff --git a/start-dev.sh b/start-dev.sh index f2f06e0..57c886d 100755 --- a/start-dev.sh +++ b/start-dev.sh @@ -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" <