Port the player to Raspberry Pi OS Trixie 64-bit (Linux-only branch)
Replaces the Windows port with a Raspberry Pi / Linux implementation on Raspberry Pi OS "Trixie" (Debian 13, aarch64, Wayland/labwc). The Windows code is removed here but preserved on the Windows-Player branch. Entry point ----------- linux/run_linux.py replaces windows/run_win.py. src/main.py stays platform-neutral; all Pi-specific behaviour is injected from linux/. Five bugs that prevented the port (all measured on real hardware) ---------------------------------------------------------------- 1. Kivy's PyPI wheel bundles an SDL2 built WITHOUT the wayland driver, so no window could be created (Trixie has no X server). linux/fix_kivy_sdl2.sh symlinks the system SDL2 over the bundled filename. 2. SDL2 requires WAYLAND_DISPLAY to be *set* - the socket alone is not enough, unlike wlopm. This broke every systemd/cron/autostart launch. linux_display.ensure_session_environment() detects and exports it. 3. Kivy's Clock resolves callbacks via func.__name__; a patch assigned under a different name crashed the player ~20s after a successful start. 4. The inherited signal_screen_activity() shelled out to tvservice, xdotool and ydotool - none exist on Trixie - and mis-escaped 'wlopm --on \*', so the display blanked after 10 minutes. 5. The launchers ran src/main.py directly, bypassing every platform patch and resolving the data directory one level too high. Web links --------- - --ozone-platform-hint=auto does NOT fall back to Wayland on Chromium 152; it aborts. The platform is now chosen explicitly. - The keyring password prompt is suppressed via the ENVIRONMENT, not the flags: launch_env() strips DBUS_SESSION_BUS_ADDRESS for the child so Chromium cannot reach gnome-keyring-daemon. - Teardown kills the whole process group (needs start_new_session=True); previously it silently fell back to terminate() and orphaned children. Video normalisation ------------------- A 4K video cannot play on a Pi 4: ffpyplayer decodes in software, measured at 0.90x realtime (1080p is 3.03x). Oversized media is downscaled to 1920x1080 at sync time using the hardware h264_v4l2m2m encoder (~31s for an 18s clip), triggered by resolution only so already-playable files are untouched. src/media_state.py owns the shared on-disk contract: a .kiwy-converting marker makes the player skip the item while it is being rebuilt, then the converted file is played instead. If nothing is playable at all (a single-item playlist whose only video is converting), the player loops the intro video rather than leaving a blank screen. Also fixed ---------- - network_monitor: replaced netsh/ifconfig/dhclient with nmcli (Trixie uses NetworkManager; ifconfig and dhclient are not even installed). - Removed the Windows-only focus keeper/guardian from main.py. - main.py: duplicate SDL_AUDIODRIVER setdefault (a silent no-op); Settings "Test connection" now uses tempfile.gettempdir(). - config/app_config.json: credentials blanked so a fresh clone runs the first-run setup flow. Verification ------------ linux/test_media_state.py 18/18, test_linux_patches.py 21/21, test_linux_browser_flags.py 27/27. Verified live against a real DigiServer: image -> weblink -> image -> video with correct durations, zero leaked Chromium processes, and no throttling over a 10 minute monitored run.
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
"""test_media_state.py — the conversion-flag state machine.
|
||||
|
||||
The rules here are easy to get subtly wrong, and getting them wrong is
|
||||
user-visible in two opposite ways: playing a file that is mid-conversion
|
||||
(truncated video), or skipping forever an item that is actually fine.
|
||||
|
||||
Covered:
|
||||
|
||||
1. A normal-size video resolves to itself and is playable.
|
||||
2. An oversized video with no completed conversion reports ``pending``.
|
||||
3. While the ``.kiwy-converting`` marker exists it reports ``converting``.
|
||||
4. Once the output + metadata exist it resolves to the **converted** file.
|
||||
5. A stale marker (from a crash) does not park the item forever.
|
||||
6. Metadata that does not match the current source is ignored — otherwise a
|
||||
different video reusing the same filename would play the previous one.
|
||||
7. The MP4 header parser agrees with ffprobe (the player must not spawn a
|
||||
subprocess on the playback path, so it reads the container directly).
|
||||
|
||||
Run:
|
||||
.venv/bin/python linux/test_media_state.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parent
|
||||
SRC = ROOT / 'src'
|
||||
for path in (str(HERE), str(SRC)):
|
||||
if path not in sys.path:
|
||||
sys.path.insert(0, path)
|
||||
|
||||
import media_state as ms # noqa: E402
|
||||
|
||||
failures: list[str] = []
|
||||
checks = 0
|
||||
|
||||
|
||||
def check(label, condition, detail=''):
|
||||
global checks
|
||||
checks += 1
|
||||
print(f' {"PASS" if condition else "FAIL"} {label}'
|
||||
+ (f' — {detail}' if not condition and detail else ''))
|
||||
if not condition:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
def make_video(path, width, height, seconds=1):
|
||||
"""Create a tiny real video of the given size (or None if ffmpeg is absent)."""
|
||||
if not shutil.which('ffmpeg'):
|
||||
return False
|
||||
cmd = [
|
||||
'ffmpeg', '-hide_banner', '-loglevel', 'error',
|
||||
'-f', 'lavfi', '-i', f'testsrc=size={width}x{height}:rate=10:duration={seconds}',
|
||||
'-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p',
|
||||
'-y', path,
|
||||
]
|
||||
return subprocess.run(cmd, capture_output=True, check=False).returncode == 0
|
||||
|
||||
|
||||
workdir = Path(tempfile.mkdtemp(prefix='kiwy-mediastate-'))
|
||||
print(f'workdir: {workdir}')
|
||||
|
||||
try:
|
||||
big = workdir / 'big.mp4'
|
||||
small = workdir / 'small.mp4'
|
||||
|
||||
if not make_video(big, 2560, 1440) or not make_video(small, 1280, 720):
|
||||
print('SKIP: ffmpeg not available to build fixtures')
|
||||
raise SystemExit(0)
|
||||
|
||||
# ── 1. Within-limit video is playable as itself ──────────────────
|
||||
print('\n[1] A within-limit video resolves to itself')
|
||||
chosen, state = ms.resolve_playable(str(small))
|
||||
check('state is ready', state == 'ready', f'got {state}')
|
||||
check('chosen path is the original', chosen == str(small), f'got {chosen}')
|
||||
|
||||
# ── 2. Oversized + no conversion -> pending ──────────────────────
|
||||
print('\n[2] Oversized video with no conversion reports pending')
|
||||
chosen, state = ms.resolve_playable(str(big))
|
||||
check('state is pending', state == 'pending', f'got {state}')
|
||||
oversized, _w, _h = ms.is_oversized(str(big))
|
||||
check('is_oversized is True', oversized is True)
|
||||
check('a within-limit file is not oversized',
|
||||
ms.is_oversized(str(small))[0] is False)
|
||||
|
||||
# ── 3. Converting marker -> converting ───────────────────────────
|
||||
print('\n[3] The converting marker suppresses playback')
|
||||
ms.begin_conversion(str(big))
|
||||
chosen, state = ms.resolve_playable(str(big))
|
||||
check('state is converting', state == 'converting', f'got {state}')
|
||||
check('is_converting is True', ms.is_converting(str(big)) is True)
|
||||
|
||||
# ── 4. Completed conversion resolves to the output ───────────────
|
||||
print('\n[4] A finished conversion resolves to the converted file')
|
||||
output = ms.normalized_output(str(big))
|
||||
shutil.copy2(str(small), output) # stand-in for the 1080p result
|
||||
with open(output + ms.MARKER_SUFFIX, 'w') as fh:
|
||||
json.dump({'source_size': os.path.getsize(big),
|
||||
'width': 2560, 'height': 1440}, fh)
|
||||
ms.end_conversion(str(big))
|
||||
|
||||
chosen, state = ms.resolve_playable(str(big))
|
||||
check('state is ready', state == 'ready', f'got {state}')
|
||||
check('chosen path is the converted file', chosen == output, f'got {chosen}')
|
||||
|
||||
# ── 5. Stale marker is ignored ───────────────────────────────────
|
||||
print('\n[5] A stale (crashed) marker does not block the item forever')
|
||||
os.remove(output)
|
||||
os.remove(output + ms.MARKER_SUFFIX)
|
||||
ms.begin_conversion(str(big))
|
||||
old = time.time() - (ms.STALE_CONVERSION_SECONDS + 60)
|
||||
os.utime(ms.converting_marker(str(big)), (old, old))
|
||||
check('a stale marker is not treated as converting',
|
||||
ms.is_converting(str(big)) is False)
|
||||
_chosen, state = ms.resolve_playable(str(big))
|
||||
check('so the item is pending rather than converting',
|
||||
state == 'pending', f'got {state}')
|
||||
ms.end_conversion(str(big))
|
||||
|
||||
# ── 6. Mismatched metadata is ignored ────────────────────────────
|
||||
print('\n[6] Metadata for a different source is rejected')
|
||||
shutil.copy2(str(small), output)
|
||||
with open(output + ms.MARKER_SUFFIX, 'w') as fh:
|
||||
json.dump({'source_size': 12345, # does not match big.mp4
|
||||
'width': 2560, 'height': 1440}, fh)
|
||||
check('a stale output is not accepted', ms.normalized_file(str(big)) is None)
|
||||
_chosen, state = ms.resolve_playable(str(big))
|
||||
check('the item is treated as pending', state == 'pending', f'got {state}')
|
||||
|
||||
# ── 7. Header parser agrees with ffprobe ─────────────────────────
|
||||
print('\n[7] The dependency-free MP4 parser matches ffprobe')
|
||||
for label, path, expect in (('2560x1440', str(big), (2560, 1440)),
|
||||
('1280x720', str(small), (1280, 720))):
|
||||
parsed = ms.read_video_size(path)
|
||||
check(f'{label} parsed correctly', parsed == expect, f'got {parsed}')
|
||||
|
||||
if shutil.which('ffprobe'):
|
||||
out = subprocess.run(
|
||||
['ffprobe', '-v', 'error', '-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=width,height', '-of', 'csv=p=0', str(big)],
|
||||
capture_output=True, text=True, check=False).stdout.strip()
|
||||
fw, fh = (int(x) for x in out.split(',')[:2])
|
||||
check('parser matches ffprobe for the oversized file',
|
||||
ms.read_video_size(str(big)) == (fw, fh),
|
||||
f'parser={ms.read_video_size(str(big))} ffprobe={(fw, fh)}')
|
||||
|
||||
check('a non-video file yields (None, None)',
|
||||
ms.read_video_size(str(workdir / 'missing.mp4')) == (None, None))
|
||||
check('a corrupt file yields (None, None)', (lambda p: (
|
||||
p.write_bytes(b'not a video'), ms.read_video_size(str(p)))[1]
|
||||
)(workdir / 'corrupt.mp4') == (None, None))
|
||||
|
||||
finally:
|
||||
shutil.rmtree(workdir, ignore_errors=True)
|
||||
|
||||
print(f'\n{checks - len(failures)}/{checks} checks passed')
|
||||
if failures:
|
||||
print('\nFailed:')
|
||||
for name in failures:
|
||||
print(f' - {name}')
|
||||
raise SystemExit(1)
|
||||
print('All checks passed.')
|
||||
Reference in New Issue
Block a user