Add weblink playlist support and fix offline playback recursion

- get_playlists_v2: pass through weblink items without download, preserve type
- main.py: render weblink items fullscreen via Chromium kiosk overlay with cleanup on next/pause/stop
- main.py: fix unbounded recursion when playlist media is missing/invalid by scheduling retries via Clock (keeps standard playlist cycling offline)
- docs: add PLAYER_WEBLINK_INTEGRATION.md
This commit is contained in:
Kiwy Signage Player
2026-06-28 13:46:29 +03:00
parent 2c31589787
commit abbd51a787
3 changed files with 413 additions and 21 deletions
+245
View File
@@ -0,0 +1,245 @@
# 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 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-<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.
+15
View File
@@ -243,6 +243,20 @@ def download_media_files(playlist, media_dir, ssl_manager=None, server_url=None)
file_name = media.get('file_name', '') file_name = media.get('file_name', '')
file_url = media.get('url', '') file_url = media.get('url', '')
duration = media.get('duration', 10) 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) local_path = os.path.join(media_dir, file_name)
logger.info(f"📥 Preparing to download {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) # (it might already exist or be available later)
updated_media = { updated_media = {
'file_name': file_name, 'file_name': file_name,
'type': item_type, # Preserve media type (image/video/...)
'url': os.path.relpath(local_path, os.path.dirname(media_dir)), 'url': os.path.relpath(local_path, os.path.dirname(media_dir)),
'duration': duration, 'duration': duration,
'edit_on_player': media.get('edit_on_player', False) # Preserve edit_on_player flag 'edit_on_player': media.get('edit_on_player', False) # Preserve edit_on_player flag
+153 -21
View File
@@ -912,6 +912,7 @@ class SignagePlayer(Widget):
self.playlist = [] self.playlist = []
self.current_index = 0 self.current_index = 0
self.current_widget = None self.current_widget = None
self._weblink_proc = None # Handle to the Chromium kiosk process for weblink items
self.is_playing = False self.is_playing = False
self.is_paused = False self.is_paused = False
self.auto_resume_event = None # Track scheduled auto-resume 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)") 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 # Construct full path to media file
media_path = os.path.join(self.media_dir, file_name) 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: ❌ Media file not found: {media_path}")
Logger.error(f"SignagePlayer: Skipping to next media...") Logger.error(f"SignagePlayer: Skipping to next media...")
self.consecutive_errors += 1 self.consecutive_errors += 1
self.next_media() self._skip_to_next_media()
return return
# Remove status label if showing # Remove status label if showing
self.ids.status_label.opacity = 0 self.ids.status_label.opacity = 0
# Remove previous media widget # Remove previous media widget
if self.current_widget: self._remove_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")
# Determine media type and create appropriate widget # Determine media type and create appropriate widget
file_extension = os.path.splitext(file_name)[1].lower() 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: Supported: .mp4/.avi/.mkv/.mov/.webm/.jpg/.jpeg/.png/.bmp/.gif/.webp")
Logger.warning(f"SignagePlayer: Skipping to next media...") Logger.warning(f"SignagePlayer: Skipping to next media...")
self.consecutive_errors += 1 self.consecutive_errors += 1
self.next_media() self._skip_to_next_media()
return return
# Send feedback to server asynchronously (non-blocking) # Send feedback to server asynchronously (non-blocking)
@@ -1372,7 +1383,7 @@ class SignagePlayer(Widget):
return return
self.show_error(f"Error playing media: {e}") self.show_error(f"Error playing media: {e}")
self.next_media() self._skip_to_next_media()
def play_video(self, video_path, duration): def play_video(self, video_path, duration):
"""Play a video file using Kivy's Video widget with optimizations""" """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): if not os.path.exists(video_path):
Logger.error(f"SignagePlayer: ❌ Video file not found: {video_path}") Logger.error(f"SignagePlayer: ❌ Video file not found: {video_path}")
self.consecutive_errors += 1 self.consecutive_errors += 1
self.next_media() self._skip_to_next_media()
return return
Logger.debug(f"SignagePlayer: Loading video {os.path.basename(video_path)} for {duration}s") 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: except Exception as e:
Logger.error(f"SignagePlayer: Error playing video {video_path}: {e}") Logger.error(f"SignagePlayer: Error playing video {video_path}: {e}")
self.consecutive_errors += 1 self.consecutive_errors += 1
if self.consecutive_errors < self.max_consecutive_errors: self._skip_to_next_media()
self.next_media()
def _on_video_eos(self, instance): def _on_video_eos(self, instance):
"""Callback when video reaches end of stream""" """Callback when video reaches end of stream"""
@@ -1476,8 +1486,120 @@ class SignagePlayer(Widget):
except Exception as e: except Exception as e:
Logger.error(f"SignagePlayer: Error playing image {image_path}: {e}") Logger.error(f"SignagePlayer: Error playing image {image_path}: {e}")
self.consecutive_errors += 1 self.consecutive_errors += 1
if self.consecutive_errors < self.max_consecutive_errors: self._skip_to_next_media()
self.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): def next_media(self, dt=None):
"""Move to next media item""" """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_normal = self.resources_path + '/play.png'
self.ids.play_pause_btn.background_down = self.resources_path + '/play.png' self.ids.play_pause_btn.background_down = self.resources_path + '/play.png'
Clock.unschedule(self.next_media) 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 # Cancel any existing auto-resume
if self.auto_resume_event: if self.auto_resume_event:
@@ -1936,6 +2060,14 @@ class SignagePlayerApp(App):
def on_stop(self): def on_stop(self):
Logger.info("SignagePlayerApp: Application stopped") 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 # Stop network monitoring
if hasattr(self.root, 'network_monitor') and self.root.network_monitor: if hasattr(self.root, 'network_monitor') and self.root.network_monitor:
self.root.network_monitor.stop_monitoring() self.root.network_monitor.stop_monitoring()