Compare commits

...

6 Commits

Author SHA1 Message Date
alexei.dolgolyov 415231f2f2 fix: tray restart uses python -m for reliable process respawn
Release / create-release (push) Successful in 1s
Lint & Test / test (push) Successful in 15s
Release / build-linux (push) Successful in 32s
Release / build-windows (push) Successful in 1m8s
The previous os.execv approach and console_script detection both
failed on Windows. Now restart always spawns `python -m media_server.main`
via subprocess.Popen with start_new_session, which works regardless
of how the server was originally started.
2026-03-24 15:26:14 +03:00
alexei.dolgolyov 32e2ff532d fix: add --only-binary to pip download fallback (CI compatibility)
Lint & Test / test (push) Successful in 9s
Release / create-release (push) Successful in 1s
Release / build-linux (push) Successful in 37s
Release / build-windows (push) Successful in 1m15s
2026-03-24 15:07:33 +03:00
alexei.dolgolyov 309f547a5e feat: add default MDI icons to example config scripts
Lint & Test / test (push) Successful in 9s
2026-03-24 15:07:09 +03:00
alexei.dolgolyov 402183765c 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
2026-03-24 15:05:36 +03:00
alexei.dolgolyov d7e10b1005 fix: interpolate tag in release body template (f-string)
Lint & Test / test (push) Successful in 10s
Release / create-release (push) Successful in 1s
Release / build-linux (push) Successful in 33s
Release / build-windows (push) Successful in 1m8s
2026-03-24 14:26:14 +03:00
alexei.dolgolyov 3f14512e5d feat: add Restart and Shutdown tray actions with confirmation dialogs
Lint & Test / test (push) Successful in 24s
Release / create-release (push) Successful in 1s
Release / build-linux (push) Successful in 31s
Release / build-windows (push) Successful in 1m13s
2026-03-24 14:19:15 +03:00
7 changed files with 156 additions and 53 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
BODY_JSON=$(python3 -c "
import json, textwrap
tag = '$TAG'
body = '''## Downloads
body = f'''## Downloads
| Platform | File |
|----------|------|
| Windows (installer) | \`MediaServer-{tag}-setup.exe\` |
+10 -3
View File
@@ -74,17 +74,24 @@ WIN_DEPS=(
# Visualizer dependencies
VIS_DEPS=(
"soundcard>=0.4.0"
"numpy>=1.24.0"
"numpy>=1.24.0,<2.0"
)
ALL_DEPS=("${CORE_DEPS[@]}" "${WIN_DEPS[@]}" "${VIS_DEPS[@]}")
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}" \
--implementation cp --only-binary :all: \
"$dep" 2>/dev/null || \
pip download --quiet --dest "$WHEEL_DIR" "$dep"
pip download --quiet --no-cache-dir --dest "$WHEEL_DIR" \
--only-binary :all: \
"$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
# Install wheels into site-packages
+5
View File
@@ -20,6 +20,7 @@ scripts:
command: "rundll32.exe user32.dll,LockWorkStation"
label: "Lock Screen"
description: "Lock the workstation"
icon: "mdi:lock"
timeout: 5
shell: true
@@ -27,6 +28,7 @@ scripts:
command: "shutdown /h"
label: "Hibernate"
description: "Hibernate the PC"
icon: "mdi:power-sleep"
timeout: 10
shell: true
@@ -34,6 +36,7 @@ scripts:
command: "rundll32.exe powrprof.dll,SetSuspendState 0,1,0"
label: "Sleep"
description: "Put PC to sleep"
icon: "mdi:sleep"
timeout: 10
shell: true
@@ -41,6 +44,7 @@ scripts:
command: "shutdown /s /t 0"
label: "Shutdown"
description: "Shutdown the PC immediately"
icon: "mdi:power"
timeout: 10
shell: true
@@ -48,6 +52,7 @@ scripts:
command: "shutdown /r /t 0"
label: "Restart"
description: "Restart the PC immediately"
icon: "mdi:restart"
timeout: 10
shell: true
+4
View File
@@ -73,6 +73,10 @@ Section "!Core (required)" SecCore
; Copy entire distribution
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
WriteUninstaller "$INSTDIR\uninstall.exe"
+50 -9
View File
@@ -51,6 +51,9 @@ def setup_logging():
handlers=[handler],
)
# Suppress noisy third-party loggers
logging.getLogger("screen_brightness_control").setLevel(logging.ERROR)
@asynccontextmanager
async def lifespan(app: FastAPI):
@@ -240,23 +243,61 @@ def main():
print("\nAuthentication is DISABLED (no tokens configured)")
return
# Start system tray icon (unless disabled)
tray_icon = None
if not args.no_tray:
from .tray import start_tray
from .tray import PYSTRAY_AVAILABLE, TrayManager
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)
if tray.restart_requested:
import subprocess
# Always restart via `python -m media_server.main` — this works
# regardless of how we were originally started (console_script,
# python -m, or direct script invocation).
cmd = [sys.executable, "-m", "media_server.main"]
subprocess.Popen(
cmd,
cwd=Path.cwd(),
start_new_session=True,
)
else:
uvicorn.run(
"media_server.main:app",
host=args.host,
port=args.port,
reload=False,
)
finally:
if tray_icon is not None:
tray_icon.stop()
if __name__ == "__main__":
+85 -39
View File
@@ -1,11 +1,11 @@
"""System tray icon for Media Server."""
import ctypes
import io
import logging
import os
import signal
import threading
import webbrowser
from pathlib import Path
from typing import Callable
from PIL import Image, ImageDraw
@@ -14,8 +14,27 @@ logger = logging.getLogger(__name__)
# pystray is optional — tray silently disabled when missing
try:
import pystray
PYSTRAY_AVAILABLE = True
except ImportError:
pystray = None
PYSTRAY_AVAILABLE = False
# Windows-native confirmation (no tkinter needed)
_MB_YESNO = 0x04
_MB_ICONQUESTION = 0x20
_MB_TOPMOST = 0x40000
_MB_SETFOREGROUND = 0x10000
_IDYES = 6
def _confirm(title: str, message: str) -> bool:
"""Show a Yes/No dialog using native Windows MessageBox."""
result = ctypes.windll.user32.MessageBoxW(
0, message, title, _MB_YESNO | _MB_ICONQUESTION | _MB_TOPMOST | _MB_SETFOREGROUND
)
return result == _IDYES
def _create_icon_image(size: int = 64) -> Image.Image:
@@ -44,15 +63,24 @@ def _create_icon_image(size: int = 64) -> 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:
import cairosvg
svg_path = os.path.join(
os.path.dirname(__file__), "static", "icons", "icon.svg"
)
if os.path.exists(svg_path):
png_data = cairosvg.svg2png(url=svg_path, output_width=64, output_height=64)
svg_path = icons_dir / "icon.svg"
if svg_path.exists():
png_data = cairosvg.svg2png(url=str(svg_path), output_width=64, output_height=64)
return Image.open(io.BytesIO(png_data))
except Exception:
pass
@@ -60,42 +88,60 @@ def _load_icon_image() -> Image.Image:
return _create_icon_image()
def start_tray(host: str, port: int) -> "pystray.Icon | None":
"""Start system tray icon in a background thread.
class TrayManager:
"""Manages the system tray icon and its context menu.
Returns the Icon instance (call icon.stop() to remove), or None if
pystray is not installed.
Call ``run()`` on the **main thread** — it blocks until ``stop()``
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):
webbrowser.open(url)
self._port = port
self._on_exit = on_exit
def on_exit(_icon, _item):
logger.info("Exit requested from tray")
_icon.stop()
# Signal the main process to shut down gracefully
os.kill(os.getpid(), signal.SIGINT)
menu = pystray.Menu(
pystray.MenuItem("Show UI", self._show_ui, default=True),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Restart", self._restart),
pystray.MenuItem("Shutdown", self._shutdown),
)
menu = pystray.Menu(
pystray.MenuItem("Show UI", on_show_ui, default=True),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Exit", on_exit),
)
self._icon = pystray.Icon(
name="media-server",
icon=_load_icon_image(),
title="Media Server",
menu=menu,
)
icon = pystray.Icon(
name="media-server",
icon=_load_icon_image(),
title="Media Server",
menu=menu,
)
def _show_ui(self, icon: "pystray.Icon", item: "pystray.MenuItem") -> None:
webbrowser.open(f"http://localhost:{self._port}")
thread = threading.Thread(target=icon.run, daemon=True)
thread.start()
logger.info("System tray icon started")
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._restart_requested = True
self._on_exit()
self._icon.stop()
return icon
@property
def restart_requested(self) -> bool:
return getattr(self, "_restart_requested", False)
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 = [
"soundcard>=0.4.0",
"numpy>=1.24.0",
"numpy>=1.24.0,<2.0",
]
dev = [
"pytest>=7.0",