diff --git a/PLAYER_WEBLINK_INTEGRATION.md b/PLAYER_WEBLINK_INTEGRATION.md new file mode 100644 index 0000000..7f50c71 --- /dev/null +++ b/PLAYER_WEBLINK_INTEGRATION.md @@ -0,0 +1,245 @@ +# 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 will be updated to emit +> `weblink` items in the playlist API. The player does **not** yet support them. +> Use this guide to implement the player side later. + +--- + +## 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/src/get_playlists_v2.py b/src/get_playlists_v2.py index 8aa09d8..8d655cd 100644 --- a/src/get_playlists_v2.py +++ b/src/get_playlists_v2.py @@ -243,6 +243,20 @@ def download_media_files(playlist, media_dir, ssl_manager=None, server_url=None) file_name = media.get('file_name', '') file_url = media.get('url', '') duration = media.get('duration', 10) + item_type = media.get('type', '') + + # Web-link items have no file to download — pass the link through unchanged. + if item_type == 'weblink': + logger.info(f"🔗 Web link item (no download): {file_url}") + updated_playlist.append({ + 'file_name': file_name, + 'type': 'weblink', + 'url': file_url, # keep the original web address (not a local path) + 'duration': duration, + 'edit_on_player': False, + }) + continue + local_path = os.path.join(media_dir, file_name) logger.info(f"📥 Preparing to download {file_name}...") @@ -307,6 +321,7 @@ def download_media_files(playlist, media_dir, ssl_manager=None, server_url=None) # (it might already exist or be available later) updated_media = { 'file_name': file_name, + 'type': item_type, # Preserve media type (image/video/...) 'url': os.path.relpath(local_path, os.path.dirname(media_dir)), 'duration': duration, 'edit_on_player': media.get('edit_on_player', False) # Preserve edit_on_player flag diff --git a/src/main.py b/src/main.py index c3d5021..64c895e 100644 --- a/src/main.py +++ b/src/main.py @@ -912,6 +912,7 @@ class SignagePlayer(Widget): self.playlist = [] self.current_index = 0 self.current_widget = None + self._weblink_proc = None # Handle to the Chromium kiosk process for weblink items self.is_playing = False self.is_paused = False self.auto_resume_event = None # Track scheduled auto-resume @@ -1293,6 +1294,28 @@ class SignagePlayer(Widget): Logger.info(f"SignagePlayer: Playing item {self.current_index + 1}/{len(self.playlist)}: {file_name} ({duration}s)") + # Close any kiosk browser left over from a previous web-link item + self._kill_weblink_process() + + # Handle web links before any file/path handling (no local file exists) + if media_item.get('type') == 'weblink': + Logger.debug("SignagePlayer: Media type: WEBLINK") + self.ids.status_label.opacity = 0 + self._remove_current_widget() + started = self.play_weblink(media_item.get('url', ''), duration) + if started: + self.consecutive_errors = 0 + if self.config: + asyncio.ensure_future( + self.async_send_feedback( + send_playing_status_feedback, + self.config, + self.playlist_version, + file_name + ) + ) + return + # Construct full path to media file media_path = os.path.join(self.media_dir, file_name) @@ -1304,26 +1327,14 @@ class SignagePlayer(Widget): Logger.error(f"SignagePlayer: ❌ Media file not found: {media_path}") Logger.error(f"SignagePlayer: Skipping to next media...") self.consecutive_errors += 1 - self.next_media() + self._skip_to_next_media() return # Remove status label if showing self.ids.status_label.opacity = 0 # Remove previous media widget - if self.current_widget: - # Properly stop video if it's playing to prevent resource leaks - if isinstance(self.current_widget, Video): - try: - Logger.debug(f"SignagePlayer: Stopping previous video widget...") - self.current_widget.state = 'stop' - self.current_widget.unload() - except Exception as e: - Logger.warning(f"SignagePlayer: Error stopping video: {e}") - - self.ids.content_area.remove_widget(self.current_widget) - self.current_widget = None - Logger.debug(f"SignagePlayer: Previous widget removed") + self._remove_current_widget() # Determine media type and create appropriate widget file_extension = os.path.splitext(file_name)[1].lower() @@ -1341,7 +1352,7 @@ class SignagePlayer(Widget): Logger.warning(f"SignagePlayer: Supported: .mp4/.avi/.mkv/.mov/.webm/.jpg/.jpeg/.png/.bmp/.gif/.webp") Logger.warning(f"SignagePlayer: Skipping to next media...") self.consecutive_errors += 1 - self.next_media() + self._skip_to_next_media() return # Send feedback to server asynchronously (non-blocking) @@ -1372,7 +1383,7 @@ class SignagePlayer(Widget): return self.show_error(f"Error playing media: {e}") - self.next_media() + self._skip_to_next_media() def play_video(self, video_path, duration): """Play a video file using Kivy's Video widget with optimizations""" @@ -1381,7 +1392,7 @@ class SignagePlayer(Widget): if not os.path.exists(video_path): Logger.error(f"SignagePlayer: ❌ Video file not found: {video_path}") self.consecutive_errors += 1 - self.next_media() + self._skip_to_next_media() return Logger.debug(f"SignagePlayer: Loading video {os.path.basename(video_path)} for {duration}s") @@ -1423,8 +1434,7 @@ class SignagePlayer(Widget): except Exception as e: Logger.error(f"SignagePlayer: Error playing video {video_path}: {e}") self.consecutive_errors += 1 - if self.consecutive_errors < self.max_consecutive_errors: - self.next_media() + self._skip_to_next_media() def _on_video_eos(self, instance): """Callback when video reaches end of stream""" @@ -1476,8 +1486,120 @@ class SignagePlayer(Widget): except Exception as e: Logger.error(f"SignagePlayer: Error playing image {image_path}: {e}") self.consecutive_errors += 1 - if self.consecutive_errors < self.max_consecutive_errors: - self.next_media() + self._skip_to_next_media() + + def _remove_current_widget(self): + """Stop and remove the current Kivy media widget if one is present.""" + if self.current_widget: + # Properly stop video if it's playing to prevent resource leaks + if isinstance(self.current_widget, Video): + try: + Logger.debug("SignagePlayer: Stopping previous video widget...") + self.current_widget.state = 'stop' + self.current_widget.unload() + except Exception as e: + Logger.warning(f"SignagePlayer: Error stopping video: {e}") + self.ids.content_area.remove_widget(self.current_widget) + self.current_widget = None + Logger.debug("SignagePlayer: Previous widget removed") + + def play_weblink(self, url, duration): + """Display a live web page fullscreen using a Chromium kiosk overlay. + + Kivy has no production-grade embedded web view on Raspberry Pi, so we + launch Chromium in kiosk mode over the Kivy window for the item's + duration, then close it and advance to the next item. + + Returns True if the browser was launched, False otherwise. + """ + import shutil + import subprocess + from urllib.parse import urlparse + + # Defence in depth: only ever open http/https links. + scheme = urlparse(url).scheme.lower() + if scheme not in ('http', 'https'): + Logger.warning(f"SignagePlayer: Refusing non-http(s) weblink: {url}") + self.consecutive_errors += 1 + self._skip_to_next_media() + return False + + browser = shutil.which('chromium-browser') or shutil.which('chromium') + if not browser: + Logger.error("SignagePlayer: Chromium not installed; cannot display weblink") + self.consecutive_errors += 1 + self._skip_to_next_media() + return False + + try: + Logger.info(f"SignagePlayer: Opening weblink in kiosk browser for {duration}s: {url}") + self._weblink_proc = subprocess.Popen([ + browser, + '--kiosk', + '--app=' + url, + '--noerrdialogs', + '--disable-infobars', + '--incognito', + '--no-first-run', + '--disable-session-crashed-bubble', + '--check-for-update-interval=31536000', + ]) + + # Advance after the configured duration. The kiosk browser is closed + # at the start of the next play_current_media() via _kill_weblink_process(). + Clock.unschedule(self.next_media) + Clock.schedule_once(self.next_media, duration) + + # Preload the next image so the transition after the weblink is smooth. + self.preload_next_media() + return True + except Exception as e: + Logger.error(f"SignagePlayer: Error opening weblink {url}: {e}") + self.consecutive_errors += 1 + self._weblink_proc = None + self._skip_to_next_media() + return False + + def _kill_weblink_process(self): + """Terminate the kiosk browser process if one is running.""" + proc = getattr(self, '_weblink_proc', None) + if proc is not None and proc.poll() is None: + try: + proc.terminate() + try: + proc.wait(timeout=5) + except Exception: + proc.kill() + Logger.debug("SignagePlayer: Closed weblink kiosk browser") + except Exception as e: + Logger.warning(f"SignagePlayer: Error closing weblink browser: {e}") + self._weblink_proc = None + + def _skip_to_next_media(self): + """Advance past a failed item WITHOUT recursing. + + Error paths used to call next_media() synchronously, which re-entered + play_current_media() immediately. With a playlist whose files are all + missing/invalid (e.g. offline before the first media sync) this recursed + until Python's stack limit ('maximum recursion depth exceeded') and + crashed playback. Scheduling via the Kivy Clock unwinds the stack + between attempts and throttles retries so the standard playlist keeps + cycling and automatically resumes once content is available. + """ + Clock.unschedule(self.next_media) + if self.consecutive_errors >= self.max_consecutive_errors: + msg = "No playable media yet - waiting for content to sync..." + Logger.warning(f"SignagePlayer: {msg} ({self.consecutive_errors} errors)") + try: + self.ids.status_label.text = msg + self.ids.status_label.opacity = 1 + except Exception: + pass + # Reset and retry slowly instead of stopping forever + self.consecutive_errors = 0 + Clock.schedule_once(self.next_media, 30) + else: + Clock.schedule_once(self.next_media, 1) def next_media(self, dt=None): """Move to next media item""" @@ -1553,6 +1675,8 @@ class SignagePlayer(Widget): self.ids.play_pause_btn.background_normal = self.resources_path + '/play.png' self.ids.play_pause_btn.background_down = self.resources_path + '/play.png' Clock.unschedule(self.next_media) + # Close any kiosk browser so the player controls are visible while paused + self._kill_weblink_process() # Cancel any existing auto-resume if self.auto_resume_event: @@ -1936,6 +2060,14 @@ class SignagePlayerApp(App): def on_stop(self): Logger.info("SignagePlayerApp: Application stopped") + # Close any kiosk browser opened for a weblink item + try: + if self.root and hasattr(self.root, '_kill_weblink_process'): + self.root._kill_weblink_process() + Logger.info("SignagePlayerApp: Weblink browser closed") + except Exception as e: + Logger.debug(f"SignagePlayerApp: Error closing weblink browser: {e}") + # Stop network monitoring if hasattr(self.root, 'network_monitor') and self.root.network_monitor: self.root.network_monitor.stop_monitoring()