fix: tray main-thread message loop, numpy <2.0 pin, installer config copy
Lint & Test / test (push) Successful in 9s
Release / create-release (push) Successful in 1s
Release / build-windows (push) Failing after 30s
Release / build-linux (push) Successful in 35s

- Rewrite tray to run on main thread (pystray owns message loop, uvicorn
  in background thread) — fixes unresponsive confirmation dialogs
- Use native Windows MessageBoxW instead of tkinter (embedded Python
  has no tkinter)
- Pin numpy <2.0 to fix soundcard's numpy.fromstring (removed in 2.0)
- Strip transitive numpy 2.x wheels in build script
- Installer copies config.example.yaml as config.yaml on fresh install
- Suppress noisy screen_brightness_control warnings
This commit is contained in:
2026-03-24 15:05:36 +03:00
parent d7e10b1005
commit 402183765c
5 changed files with 127 additions and 77 deletions
+10 -3
View File
@@ -74,17 +74,24 @@ WIN_DEPS=(
# Visualizer dependencies # Visualizer dependencies
VIS_DEPS=( VIS_DEPS=(
"soundcard>=0.4.0" "soundcard>=0.4.0"
"numpy>=1.24.0" "numpy>=1.24.0,<2.0"
) )
ALL_DEPS=("${CORE_DEPS[@]}" "${WIN_DEPS[@]}" "${VIS_DEPS[@]}") ALL_DEPS=("${CORE_DEPS[@]}" "${WIN_DEPS[@]}" "${VIS_DEPS[@]}")
for dep in "${ALL_DEPS[@]}"; do for dep in "${ALL_DEPS[@]}"; do
pip download --quiet --dest "$WHEEL_DIR" \ pip download --quiet --no-cache-dir --dest "$WHEEL_DIR" \
--platform win_amd64 --python-version "${PYTHON_SHORT}" \ --platform win_amd64 --python-version "${PYTHON_SHORT}" \
--implementation cp --only-binary :all: \ --implementation cp --only-binary :all: \
"$dep" 2>/dev/null || \ "$dep" 2>/dev/null || \
pip download --quiet --dest "$WHEEL_DIR" "$dep" pip download --quiet --no-cache-dir --dest "$WHEEL_DIR" \
--python-version "${PYTHON_SHORT}" \
"$dep"
done
# Remove numpy 2.x wheels pulled as transitive deps (soundcard requires <2.0)
for f in "$WHEEL_DIR"/numpy-2*; do
[ -f "$f" ] && echo "Removing incompatible: $(basename "$f")" && rm "$f"
done done
# Install wheels into site-packages # Install wheels into site-packages
+4
View File
@@ -73,6 +73,10 @@ Section "!Core (required)" SecCore
; Copy entire distribution ; Copy entire distribution
File /r "dist\media-server\*.*" File /r "dist\media-server\*.*"
; Create config.yaml from example if it doesn't already exist (preserve user config on upgrade)
IfFileExists "$INSTDIR\config.yaml" +2
CopyFiles /SILENT "$INSTDIR\config.example.yaml" "$INSTDIR\config.yaml"
; Create uninstaller ; Create uninstaller
WriteUninstaller "$INSTDIR\uninstall.exe" WriteUninstaller "$INSTDIR\uninstall.exe"
+36 -9
View File
@@ -51,6 +51,9 @@ def setup_logging():
handlers=[handler], handlers=[handler],
) )
# Suppress noisy third-party loggers
logging.getLogger("screen_brightness_control").setLevel(logging.ERROR)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
@@ -240,23 +243,47 @@ def main():
print("\nAuthentication is DISABLED (no tokens configured)") print("\nAuthentication is DISABLED (no tokens configured)")
return return
# Start system tray icon (unless disabled) from .tray import PYSTRAY_AVAILABLE, TrayManager
tray_icon = None
if not args.no_tray:
from .tray import start_tray
tray_icon = start_tray(args.host, args.port) use_tray = PYSTRAY_AVAILABLE and not args.no_tray
try: if use_tray:
import asyncio
import threading
# Run uvicorn in a background thread so tray owns the main thread message loop
uv_config = uvicorn.Config(
"media_server.main:app",
host=args.host,
port=args.port,
log_level=settings.log_level.lower(),
)
server = uvicorn.Server(uv_config)
def run_server():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(server.serve())
server_thread = threading.Thread(target=run_server, daemon=True)
server_thread.start()
# Tray on main thread (blocking)
tray = TrayManager(
port=args.port,
on_exit=lambda: setattr(server, "should_exit", True),
)
tray.run()
# Tray exited — wait for server to finish graceful shutdown
server_thread.join(timeout=10)
else:
uvicorn.run( uvicorn.run(
"media_server.main:app", "media_server.main:app",
host=args.host, host=args.host,
port=args.port, port=args.port,
reload=False, reload=False,
) )
finally:
if tray_icon is not None:
tray_icon.stop()
if __name__ == "__main__": if __name__ == "__main__":
+71 -59
View File
@@ -1,13 +1,13 @@
"""System tray icon for Media Server.""" """System tray icon for Media Server."""
import ctypes
import io import io
import logging import logging
import os import os
import signal
import subprocess
import sys import sys
import threading
import webbrowser import webbrowser
from pathlib import Path
from typing import Callable
from PIL import Image, ImageDraw from PIL import Image, ImageDraw
@@ -16,28 +16,27 @@ logger = logging.getLogger(__name__)
# pystray is optional — tray silently disabled when missing # pystray is optional — tray silently disabled when missing
try: try:
import pystray import pystray
PYSTRAY_AVAILABLE = True
except ImportError: except ImportError:
pystray = None pystray = None
PYSTRAY_AVAILABLE = False
# tkinter is optional — used for confirmation dialogs
try: # Windows-native confirmation (no tkinter needed)
import tkinter as tk _MB_YESNO = 0x04
from tkinter import messagebox _MB_ICONQUESTION = 0x20
except ImportError: _MB_TOPMOST = 0x40000
tk = None _MB_SETFOREGROUND = 0x10000
messagebox = None _IDYES = 6
def _confirm(title: str, message: str) -> bool: def _confirm(title: str, message: str) -> bool:
"""Show a Yes/No confirmation dialog. Returns True if user clicks Yes.""" """Show a Yes/No dialog using native Windows MessageBox."""
if messagebox is None: result = ctypes.windll.user32.MessageBoxW(
return True # No tkinter — skip confirmation 0, message, title, _MB_YESNO | _MB_ICONQUESTION | _MB_TOPMOST | _MB_SETFOREGROUND
root = tk.Tk() )
root.withdraw() return result == _IDYES
root.attributes("-topmost", True)
result = messagebox.askyesno(title, message, parent=root)
root.destroy()
return result
def _create_icon_image(size: int = 64) -> Image.Image: def _create_icon_image(size: int = 64) -> Image.Image:
@@ -66,15 +65,24 @@ def _create_icon_image(size: int = 64) -> Image.Image:
def _load_icon_image() -> Image.Image: def _load_icon_image() -> Image.Image:
"""Load the SVG app icon, falling back to a generated image.""" """Load the ICO/SVG app icon, falling back to a generated image."""
icons_dir = Path(__file__).parent / "static" / "icons"
# Try .ico first (best for Windows tray)
ico_path = icons_dir / "icon.ico"
if ico_path.exists():
try:
return Image.open(ico_path)
except Exception:
pass
# Try SVG via cairosvg
try: try:
import cairosvg import cairosvg
svg_path = os.path.join( svg_path = icons_dir / "icon.svg"
os.path.dirname(__file__), "static", "icons", "icon.svg" if svg_path.exists():
) png_data = cairosvg.svg2png(url=str(svg_path), output_width=64, output_height=64)
if os.path.exists(svg_path):
png_data = cairosvg.svg2png(url=svg_path, output_width=64, output_height=64)
return Image.open(io.BytesIO(png_data)) return Image.open(io.BytesIO(png_data))
except Exception: except Exception:
pass pass
@@ -82,53 +90,57 @@ def _load_icon_image() -> Image.Image:
return _create_icon_image() return _create_icon_image()
def start_tray(host: str, port: int) -> "pystray.Icon | None": class TrayManager:
"""Start system tray icon in a background thread. """Manages the system tray icon and its context menu.
Returns the Icon instance (call icon.stop() to remove), or None if Call ``run()`` on the **main thread** — it blocks until ``stop()``
pystray is not installed. is called (from any thread) or the user picks *Shutdown* from the menu.
""" """
if pystray is None:
logger.info("pystray not installed — tray icon disabled")
return None
url = f"http://{'localhost' if host == '0.0.0.0' else host}:{port}" def __init__(self, port: int, on_exit: Callable[[], None]) -> None:
if not PYSTRAY_AVAILABLE:
raise ImportError("pystray is required for system tray support")
def on_show_ui(_icon, _item): self._port = port
webbrowser.open(url) self._on_exit = on_exit
def on_restart(_icon, _item):
if not _confirm("Restart Media Server", "Are you sure you want to restart the server?"):
return
logger.info("Restart requested from tray")
_icon.stop()
# Re-launch the same process, then exit current
subprocess.Popen([sys.executable] + sys.argv)
os.kill(os.getpid(), signal.SIGINT)
def on_exit(_icon, _item):
if not _confirm("Exit Media Server", "Are you sure you want to shut down the server?"):
return
logger.info("Exit requested from tray")
_icon.stop()
os.kill(os.getpid(), signal.SIGINT)
menu = pystray.Menu( menu = pystray.Menu(
pystray.MenuItem("Show UI", on_show_ui, default=True), pystray.MenuItem("Show UI", self._show_ui, default=True),
pystray.Menu.SEPARATOR, pystray.Menu.SEPARATOR,
pystray.MenuItem("Restart", on_restart), pystray.MenuItem("Restart", self._restart),
pystray.MenuItem("Shutdown", on_exit), pystray.MenuItem("Shutdown", self._shutdown),
) )
icon = pystray.Icon( self._icon = pystray.Icon(
name="media-server", name="media-server",
icon=_load_icon_image(), icon=_load_icon_image(),
title="Media Server", title="Media Server",
menu=menu, menu=menu,
) )
thread = threading.Thread(target=icon.run, daemon=True) def _show_ui(self, icon: "pystray.Icon", item: "pystray.MenuItem") -> None:
thread.start() webbrowser.open(f"http://localhost:{self._port}")
logger.info("System tray icon started")
return icon def _restart(self, icon: "pystray.Icon", item: "pystray.MenuItem") -> None:
if not _confirm("Media Server", "Restart the server?"):
return
logger.info("Restart requested from tray")
self._icon.stop()
self._on_exit()
os.environ["MEDIA_SERVER_RESTART"] = "1"
os.execv(sys.executable, [sys.executable] + sys.argv)
def _shutdown(self, icon: "pystray.Icon", item: "pystray.MenuItem") -> None:
if not _confirm("Media Server", "Shut down the server?"):
return
logger.info("Shutdown requested from tray")
self._on_exit()
self._icon.stop()
def run(self) -> None:
"""Block the calling thread running the tray message loop."""
self._icon.run()
def stop(self) -> None:
"""Stop the tray icon from any thread."""
self._icon.stop()
+1 -1
View File
@@ -46,7 +46,7 @@ windows = [
] ]
visualizer = [ visualizer = [
"soundcard>=0.4.0", "soundcard>=0.4.0",
"numpy>=1.24.0", "numpy>=1.24.0,<2.0",
] ]
dev = [ dev = [
"pytest>=7.0", "pytest>=7.0",