Fix pre-existing player issues: edit upload paths, playlist sync, DPI, console
src/edit_popup.py: pass and reproduce the server-side edited-media layout
('edited_media/<media_id>/') when saving an edit, falling back to the flat
folder when no media id is available, so uploads land where the server expects.
src/get_playlists_v2.py: preserve every server field (audio, muted, description,
id, position, ...) when rewriting playlist items, instead of rebuilding fixed
dicts that silently dropped them. Web-link items keep their original http(s) url
and are never downloaded.
windows/pyi_runtime_hook.py: declare per-monitor DPI awareness before SDL/Kivy
initialise, so on a scaled display the window is not virtualised to a smaller
resolution (which left a black strip and mis-scaled media).
windows/build_win.bat: optional code signing step for PCs with Smart App Control
enabled, driven by KIWY_SIGN_PFX / KIWY_SIGN_PFX_PASSWORD or a local
kiwy_signing.pfx.
windows/build.spec, windows/README_WINDOWS_BUILD.md, windows/development-track.md:
build notes and manifest updates.
This commit is contained in:
+70
-8
@@ -82,11 +82,17 @@ class DrawingLayer(Widget):
|
||||
|
||||
class EditPopup(Popup):
|
||||
"""Popup for editing/annotating images"""
|
||||
def __init__(self, player_instance, image_path, user_card_data=None, **kwargs):
|
||||
def __init__(self, player_instance, image_path, user_card_data=None,
|
||||
media_id=None, original_filename=None, **kwargs):
|
||||
super(EditPopup, self).__init__(**kwargs)
|
||||
self.player = player_instance
|
||||
self.image_path = image_path
|
||||
self.user_card_data = user_card_data # Store card data to send to server on save
|
||||
# Server naming context: which media item (id) is being edited and what
|
||||
# its original file name is on the server. The server stores edited
|
||||
# media under 'edited_media/<media_id>/', so we must reproduce that.
|
||||
self.media_id = media_id
|
||||
self.original_filename = original_filename # server-side file_name
|
||||
|
||||
# Auto-close timer (5 minutes)
|
||||
self.auto_close_timeout = 300 # 5 minutes in seconds
|
||||
@@ -259,8 +265,15 @@ class EditPopup(Popup):
|
||||
def save_image(self, instance):
|
||||
"""Save the edited image"""
|
||||
try:
|
||||
# Create edited_media directory if it doesn't exist
|
||||
edited_dir = os.path.join(self.player.base_dir, 'media', 'edited_media')
|
||||
# Edited media is stored on the server under
|
||||
# 'edited_media/<media_id>/'. Reproduce that subfolder locally so
|
||||
# the upload naming matches what the server expects. Fall back to
|
||||
# the flat 'edited_media/' folder when no media_id is available.
|
||||
edited_base = os.path.join(self.player.base_dir, 'media', 'edited_media')
|
||||
if self.media_id is not None:
|
||||
edited_dir = os.path.join(edited_base, str(self.media_id))
|
||||
else:
|
||||
edited_dir = edited_base
|
||||
os.makedirs(edited_dir, exist_ok=True)
|
||||
|
||||
# Get original filename
|
||||
@@ -310,8 +323,22 @@ class EditPopup(Popup):
|
||||
# Overwrite the file
|
||||
shutil.copy2(output_path, self.image_path)
|
||||
|
||||
# Force file system sync to ensure data is written to disk
|
||||
# Force file system sync to ensure data is written to disk.
|
||||
# NOTE: os.sync() is Linux-only and raises AttributeError on
|
||||
# Windows — that used to abort the whole pipeline before the
|
||||
# metadata/upload steps. Use a cross-platform fsync that is
|
||||
# best-effort and can never break the save/upload flow.
|
||||
try:
|
||||
if hasattr(os, 'sync'):
|
||||
os.sync()
|
||||
else:
|
||||
with open(output_path, 'rb') as _f:
|
||||
try:
|
||||
os.fsync(_f.fileno())
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as _sync_err:
|
||||
Logger.warning(f"EditPopup: File sync skipped ({_sync_err})")
|
||||
|
||||
# Verify the overwrite
|
||||
new_size = os.path.getsize(self.image_path)
|
||||
@@ -326,9 +353,15 @@ class EditPopup(Popup):
|
||||
self.ids.top_toolbar.opacity = 1
|
||||
self.ids.right_sidebar.opacity = 1
|
||||
|
||||
# Create and save metadata
|
||||
# Create and save metadata. This runs in its own guarded
|
||||
# block so that a failure here cannot silently stop the
|
||||
# upload — the two steps are intentionally decoupled.
|
||||
json_filename = None
|
||||
try:
|
||||
json_filename = self._save_metadata(edited_dir, new_name, base_name,
|
||||
new_version if version_match else 1, output_filename)
|
||||
except Exception as meta_err:
|
||||
Logger.error(f"EditPopup: Metadata save failed: {meta_err}")
|
||||
|
||||
# Upload to server in background (continues after popup closes)
|
||||
upload_thread = threading.Thread(
|
||||
@@ -414,6 +447,14 @@ class EditPopup(Popup):
|
||||
'version': version,
|
||||
'user_card_data': self.user_card_data # Card data from reader (or None)
|
||||
}
|
||||
# Include the server-side file name and media id so the server can
|
||||
# attach the edit to the correct media item.
|
||||
if self.original_filename:
|
||||
metadata['original_filename'] = self.original_filename
|
||||
else:
|
||||
metadata['original_filename'] = os.path.basename(self.image_path)
|
||||
if self.media_id is not None:
|
||||
metadata['media_id'] = self.media_id
|
||||
|
||||
# Save metadata JSON
|
||||
json_filename = f"{new_name}_metadata.json"
|
||||
@@ -444,16 +485,37 @@ class EditPopup(Popup):
|
||||
Logger.warning("EditPopup: Missing server URL or auth code (upload skipped)")
|
||||
return False
|
||||
|
||||
# Load metadata from file
|
||||
# Load metadata from file (or build it in memory if the metadata
|
||||
# file was not written — the upload must still go through).
|
||||
metadata = None
|
||||
if metadata_path and os.path.exists(metadata_path):
|
||||
try:
|
||||
with open(metadata_path, 'r') as meta_file:
|
||||
metadata = json.load(meta_file)
|
||||
except Exception as e:
|
||||
Logger.warning(f"EditPopup: Could not read metadata file: {e}")
|
||||
if not metadata:
|
||||
metadata = {
|
||||
'time_of_modification': datetime.now().isoformat(),
|
||||
'original_name': os.path.basename(image_path),
|
||||
'new_name': os.path.basename(image_path),
|
||||
'version': 1,
|
||||
'user_card_data': self.user_card_data,
|
||||
}
|
||||
if self.original_filename:
|
||||
metadata['original_filename'] = self.original_filename
|
||||
if self.media_id is not None:
|
||||
metadata['media_id'] = self.media_id
|
||||
|
||||
# Prepare upload URL - send to the original file endpoint
|
||||
upload_url = f"{server_url}/api/player-edit-media"
|
||||
headers = {'Authorization': f'Bearer {auth_code}'}
|
||||
|
||||
# Add the original filename to metadata so server knows which file was edited
|
||||
metadata['original_filename'] = os.path.basename(metadata['original_path'])
|
||||
# Ensure the original filename (server-side name) is present so the
|
||||
# server knows which file was edited. Prefer the media context we
|
||||
# captured when the edit popup opened.
|
||||
if not metadata.get('original_filename'):
|
||||
metadata['original_filename'] = os.path.basename(metadata.get('original_path', image_path))
|
||||
|
||||
# Disable SSL verification for self-signed certificates (like main code does)
|
||||
# Note: This is NOT recommended for production with untrusted servers
|
||||
|
||||
+12
-15
@@ -248,13 +248,11 @@ def download_media_files(playlist, media_dir, ssl_manager=None, server_url=None)
|
||||
# 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,
|
||||
})
|
||||
# Preserve every server field (audio/muted/description/id/position/...)
|
||||
# instead of rebuilding a fixed dict, so nothing is silently dropped.
|
||||
weblink_item = dict(media)
|
||||
weblink_item['type'] = 'weblink'
|
||||
updated_playlist.append(weblink_item)
|
||||
continue
|
||||
|
||||
local_path = os.path.join(media_dir, file_name)
|
||||
@@ -318,14 +316,13 @@ def download_media_files(playlist, media_dir, ssl_manager=None, server_url=None)
|
||||
# Don't skip - may still add to playlist
|
||||
|
||||
# Always add the media item to the playlist, even if download failed
|
||||
# (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
|
||||
}
|
||||
# (it might already exist or be available later).
|
||||
# Preserve EVERY server field (audio/muted/description/id/position/...)
|
||||
# by copying the original dict and only overriding the URL with the
|
||||
# local path — previously the fixed dict below dropped `audio`, `muted`,
|
||||
# `description`, `id` and `position` from the saved playlist.
|
||||
updated_media = dict(media)
|
||||
updated_media['url'] = os.path.relpath(local_path, os.path.dirname(media_dir))
|
||||
updated_playlist.append(updated_media)
|
||||
|
||||
return updated_playlist
|
||||
|
||||
@@ -10,7 +10,7 @@ The original app was built for **Raspberry Pi (Linux)**, using these technologie
|
||||
|-----------|---------------------|-------------------|
|
||||
| **GUI** | Kivy 2.3+ | Kivy 2.3+ (works cross-platform) |
|
||||
| **Video** | ffpyplayer | ffpyplayer (needs FFmpeg DLLs) |
|
||||
| **Card Reader** | evdev (Linux input) | **Not available** — gracefully disabled |
|
||||
| **Card Reader** | evdev (Linux input) | ✅ Raw Input API + LL-hook fallback |
|
||||
| **Screen Keep-Awake** | xset, xdotool, Wayland | `SetThreadExecutionState` (Win32 API) |
|
||||
| **Weblink** | chromium-browser (kiosk) | Chrome/Edge (--kiosk mode) |
|
||||
| **Audio** | ALSA/PulseAudio | DirectSound |
|
||||
@@ -27,9 +27,9 @@ The original app was built for **Raspberry Pi (Linux)**, using these technologie
|
||||
- ✅ Web links (opens in Chrome/Edge kiosk)
|
||||
- ✅ Network monitoring
|
||||
- ✅ Auto-update playlist
|
||||
- ✅ Card reader authentication (Raw Input API — see below)
|
||||
|
||||
### What is disabled on Windows
|
||||
- ❌ Card reader (evdev is Linux-only; `EVDEV_AVAILABLE = False`)
|
||||
- ❌ HDMI power management (tvservice is RPi-specific)
|
||||
- ❌ WiFi restart (uses Linux `nmcli`)
|
||||
|
||||
@@ -112,6 +112,50 @@ For a **single-file .exe**, edit `build.spec` — uncomment the `exe_onefile` se
|
||||
}
|
||||
```
|
||||
|
||||
## 💳 Card Reader (Windows Edition)
|
||||
|
||||
The card reader now works on Windows via the **Raw Input API** (with a
|
||||
low-level keyboard-hook fallback). It replaces the Linux-only `evdev`
|
||||
implementation automatically when `run_win.py` starts.
|
||||
|
||||
- Detection mirrors the Linux logic:
|
||||
1. A device named with `card` / `reader` / `rfid`
|
||||
2. A USB HID keyboard (non-PS/2) — most card readers enumerate this way
|
||||
3. Any remaining keyboard (excluding touchscreens/mice)
|
||||
- Only keystrokes from the **selected device** are captured, so the
|
||||
operator's real keyboard cannot pollute card data.
|
||||
- Card data ends on **Enter** (same as Linux).
|
||||
|
||||
### Card reader config (optional)
|
||||
|
||||
Add any of these to `config\app_config.json` next to the .exe:
|
||||
|
||||
```json
|
||||
{
|
||||
"card_reader_mode": "auto", // "auto" | "raw" | "hook"
|
||||
"card_reader_device": "", // e.g. "VID_08FF" to force a specific device
|
||||
"card_reader_timeout": 5 // seconds
|
||||
}
|
||||
```
|
||||
|
||||
- `card_reader_mode`: `auto` (default, tries Raw Input then falls back),
|
||||
`raw` (force Raw Input), or `hook` (force the low-level keyboard hook).
|
||||
- `card_reader_device`: optional substring of the device name to pin the
|
||||
reader (e.g. `VID_08FF`, `HID#VID_08FF`). Run the manual test below to see
|
||||
the exact device names on your host.
|
||||
- `card_reader_timeout`: how long the swipe popup waits (default 5 s).
|
||||
|
||||
### Manual card reader test (no GUI)
|
||||
|
||||
```batch
|
||||
cd windows
|
||||
venv\Scripts\activate
|
||||
python win_card_reader.py
|
||||
```
|
||||
|
||||
Swipe a card within 10 seconds — the tool prints the captured data, then
|
||||
exits. The detected devices are listed in the console/log.
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```batch
|
||||
|
||||
@@ -107,6 +107,38 @@ if %ERRORLEVEL% neq 0 (
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM ---- Optional code signing ----------------------------------------
|
||||
REM For production PCs with Smart App Control ON, the exe MUST be signed
|
||||
REM by a cert from a reputable public CA. If you have a .pfx, put its path
|
||||
REM in the env var KIWY_SIGN_PFX (and optionally KIWY_SIGN_PFX_PASSWORD),
|
||||
REM or drop a pfx named "kiwy_signing.pfx" in this folder. The build will
|
||||
REM then auto-sign via sign_exe.ps1.
|
||||
echo.
|
||||
echo [STEP] Checking for code-signing certificate...
|
||||
|
||||
set "SIGN_PFX=%KIWY_SIGN_PFX%"
|
||||
if not defined SIGN_PFX if exist "%~dp0kiwy_signing.pfx" set "SIGN_PFX=%~dp0kiwy_signing.pfx"
|
||||
|
||||
if defined SIGN_PFX (
|
||||
echo [INFO ] Code-signing cert found: %SIGN_PFX%
|
||||
if defined KIWY_SIGN_PFX_PASSWORD (
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0sign_exe.ps1" -CertPath "%SIGN_PFX%" -CertPassword "%KIWY_SIGN_PFX_PASSWORD%"
|
||||
) else (
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0sign_exe.ps1" -CertPath "%SIGN_PFX%"
|
||||
)
|
||||
if not "!ERRORLEVEL!"=="0" (
|
||||
echo [WARNING] Signing failed or signtool missing - the exe is NOT signed.
|
||||
echo Smart App Control machines will still block it.
|
||||
) else (
|
||||
echo [OK] Executable signed successfully.
|
||||
)
|
||||
) else (
|
||||
echo [INFO ] No signing cert found - skipping signing.
|
||||
echo [INFO ] To sign automatically, set KIWY_SIGN_PFX to your .pfx path
|
||||
echo or place "kiwy_signing.pfx" in this folder.
|
||||
echo [INFO ] NOTE: Unsigned exe will be BLOCKED on PCs with Smart App Control ON.
|
||||
)
|
||||
|
||||
REM ---- Success ----
|
||||
echo.
|
||||
echo ============================================
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 📅 Current Session — 2026-07-31
|
||||
## 📅 Current Session — 2026-08-07
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
@@ -14,9 +14,37 @@
|
||||
| **Python** | 3.12.9 — `windows\venv\` (250 MB, all deps installed) |
|
||||
| **Kivy** | 2.3.1 |
|
||||
| **PyInstaller** | 6.21.0 |
|
||||
| **Last .exe build** | 2026-07-26 16:53 — `windows\dist\KiwySignagePlayer\KiwySignagePlayer.exe` (96 MB) |
|
||||
| **Last .exe build** | 2026-08-07 08:23 — `windows\dist\KiwySignagePlayer\KiwySignagePlayer.exe` (98.8 MB) |
|
||||
| **Build command** | `.\venv\Scripts\python.exe -m PyInstaller build.spec --clean --noconfirm` |
|
||||
|
||||
### Overnight soak test findings (2026-08-07 morning)
|
||||
|
||||
- **Symptom:** player froze on the last video widget (never advanced past
|
||||
`video_loaded`); heartbeat stopped; 7 leaked `msedge.exe` processes left
|
||||
running in the background.
|
||||
- **Root cause (two compounding bugs in `run_win.py`):**
|
||||
1. **Leaked browser + instant-exit handoff.** A previously leaked Chrome/Edge
|
||||
process held the `.kiosk-profile` lock. The next weblink launch handed
|
||||
the URL to that leaked instance and **exited in ~2s** (`07:04:57` launch →
|
||||
`07:04:59 next_media_called`). The watchdog advanced instantly, so the
|
||||
weblink never showed AND the leaked browser window was never killed →
|
||||
`msedge.exe` processes accumulated overnight.
|
||||
2. **Main-thread freeze.** The focus keeper ran heavy Win32 work
|
||||
(`EnumWindows` + `AttachThreadInput` + `SetForegroundWindow` + `SendInput`)
|
||||
synchronously on the Kivy thread every second. With leaked Edge windows
|
||||
fighting back, this wedged the event loop → video never advanced, heartbeat
|
||||
stopped (`08-07 07:09`).
|
||||
- **Fix (in `run_win.py`, rebuilt 08:23):**
|
||||
1. New `_windows_kill_browsers_on_profile()` — scans `chrome/msedge/chromium`
|
||||
command lines (WMIC, PowerShell fallback), taskkills any browser holding
|
||||
the `.kiosk-profile` lock. Called **before every weblink launch**.
|
||||
2. Watchdog now has `MIN_ALIVE_BEFORE_EARLY_ADVANCE = 8s` — an instant
|
||||
(~2s) handoff exit no longer advances/skips the weblink.
|
||||
3. `_bring_kivy_to_front(async_ok=True)` runs the heavy Win32 bring-to-front
|
||||
on a **background worker thread** guarded by a lock, so the Kivy main
|
||||
thread is never blocked. Synchronous `async_ok=False` still available for
|
||||
explicit transitions.
|
||||
|
||||
### 📋 Cross-platform audit — Linux commands → Windows handling
|
||||
|
||||
Every Linux-only command in `src/` was cross-referenced against the patches
|
||||
|
||||
@@ -10,6 +10,37 @@ import sys
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _set_process_dpi_awareness():
|
||||
"""Declare per-monitor DPI awareness BEFORE SDL/Kivy initialize.
|
||||
|
||||
On a display scaled above 100% (e.g. 1920x1080 @ 125%), Windows
|
||||
virtualizes a non-DPI-aware app to the scaled-down size (1536x864).
|
||||
Kivy then sizes its content area to the virtualized resolution, leaving a
|
||||
black strip on one side and making images/videos render at the wrong size.
|
||||
Must run before any SDL window is created, so this lives in the runtime
|
||||
hook (the first Python code that runs in the frozen app).
|
||||
"""
|
||||
try:
|
||||
try:
|
||||
aware = ctypes.c_int(2) # PROCESS_PER_MONITOR_DPI_AWARE_V2
|
||||
ctypes.windll.shcore.SetProcessDpiAwareness(aware)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ctypes.windll.user32.SetProcessDPIAware()
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if platform.system() == 'Windows':
|
||||
import ctypes
|
||||
_set_process_dpi_awareness()
|
||||
|
||||
# ── IMPORTANT: Set Windows environment BEFORE any Kivy code runs ──
|
||||
# This must happen before main.py's top-level code executes, because
|
||||
# main.py sets SDL_VIDEODRIVER=wayland,x11,dummy which would crash on Windows.
|
||||
@@ -23,6 +54,8 @@ os.environ['FFPYPLAYER_CODECS'] = 'h264,h265,vp9,vp8'
|
||||
os.environ['SDL_VIDEO_ALLOW_SCREENSAVER'] = '0'
|
||||
os.environ['KIVY_NO_FILELOG'] = '1'
|
||||
os.environ['KIVY_INPUTPROVIDERS'] = '' # Let Kivy auto-detect on Windows
|
||||
# Use native physical pixels (fixes black strip on DPI-scaled displays).
|
||||
os.environ.setdefault('SDL_VIDEO_HIGHDPI', '1')
|
||||
|
||||
# ── Capture ALL early output to a crash log ─────────────────────────
|
||||
# Ensure we catch any exception that happens before Logger is available.
|
||||
|
||||
Reference in New Issue
Block a user