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 " BODY_JSON=$(python3 -c "
import json, textwrap import json, textwrap
tag = '$TAG' tag = '$TAG'
body = '''## Downloads body = f'''## Downloads
| Platform | File | | Platform | File |
|----------|------| |----------|------|
| Windows (installer) | \`MediaServer-{tag}-setup.exe\` | | Windows (installer) | \`MediaServer-{tag}-setup.exe\` |
+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" \
--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 done
# Install wheels into site-packages # Install wheels into site-packages
+5
View File
@@ -20,6 +20,7 @@ scripts:
command: "rundll32.exe user32.dll,LockWorkStation" command: "rundll32.exe user32.dll,LockWorkStation"
label: "Lock Screen" label: "Lock Screen"
description: "Lock the workstation" description: "Lock the workstation"
icon: "mdi:lock"
timeout: 5 timeout: 5
shell: true shell: true
@@ -27,6 +28,7 @@ scripts:
command: "shutdown /h" command: "shutdown /h"
label: "Hibernate" label: "Hibernate"
description: "Hibernate the PC" description: "Hibernate the PC"
icon: "mdi:power-sleep"
timeout: 10 timeout: 10
shell: true shell: true
@@ -34,6 +36,7 @@ scripts:
command: "rundll32.exe powrprof.dll,SetSuspendState 0,1,0" command: "rundll32.exe powrprof.dll,SetSuspendState 0,1,0"
label: "Sleep" label: "Sleep"
description: "Put PC to sleep" description: "Put PC to sleep"
icon: "mdi:sleep"
timeout: 10 timeout: 10
shell: true shell: true
@@ -41,6 +44,7 @@ scripts:
command: "shutdown /s /t 0" command: "shutdown /s /t 0"
label: "Shutdown" label: "Shutdown"
description: "Shutdown the PC immediately" description: "Shutdown the PC immediately"
icon: "mdi:power"
timeout: 10 timeout: 10
shell: true shell: true
@@ -48,6 +52,7 @@ scripts:
command: "shutdown /r /t 0" command: "shutdown /r /t 0"
label: "Restart" label: "Restart"
description: "Restart the PC immediately" description: "Restart the PC immediately"
icon: "mdi:restart"
timeout: 10 timeout: 10
shell: true shell: true
+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"
+50 -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,61 @@ 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)
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( 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__":
+78 -32
View File
@@ -1,11 +1,11 @@
"""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 signal
import threading
import webbrowser import webbrowser
from pathlib import Path
from typing import Callable
from PIL import Image, ImageDraw from PIL import Image, ImageDraw
@@ -14,8 +14,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
# 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: 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: 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
@@ -60,42 +88,60 @@ 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_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( 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("Exit", on_exit), pystray.MenuItem("Restart", self._restart),
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._restart_requested = True
self._on_exit()
self._icon.stop()
@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 = [ 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",