feat: production-ready Linux & macOS support
Lint & Test / test (push) Successful in 20s
Lint & Test / linux-smoke (push) Failing after 34s

- Add `linux` (dbus-python, PyGObject, python-xlib) and `macos`
  (pyobjc) extras to pyproject.toml with sys_platform markers; move
  cross-platform screen-brightness-control + monitorcontrol to base deps.
- build-dist-linux.sh: install `.[linux]`, pkg-config pre-flight for
  dbus-1/glib-2.0, emit a systemd unit with DBUS_SESSION_BUS_ADDRESS +
  XDG_RUNTIME_DIR + ReadWritePaths for ~/.config and ~/.cache so MPRIS
  works and audit-log / thumbnail writes aren't blocked by ProtectHome.
- New build-dist-macos.sh + per-user LaunchAgent installer producing
  MediaServer-vX.Y-macos-{arm64,x86_64}.tar.gz.
- Templated media-server.service updated to match the dist layout with
  proper session-bus env vars and a writable state-dir grant.
- install_linux.sh: drop dead requirements.txt path; install via
  `pip install ".[linux]"` and pre-create the writable state dirs.
- Cross-platform album artwork: abstract MediaController.get_album_art()
  with Linux (mpris:artUrl, file:// + http(s)://) and macOS (Spotify URL)
  impls; routes/media artwork endpoint now awaits the controller.
- LinuxMediaController connects to the session bus lazily — failure no
  longer crashes lifespan startup; MPRIS calls return idle until the bus
  is reachable. Logged once at INFO with a hint about
  `loginctl enable-linger`.
- Startup preflight on Linux warns if DBUS_SESSION_BUS_ADDRESS or
  XDG_RUNTIME_DIR is unset and informs the user when Wayland disables
  the foreground probe.
- /api/media/visualizer/status now reports a per-OS unavailable_reason.
- tray._confirm guarded against ctypes.windll on non-Windows.
- config.example.yaml: per-OS commented script examples; on_turn_off
  default is now a no-op echo (used to silently fail off Windows).
- README: replace stale `pip install -r requirements.txt` instructions
  with the new extras; add systemd lingering doc + troubleshooting
  section; add macOS LaunchAgent section.
- CI: new linux-smoke job (installs `.[linux]`, boots the server under
  dbus-run-session, asserts /api/health). Release workflow gains
  apt-deps step for the Linux build and a best-effort macOS build job.
This commit is contained in:
2026-05-26 12:17:30 +03:00
parent 82710c6457
commit ddf4a6cb29
16 changed files with 823 additions and 109 deletions
+68
View File
@@ -60,6 +60,8 @@ jobs:
| Windows (installer) | \`MediaServer-{tag}-setup.exe\` | | Windows (installer) | \`MediaServer-{tag}-setup.exe\` |
| Windows (portable) | \`MediaServer-{tag}-win-x64.zip\` | | Windows (portable) | \`MediaServer-{tag}-win-x64.zip\` |
| Linux | \`MediaServer-{tag}-linux-x64.tar.gz\` | | Linux | \`MediaServer-{tag}-linux-x64.tar.gz\` |
| macOS (Apple Silicon) | \`MediaServer-{tag}-macos-arm64.tar.gz\` |
| macOS (Intel) | \`MediaServer-{tag}-macos-x86_64.tar.gz\` |
''').strip()) ''').strip())
print(json.dumps('\n\n'.join(sections))) print(json.dumps('\n\n'.join(sections)))
@@ -187,6 +189,13 @@ jobs:
with: with:
python-version: '3.11' python-version: '3.11'
- name: Install native deps for dbus-python + PyGObject
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libdbus-1-dev libglib2.0-dev pkg-config \
libcairo2-dev libgirepository1.0-dev
- name: Build Linux distribution - name: Build Linux distribution
run: | run: |
chmod +x build-dist-linux.sh chmod +x build-dist-linux.sh
@@ -226,3 +235,62 @@ jobs:
-H "Authorization: token $DEPLOY_TOKEN" \ -H "Authorization: token $DEPLOY_TOKEN" \
-H "Content-Type: application/octet-stream" \ -H "Content-Type: application/octet-stream" \
--data-binary "@$FILE" --data-binary "@$FILE"
# --- Build macOS tarball (best-effort; requires a macos runner) ---
# PyObjC wheels are macOS-only, so this job must run on a real Mac.
# If your Gitea instance has no macos runners, the job is skipped at
# scheduling time and the rest of the release still ships.
build-macos:
needs: create-release
runs-on: macos-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build frontend
run: npm ci && npm run build
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Build macOS distribution
run: |
chmod +x build-dist-macos.sh
./build-dist-macos.sh "${{ gitea.ref_name }}"
- name: Upload assets to release
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
FILE=$(ls build/MediaServer-*-macos-*.tar.gz | head -1)
NAME=$(basename "$FILE")
EXISTING=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
-H "Authorization: token $DEPLOY_TOKEN")
ASSET_ID=$(echo "$EXISTING" | python3 -c "
import sys, json
assets = json.load(sys.stdin)
for a in assets:
if a['name'] == '$NAME':
print(a['id'])
break
" 2>/dev/null || true)
if [ -n "$ASSET_ID" ]; then
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$ASSET_ID" \
-H "Authorization: token $DEPLOY_TOKEN"
fi
curl -s -X POST \
"$BASE_URL/releases/$RELEASE_ID/assets?name=$NAME" \
-H "Authorization: token $DEPLOY_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$FILE"
+61
View File
@@ -34,3 +34,64 @@ jobs:
- name: Test - name: Test
run: pytest --tb=short -q || test $? -eq 5 run: pytest --tb=short -q || test $? -eq 5
# Linux smoke test: install the linux extra in the same way build-dist-linux.sh
# does, then boot the server and hit /api/health. Catches dependency-resolution
# and import-time regressions for the Linux distribution path.
linux-smoke:
if: "!startsWith(github.event.head_commit.message, 'chore: release')"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build frontend
run: npm ci && npm run build
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install Linux system deps for dbus-python + PyGObject
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libdbus-1-dev libglib2.0-dev pkg-config \
libcairo2-dev libgirepository1.0-dev
- name: Install with linux extra
run: |
pip install --upgrade pip
pip install ".[linux]"
- name: Smoke — server boots and /api/health responds
run: |
# Headless Linux runners have no PulseAudio and no display
# server, so we disable the visualizer + update checker, and we
# use `dbus-run-session` to give LinuxMediaController a real
# session bus to talk to (otherwise dbus.SessionBus() would
# raise during startup). This isn't a full MPRIS integration
# test — it only proves the dispatcher selects the Linux
# controller, all imports resolve, and /api/health returns 200.
sudo apt-get install -y --no-install-recommends dbus-x11
export MEDIA_SERVER_VISUALIZER_ENABLED=false
export MEDIA_SERVER_UPDATE_CHECK_ENABLED=false
dbus-run-session -- bash -c '
python -m media_server.main --no-tray --port 18765 &
SERVER_PID=$!
for i in $(seq 1 30); do
if curl -sf "http://127.0.0.1:18765/api/health" >/dev/null; then
echo "Health check passed"
kill $SERVER_PID
wait $SERVER_PID 2>/dev/null || true
exit 0
fi
sleep 0.5
done
echo "Server did not respond within 15s"
kill $SERVER_PID 2>/dev/null || true
exit 1
'
+59 -11
View File
@@ -285,37 +285,44 @@ All connected WebSocket clients receive a `links_changed` notification when link
## Installation ## Installation
Dependencies are declared in `pyproject.toml`. Pick the extra that matches
your OS — the Python deps differ enough between Windows / Linux / macOS
that there's no single `pip install` line.
### Installing on Windows ### Installing on Windows
```bash ```bash
pip install -r requirements.txt pip install ".[windows]"
``` ```
Required packages: `winsdk`, `pywin32`, `pycaw`, `comtypes` Pulls in `winsdk`, `pywin32`, `pycaw`, `comtypes`, `pystray`, etc.
### Installing on Linux ### Installing on Linux
```bash ```bash
# Install system dependencies # System packages required to build dbus-python + PyGObject from sdist.
sudo apt-get install python3-dbus python3-gi libdbus-1-dev libglib2.0-dev sudo apt-get install -y python3-pip python3-venv \
libdbus-1-dev libglib2.0-dev pkg-config
pip install -r requirements.txt pip install ".[linux]"
``` ```
### Installing on macOS ### Installing on macOS
```bash ```bash
pip install -r requirements.txt pip install ".[macos]"
``` ```
No additional dependencies - uses built-in `osascript`. Pulls in `pyobjc-framework-Cocoa` + `pyobjc-framework-Quartz` for the
foreground-window probe; AppleScript-based media control uses the
built-in `osascript`.
### Installing on Android (Termux) ### Installing on Android (Termux)
```bash ```bash
# In Termux # In Termux
pkg install python termux-api pkg install python termux-api
pip install -r requirements.txt pip install "."
``` ```
Requires Termux and Termux:API apps from F-Droid. Requires Termux and Termux:API apps from F-Droid.
@@ -835,11 +842,19 @@ Install:
sudo ./service/install_linux.sh install sudo ./service/install_linux.sh install
``` ```
Enable and start for your user: **Enable user lingering** — required so `/run/user/$UID/bus` (the D-Bus
session socket needed for MPRIS) exists even when no graphical session
is active. Without this the server boots but every `/api/media/*` call
silently returns idle.
```bash ```bash
sudo systemctl enable media-server@$USER sudo loginctl enable-linger $USER
sudo systemctl start media-server@$USER ```
Enable and start the templated unit for your user:
```bash
sudo systemctl enable --now media-server@$USER
``` ```
View logs: View logs:
@@ -848,6 +863,39 @@ View logs:
journalctl -u media-server@$USER -f journalctl -u media-server@$USER -f
``` ```
**Troubleshooting:**
- *"`/api/media/status` always returns `idle`"* — check the service log for
`D-Bus session bus not available`. Most commonly: lingering isn't
enabled, or the unit is using the wrong `XDG_RUNTIME_DIR` (`%U` must
expand to the user's numeric UID).
- *"Visualizer permanently unavailable"* — PulseAudio/PipeWire must
expose monitor sources. `pactl list sources short | grep monitor`
should list at least one entry; if not, install `pipewire-pulse` and
restart your session.
- *"Volume control silently fails"* — `pactl` must be on `PATH` and the
user's PulseAudio/PipeWire server must be reachable
(`PULSE_RUNTIME_PATH=/run/user/$UID/pulse`).
- *"Foreground window is always `null`"* — expected under Wayland; the
compositor hides window info from unprivileged clients. X11 sessions
work normally.
### macOS (LaunchAgent)
The distribution tarball ships an installer:
```bash
./install-launchagent.sh
```
This drops `~/Library/LaunchAgents/com.dolgolyov.media-server.plist`,
starts the service immediately, and re-launches it at every login. Logs
go to `~/Library/Logs/media-server/{stdout,stderr}.log`. To stop:
```bash
./uninstall-launchagent.sh
```
## Command Line Options ## Command Line Options
```text ```text
+53
View File
@@ -16,12 +16,38 @@ BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-linux-x64"
clean_dist "${DIST_DIR}" build clean_dist "${DIST_DIR}" build
verify_frontend verify_frontend
# --- Sanity check: native deps required for dbus-python + PyGObject ---
# These compile from sdist on Linux. We don't try to install them via the
# distro package manager here (the CI image must already have them); fail
# with a clear message instead of cryptic gcc/pkg-config errors deep
# inside pip.
need_pkg() {
local pkg="$1"
if ! command -v pkg-config >/dev/null 2>&1; then
echo "ERROR: pkg-config is required (apt: pkg-config / dnf: pkgconf-pkg-config)" >&2
exit 1
fi
if ! pkg-config --exists "$pkg" 2>/dev/null; then
echo "ERROR: $pkg headers missing — install libdbus-1-dev libglib2.0-dev (apt)" >&2
echo " or dbus-devel glib2-devel (dnf) before running this script." >&2
exit 1
fi
}
need_pkg dbus-1
need_pkg glib-2.0
# --- Create self-contained virtualenv --- # --- Create self-contained virtualenv ---
echo "Creating virtualenv..." echo "Creating virtualenv..."
python3 -m venv "${DIST_DIR}/venv" python3 -m venv "${DIST_DIR}/venv"
source "${DIST_DIR}/venv/bin/activate" source "${DIST_DIR}/venv/bin/activate"
pip install --quiet --upgrade pip pip install --quiet --upgrade pip
# Install with the linux extra so dbus-python / PyGObject / python-xlib land
# in the venv. Falls back to base deps if the extra is unsupported (shouldn't
# happen post-0.3.2 but keep the fallback for older tags re-built here).
if ! pip install --quiet ".[linux]"; then
echo "WARN: '.[linux]' extra unavailable; installing base deps only" >&2
pip install --quiet "." pip install --quiet "."
fi
# Remove the installed package (app source is on PYTHONPATH via launcher) # Remove the installed package (app source is on PYTHONPATH via launcher)
rm -rf "${DIST_DIR}"/venv/lib/python*/site-packages/media_server* rm -rf "${DIST_DIR}"/venv/lib/python*/site-packages/media_server*
@@ -47,6 +73,11 @@ LAUNCHER
chmod +x "${DIST_DIR}/media-server.sh" chmod +x "${DIST_DIR}/media-server.sh"
# --- Create systemd service installer --- # --- Create systemd service installer ---
# Emits a non-templated unit that runs as the invoking user with the env
# bits MPRIS / PulseAudio need: DBUS_SESSION_BUS_ADDRESS and XDG_RUNTIME_DIR
# must be set or DBus.SessionBus() either fails or talks to the wrong bus.
# We also relax ProtectHome to read-write for ~/.config/media-server +
# ~/.cache/media-server so audit.log and thumbnail cache writes succeed.
cat > "${DIST_DIR}/install-service.sh" << 'SERVICE' cat > "${DIST_DIR}/install-service.sh" << 'SERVICE'
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
@@ -61,6 +92,9 @@ if [ "$EUID" -ne 0 ]; then
fi fi
REAL_USER="${SUDO_USER:-$USER}" REAL_USER="${SUDO_USER:-$USER}"
USER_UID="$(id -u "$REAL_USER")"
USER_HOME="$(getent passwd "$REAL_USER" | cut -d: -f6)"
RUNTIME_DIR="/run/user/${USER_UID}"
cat > "$SERVICE_FILE" << EOF cat > "$SERVICE_FILE" << EOF
[Unit] [Unit]
@@ -74,17 +108,36 @@ WorkingDirectory=${SCRIPT_DIR}
ExecStart=${SCRIPT_DIR}/media-server.sh ExecStart=${SCRIPT_DIR}/media-server.sh
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
# Required by MPRIS (dbus.SessionBus) and PulseAudio/PipeWire loopback.
Environment=PYTHONUNBUFFERED=1 Environment=PYTHONUNBUFFERED=1
Environment=HOME=${USER_HOME}
Environment=XDG_RUNTIME_DIR=${RUNTIME_DIR}
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=${RUNTIME_DIR}/bus
# Light sandboxing — keep ~/.config/media-server + ~/.cache/media-server
# writable so audit.log / thumbnails / config CRUD continue to work.
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=${USER_HOME}/.config/media-server ${USER_HOME}/.cache/media-server
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
EOF EOF
# Pre-create state dirs so ReadWritePaths actually has something to grant.
sudo -u "$REAL_USER" mkdir -p \
"${USER_HOME}/.config/media-server" \
"${USER_HOME}/.cache/media-server"
systemctl daemon-reload systemctl daemon-reload
systemctl enable "${SERVICE_NAME}" systemctl enable "${SERVICE_NAME}"
systemctl start "${SERVICE_NAME}" systemctl start "${SERVICE_NAME}"
echo "Service '${SERVICE_NAME}' installed and started." echo "Service '${SERVICE_NAME}' installed and started."
echo "Check status: systemctl status ${SERVICE_NAME}" echo "Check status: systemctl status ${SERVICE_NAME}"
echo "Tail logs: journalctl -u ${SERVICE_NAME} -f"
SERVICE SERVICE
chmod +x "${DIST_DIR}/install-service.sh" chmod +x "${DIST_DIR}/install-service.sh"
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
set -euo pipefail
# Build macOS distribution (self-contained venv + tarball)
# Usage: ./build-dist-macos.sh [VERSION]
#
# Must be run on macOS (PyObjC wheels are platform-specific). For CI use
# the github-hosted macos-latest runner.
source "$(dirname "$0")/build-common.sh"
if [ "$(uname -s)" != "Darwin" ]; then
echo "ERROR: build-dist-macos.sh must run on macOS (uname=$(uname -s))" >&2
exit 1
fi
detect_version "${1:-}"
echo "Building Media Server v${VERSION_CLEAN} for macOS"
# Detect host architecture for archive naming (arm64 = Apple Silicon, x86_64 = Intel).
ARCH="$(uname -m)"
DIST_DIR="dist/media-server"
BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-macos-${ARCH}"
clean_dist "${DIST_DIR}" build
verify_frontend
# --- Create self-contained virtualenv ---
echo "Creating virtualenv..."
python3 -m venv "${DIST_DIR}/venv"
# shellcheck disable=SC1091
source "${DIST_DIR}/venv/bin/activate"
pip install --quiet --upgrade pip
if ! pip install --quiet ".[macos]"; then
echo "WARN: '.[macos]' extra unavailable; installing base deps only" >&2
pip install --quiet "."
fi
# Remove the installed package (app source is on PYTHONPATH via launcher)
rm -rf "${DIST_DIR}"/venv/lib/python*/site-packages/media_server*
rm -rf "${DIST_DIR}"/venv/lib/python*/site-packages/media_server*.dist-info
deactivate
# Trim venv site-packages — macOS native ext is .so, dylibs are .dylib
MAC_SP=$(echo "${DIST_DIR}"/venv/lib/python*/site-packages)
cleanup_site_packages "$MAC_SP" "so" "dylib"
copy_app_files "$DIST_DIR"
# --- Launcher ---
cat > "${DIST_DIR}/media-server.sh" << 'LAUNCHER'
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
export PYTHONPATH="$SCRIPT_DIR/app"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/venv/bin/activate"
exec python -m media_server.main "$@"
LAUNCHER
chmod +x "${DIST_DIR}/media-server.sh"
# --- LaunchAgent installer ---
# LaunchAgents run as the user, with the user's GUI session — exactly what
# we want for AppleScript / Music.app / Spotify control. KeepAlive auto-
# restarts on crash; RunAtLoad starts at login.
cat > "${DIST_DIR}/install-launchagent.sh" << 'LAUNCHAGENT'
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
LABEL="com.dolgolyov.media-server"
PLIST_DIR="${HOME}/Library/LaunchAgents"
PLIST="${PLIST_DIR}/${LABEL}.plist"
LOG_DIR="${HOME}/Library/Logs/media-server"
mkdir -p "$PLIST_DIR" "$LOG_DIR"
cat > "$PLIST" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>${SCRIPT_DIR}/media-server.sh</string>
<string>--no-tray</string>
</array>
<key>WorkingDirectory</key>
<string>${SCRIPT_DIR}</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>StandardOutPath</key>
<string>${LOG_DIR}/stdout.log</string>
<key>StandardErrorPath</key>
<string>${LOG_DIR}/stderr.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PYTHONUNBUFFERED</key>
<string>1</string>
</dict>
</dict>
</plist>
EOF
# Reload if already loaded, then load fresh.
launchctl unload "$PLIST" 2>/dev/null || true
launchctl load -w "$PLIST"
echo "LaunchAgent '${LABEL}' installed and started."
echo "Plist: $PLIST"
echo "Logs: $LOG_DIR/{stdout,stderr}.log"
echo "Stop: launchctl unload \"$PLIST\""
echo "Start: launchctl load -w \"$PLIST\""
LAUNCHAGENT
chmod +x "${DIST_DIR}/install-launchagent.sh"
# Convenience uninstaller.
cat > "${DIST_DIR}/uninstall-launchagent.sh" << 'UNINSTALL'
#!/usr/bin/env bash
set -euo pipefail
LABEL="com.dolgolyov.media-server"
PLIST="${HOME}/Library/LaunchAgents/${LABEL}.plist"
launchctl unload "$PLIST" 2>/dev/null || true
rm -f "$PLIST"
echo "LaunchAgent '${LABEL}' removed (config preserved under ~/.config/media-server)."
UNINSTALL
chmod +x "${DIST_DIR}/uninstall-launchagent.sh"
# --- Package ---
echo "Creating archive..."
cp -r "${DIST_DIR}" "${BUILD_OUTPUT}"
tar -czf "${BUILD_OUTPUT}.tar.gz" -C build "MediaServer-v${VERSION_CLEAN}-macos-${ARCH}"
echo "Build complete: ${BUILD_OUTPUT}.tar.gz"
+85 -54
View File
@@ -21,108 +21,139 @@ host: "127.0.0.1"
port: 8765 port: 8765
# allow_lan_without_auth: true # uncomment + change host to 0.0.0.0 for LAN-open mode # allow_lan_without_auth: true # uncomment + change host to 0.0.0.0 for LAN-open mode
# Custom scripts # ─── Custom scripts ─────────────────────────────────────────────────────
#
# Examples below are platform-specific. Uncomment the block that matches
# your OS — YAML keys must be unique, so don't ship multiple
# `lock_screen:` entries.
scripts: scripts:
lock_screen: # ── Windows ─────────────────────────────────────────────────────────
command: "rundll32.exe user32.dll,LockWorkStation" # lock_screen:
label: "Lock Screen" # command: "rundll32.exe user32.dll,LockWorkStation"
description: "Lock the workstation" # label: "Lock Screen"
icon: "mdi:lock" # description: "Lock the workstation"
timeout: 5 # icon: "mdi:lock"
shell: true # timeout: 5
# shell: true
# sleep:
# command: "rundll32.exe powrprof.dll,SetSuspendState 0,1,0"
# label: "Sleep"
# icon: "mdi:sleep"
# timeout: 10
# shell: true
# shutdown:
# command: "shutdown /s /t 0"
# label: "Shutdown"
# icon: "mdi:power"
# timeout: 10
# shell: true
hibernate: # ── Linux (systemd / xdg) ───────────────────────────────────────────
command: "shutdown /h" # lock_screen:
label: "Hibernate" # command: "loginctl lock-session" # or: xdg-screensaver lock
description: "Hibernate the PC" # label: "Lock Screen"
icon: "mdi:power-sleep" # icon: "mdi:lock"
# timeout: 5
# shell: true
# sleep:
# command: "systemctl suspend"
# label: "Sleep"
# icon: "mdi:sleep"
# timeout: 10
# shell: true
# shutdown:
# command: "systemctl poweroff"
# label: "Shutdown"
# icon: "mdi:power"
# timeout: 10
# shell: true
# ── macOS ───────────────────────────────────────────────────────────
# lock_screen:
# command: "pmset displaysleepnow"
# label: "Lock Screen"
# icon: "mdi:lock"
# timeout: 5
# shell: true
# sleep:
# command: "pmset sleepnow"
# label: "Sleep"
# icon: "mdi:sleep"
# timeout: 5
# shell: true
# shutdown:
# command: "osascript -e 'tell application \"System Events\" to shut down'"
# label: "Shutdown"
# icon: "mdi:power"
# timeout: 10
# shell: true
# Cross-platform smoke test (always defined so first-run users see the UI populate).
example_script:
command: "echo Hello from Media Server!"
description: "Example script - echoes a message"
timeout: 10 timeout: 10
shell: true shell: true
sleep: # Media folder management from Web UI (default: false)
command: "rundll32.exe powrprof.dll,SetSuspendState 0,1,0"
label: "Sleep"
description: "Put PC to sleep"
icon: "mdi:sleep"
timeout: 10
shell: true
shutdown:
command: "shutdown /s /t 0"
label: "Shutdown"
description: "Shutdown the PC immediately"
icon: "mdi:power"
timeout: 10
shell: true
restart:
command: "shutdown /r /t 0"
label: "Restart"
description: "Restart the PC immediately"
icon: "mdi:restart"
timeout: 10
shell: true
# Media folder management from Web UI (default: true)
# When enabled, media folders can be added, edited, and deleted from the Settings tab. # When enabled, media folders can be added, edited, and deleted from the Settings tab.
# Set to false to disable folder management from the UI. # media_folders_management: true
# media_folders_management: false
# ─── Callback scripts (executed by integration events) ──────────────────
#
# Callbacks are OS-agnostic — if you want side effects, fill in commands
# below. The defaults are no-op echos so first-run logs don't error out.
# Callback scripts (executed after media actions)
# All callbacks are optional - if not defined, the action runs without callback
callbacks: callbacks:
# Media control callbacks (run after successful action)
on_play: on_play:
command: "echo Play triggered" command: "echo Play triggered"
timeout: 10 timeout: 10
shell: true shell: true
on_pause: on_pause:
command: "echo Pause triggered" command: "echo Pause triggered"
timeout: 10 timeout: 10
shell: true shell: true
on_stop: on_stop:
command: "echo Stop triggered" command: "echo Stop triggered"
timeout: 10 timeout: 10
shell: true shell: true
on_next: on_next:
command: "echo Next track" command: "echo Next track"
timeout: 10 timeout: 10
shell: true shell: true
on_previous: on_previous:
command: "echo Previous track" command: "echo Previous track"
timeout: 10 timeout: 10
shell: true shell: true
on_volume: on_volume:
command: "echo Volume changed" command: "echo Volume changed"
timeout: 10 timeout: 10
shell: true shell: true
on_mute: on_mute:
command: "echo Mute toggled" command: "echo Mute toggled"
timeout: 10 timeout: 10
shell: true shell: true
on_seek: on_seek:
command: "echo Seek triggered" command: "echo Seek triggered"
timeout: 10 timeout: 10
shell: true shell: true
# Turn on/off/toggle (callback-only actions, no default behavior) # Turn on/off/toggle (callback-only actions, no default behavior).
# The Windows-only example used to ship as the default for `on_turn_off`,
# which silently failed on Linux/macOS. Defaults are now no-ops — pick a
# platform-appropriate command below.
on_turn_on: on_turn_on:
command: "echo Turn on callback" command: "echo Turn on callback"
timeout: 10 timeout: 10
shell: true shell: true
on_turn_off: on_turn_off:
command: "rundll32.exe user32.dll,LockWorkStation" # Windows: "rundll32.exe user32.dll,LockWorkStation"
# Linux: "loginctl lock-session"
# macOS: "pmset displaysleepnow"
command: "echo Turn off callback"
timeout: 5 timeout: 5
shell: true shell: true
on_toggle: on_toggle:
command: "echo Toggle callback" command: "echo Toggle callback"
timeout: 10 timeout: 10
+24
View File
@@ -112,6 +112,30 @@ async def lifespan(app: FastAPI):
else: else:
logger.warning("No API tokens configured — authentication is DISABLED") logger.warning("No API tokens configured — authentication is DISABLED")
# Linux preflight: most MPRIS / PulseAudio failures are environmental
# (no DBUS_SESSION_BUS_ADDRESS, missing XDG_RUNTIME_DIR, systemd service
# started before logind). Surface that early so the failure mode is a
# warning at boot instead of silent "/api/media/status returns idle".
import os
import platform as _platform
if _platform.system() == "Linux":
missing = [
v for v in ("DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR")
if not os.environ.get(v)
]
if missing:
logger.warning(
"Linux preflight: %s not set — MPRIS / PulseAudio may be unavailable."
" Under systemd, run `loginctl enable-linger <user>` and ensure the"
" service unit sets DBUS_SESSION_BUS_ADDRESS + XDG_RUNTIME_DIR.",
", ".join(missing),
)
if os.environ.get("WAYLAND_DISPLAY"):
logger.info(
"Wayland session detected — foreground-window probe is intentionally"
" disabled (Wayland hides window info from unprivileged clients)."
)
update_checker = None update_checker = None
cleanup_task: asyncio.Task | None = None cleanup_task: asyncio.Task | None = None
analyzer = None analyzer = None
+32 -3
View File
@@ -18,7 +18,7 @@ from fastapi.responses import Response
from ..auth import verify_token, verify_token_or_query from ..auth import verify_token, verify_token_or_query
from ..config import settings from ..config import settings
from ..models import MediaStatus, SeekRequest, VolumeRequest from ..models import MediaStatus, SeekRequest, VolumeRequest
from ..services import get_current_album_art, get_media_controller from ..services import get_current_album_art_async, get_media_controller
from ..services.websocket_manager import ws_manager from ..services.websocket_manager import ws_manager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -287,7 +287,7 @@ async def get_artwork(
Returns the bytes with a content-derived ETag so the browser can serve a Returns the bytes with a content-derived ETag so the browser can serve a
304 when the same track is re-requested. 304 when the same track is re-requested.
""" """
art_bytes = get_current_album_art() art_bytes = await get_current_album_art_async()
if art_bytes is None: if art_bytes is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
@@ -326,14 +326,43 @@ async def get_artwork(
@router.get("/visualizer/status") @router.get("/visualizer/status")
async def visualizer_status(_: str = Depends(verify_token)) -> dict: async def visualizer_status(_: str = Depends(verify_token)) -> dict:
"""Check if audio visualizer is available and running.""" """Check if audio visualizer is available and running.
``available`` is True only when both numpy + soundcard import.
``unavailable_reason`` carries a short human-readable hint when False
or when a usable loopback device was not found — useful on Linux where
PulseAudio/PipeWire may not expose monitor sources to a headless
systemd-as-user service.
"""
import platform as _platform
from ..services.audio_analyzer import get_audio_analyzer from ..services.audio_analyzer import get_audio_analyzer
analyzer = get_audio_analyzer() analyzer = get_audio_analyzer()
reason: str | None = None
if not analyzer.available:
reason = "soundcard or numpy is not installed"
elif getattr(analyzer, "_unavailable", False):
if _platform.system() == "Linux":
reason = (
"No loopback audio device found. On Linux this requires a "
"running PulseAudio/PipeWire session with monitor sources "
"(systemd user service: ensure DBUS_SESSION_BUS_ADDRESS + "
"XDG_RUNTIME_DIR are set)."
)
elif _platform.system() == "Darwin":
reason = (
"No loopback audio device found. macOS does not expose system "
"loopback by default — install BlackHole or Soundflower."
)
else:
reason = "No loopback audio device found"
return { return {
"available": analyzer.available, "available": analyzer.available,
"running": analyzer.running, "running": analyzer.running,
"current_device": analyzer.current_device, "current_device": analyzer.current_device,
"unavailable_reason": reason,
} }
+19 -6
View File
@@ -57,25 +57,38 @@ install_service() {
# Create installation directory # Create installation directory
mkdir -p "$INSTALL_DIR" mkdir -p "$INSTALL_DIR"
# Copy source files # Resolve the source-tree root (two levels up from this script:
cp -r "$(dirname "$0")/../"* "$INSTALL_DIR/" # media_server/service/install_linux.sh → repo root).
SOURCE_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
# Copy the source tree (pyproject.toml + media_server/ package)
cp -r "$SOURCE_ROOT/." "$INSTALL_DIR/"
# Create virtual environment # Create virtual environment
echo_info "Creating Python virtual environment..." echo_info "Creating Python virtual environment..."
python3 -m venv "$INSTALL_DIR/venv" python3 -m venv "$INSTALL_DIR/venv"
# Install Python dependencies # Install Python dependencies from pyproject.toml (with linux extra).
# cd into the install dir so pip resolves `.[linux]` against the local
# pyproject — passing a path-with-extras (`$INSTALL_DIR[linux]`) trips
# the requirement-spec parser on some pip versions.
echo_info "Installing Python dependencies..." echo_info "Installing Python dependencies..."
"$INSTALL_DIR/venv/bin/pip" install --upgrade pip "$INSTALL_DIR/venv/bin/pip" install --upgrade pip
"$INSTALL_DIR/venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt" (cd "$INSTALL_DIR" && "$INSTALL_DIR/venv/bin/pip" install ".[linux]")
# Install systemd service file # Install systemd service file (templated unit)
echo_info "Installing systemd service..." echo_info "Installing systemd service..."
cp "$INSTALL_DIR/service/media-server.service" "$SERVICE_FILE" cp "$INSTALL_DIR/media_server/service/media-server.service" "$SERVICE_FILE"
# Reload systemd # Reload systemd
systemctl daemon-reload systemctl daemon-reload
# Pre-create writable state dirs so the unit's ReadWritePaths grant
# actually has something to grant.
sudo -u "$SUDO_USER" mkdir -p \
"/home/$SUDO_USER/.config/media-server" \
"/home/$SUDO_USER/.cache/media-server"
# Generate config if not exists # Generate config if not exists
if [[ ! -f "/home/$SUDO_USER/.config/media-server/config.yaml" ]]; then if [[ ! -f "/home/$SUDO_USER/.config/media-server/config.yaml" ]]; then
echo_info "Generating configuration file..." echo_info "Generating configuration file..."
+19 -15
View File
@@ -3,34 +3,38 @@ Description=Media Server - REST API for controlling system media playback
After=network.target sound.target After=network.target sound.target
Wants=sound.target Wants=sound.target
# Templated unit. Enable as:
# sudo systemctl enable --now media-server@$USER
# %i is the user name supplied after the '@'; %U is the matching numeric UID.
[Service] [Service]
Type=simple Type=simple
User=%i User=%i
Group=%i Group=%i
# Environment variables (optional - can also use config file) # Working directory (override via drop-in if you install elsewhere)
# Environment=MEDIA_SERVER_HOST=0.0.0.0
# Environment=MEDIA_SERVER_PORT=8765
# Environment=MEDIA_SERVER_API_TOKEN=your-secret-token
# Working directory
WorkingDirectory=/opt/media-server WorkingDirectory=/opt/media-server
# Start command - adjust path to your Python environment # Start command adjust to match where you installed the venv. --no-tray
ExecStart=/opt/media-server/venv/bin/python -m media_server.main # avoids pulling pystray into a headless service environment.
ExecStart=/opt/media-server/venv/bin/python -m media_server.main --no-tray
# Restart policy Restart=on-failure
Restart=always
RestartSec=10 RestartSec=10
# Security hardening # Required for MPRIS (dbus.SessionBus) and PulseAudio/PipeWire loopback.
Environment=PYTHONUNBUFFERED=1
Environment=XDG_RUNTIME_DIR=/run/user/%U
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%U/bus
# Light sandboxing. ProtectHome=read-only by itself blocks the app's own
# audit.log / thumbnail cache writes — ReadWritePaths re-opens just the
# two state dirs the server owns.
NoNewPrivileges=true NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict ProtectSystem=strict
ProtectHome=read-only ProtectHome=read-only
PrivateTmp=true ReadWritePaths=/home/%i/.config/media-server /home/%i/.cache/media-server
# Required for D-Bus access (MPRIS)
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%U/bus
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+28 -2
View File
@@ -65,7 +65,12 @@ def get_media_controller() -> "MediaController":
def get_current_album_art() -> bytes | None: def get_current_album_art() -> bytes | None:
"""Get the current album art bytes (Windows only for now).""" """Get the current album art bytes (synchronous, Windows-cached path).
Windows pre-populates a module-level cache via the WinRT polling thread,
so this stays sync. For Linux/macOS the controller fetches on demand —
use ``get_current_album_art_async()`` from FastAPI handlers instead.
"""
system = platform.system() system = platform.system()
if system == "Windows": if system == "Windows":
from .windows_media import get_current_album_art as _get_art from .windows_media import get_current_album_art as _get_art
@@ -73,6 +78,22 @@ def get_current_album_art() -> bytes | None:
return None return None
async def get_current_album_art_async() -> bytes | None:
"""Cross-platform album art fetch. Awaits the controller's impl.
Falls back to the sync Windows cache when running on Windows so we don't
pay an extra coroutine hop for the existing path.
"""
system = platform.system()
if system == "Windows":
return get_current_album_art()
controller = get_media_controller()
try:
return await controller.get_album_art()
except Exception: # noqa: BLE001 — art is best-effort; never break the route
return None
def get_audio_devices() -> list[dict[str, str]]: def get_audio_devices() -> list[dict[str, str]]:
"""Get list of available audio output devices (Windows only for now).""" """Get list of available audio output devices (Windows only for now)."""
system = platform.system() system = platform.system()
@@ -82,4 +103,9 @@ def get_audio_devices() -> list[dict[str, str]]:
return [] return []
__all__ = ["get_media_controller", "get_current_album_art", "get_audio_devices"] __all__ = [
"get_media_controller",
"get_current_album_art",
"get_current_album_art_async",
"get_audio_devices",
]
+118 -2
View File
@@ -2,14 +2,23 @@
import asyncio import asyncio
import logging import logging
import os
import subprocess import subprocess
import threading
from typing import Any, Optional from typing import Any, Optional
from urllib.parse import unquote, urlparse
from ..models import MediaState, MediaStatus from ..models import MediaState, MediaStatus
from .media_controller import MediaController from .media_controller import MediaController
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Cap remote artwork downloads so a hostile / huge Spotify image can't
# blow up RAM. Real album art rarely exceeds ~2 MB; 8 MB is a comfortable
# upper bound that also covers loss-less PNGs.
_MAX_ART_BYTES = 8 * 1024 * 1024
_ART_FETCH_TIMEOUT = 5.0 # seconds
# Linux-specific imports # Linux-specific imports
try: try:
import dbus import dbus
@@ -35,13 +44,54 @@ class LinuxMediaController(MediaController):
"Linux media control requires dbus-python package. " "Linux media control requires dbus-python package. "
"Install with: sudo apt-get install python3-dbus" "Install with: sudo apt-get install python3-dbus"
) )
# The session-bus connection is deferred until first use. Connecting
# in __init__ raised during app startup whenever the user's session
# bus wasn't ready yet — common under systemd (service starts
# before logind set up /run/user/<uid>/bus), under SSH-without-X11,
# and in headless CI. Failing here killed the whole lifespan; now
# MPRIS calls return "idle" until the bus appears, and other
# endpoints (health, scripts, browser, …) keep working.
self._bus_lock = threading.Lock()
self._bus = None # type: ignore[assignment]
self._bus_init_logged = False
# Cached art bytes keyed by the mpris:artUrl currently in flight.
# Lock guards the swap from the status thread vs the artwork handler.
self._art_lock = threading.Lock()
self._art_url: Optional[str] = None
self._art_bytes: Optional[bytes] = None
def _get_bus(self):
"""Lazily connect to the session bus; returns None if unavailable."""
if self._bus is not None:
return self._bus
with self._bus_lock:
if self._bus is not None:
return self._bus
try:
DBusGMainLoop(set_as_default=True) DBusGMainLoop(set_as_default=True)
self._bus = dbus.SessionBus() self._bus = dbus.SessionBus()
logger.info("Connected to D-Bus session bus")
return self._bus
except Exception as e:
# Log once at INFO to avoid log spam if every status poll fails.
if not self._bus_init_logged:
logger.info(
"D-Bus session bus not available (%s). "
"MPRIS calls will return 'idle' until DBUS_SESSION_BUS_ADDRESS"
" is set and the bus is reachable. Under systemd, ensure"
" `loginctl enable-linger <user>` is set.",
e,
)
self._bus_init_logged = True
return None
def _get_active_player(self) -> Optional[str]: def _get_active_player(self) -> Optional[str]:
"""Find an active MPRIS media player on the bus.""" """Find an active MPRIS media player on the bus."""
bus = self._get_bus()
if bus is None:
return None
try: try:
bus_names = self._bus.list_names() bus_names = bus.list_names()
mpris_players = [ mpris_players = [
name for name in bus_names if name.startswith(self.MPRIS_PREFIX) name for name in bus_names if name.startswith(self.MPRIS_PREFIX)
] ]
@@ -181,7 +231,15 @@ class LinuxMediaController(MediaController):
if artists: if artists:
status.artist = str(artists[0]) if isinstance(artists, list) else str(artists) status.artist = str(artists[0]) if isinstance(artists, list) else str(artists)
status.album = str(metadata.get("xesam:album", "")) or None status.album = str(metadata.get("xesam:album", "")) or None
status.album_art_url = str(metadata.get("mpris:artUrl", "")) or None art_url = str(metadata.get("mpris:artUrl", "")) or None
status.album_art_url = art_url
# Invalidate cached bytes when the track changes. Real fetch
# happens lazily in get_album_art() so the status hot path
# never blocks on HTTP.
with self._art_lock:
if art_url != self._art_url:
self._art_url = art_url
self._art_bytes = None
length = metadata.get("mpris:length", 0) length = metadata.get("mpris:length", 0)
if length: if length:
status.duration = int(length) / 1_000_000 status.duration = int(length) / 1_000_000
@@ -273,3 +331,61 @@ class LinuxMediaController(MediaController):
except Exception as e: except Exception as e:
logger.error(f"Failed to open file {file_path}: {e}") logger.error(f"Failed to open file {file_path}: {e}")
return False return False
def _fetch_art_sync(self, url: str) -> Optional[bytes]:
"""Resolve an ``mpris:artUrl`` to raw bytes (file://, http(s)://).
Other schemes (data:, ftp:, …) are rejected — we only support the
two cases real-world MPRIS providers use. The HTTP path is capped
at _MAX_ART_BYTES and the file path is read with a size guard so a
symlink to /dev/zero can't OOM the server.
"""
try:
parsed = urlparse(url)
except ValueError:
return None
scheme = parsed.scheme.lower()
if scheme == "file":
path = unquote(parsed.path)
try:
size = os.stat(path).st_size
if size <= 0 or size > _MAX_ART_BYTES:
return None
with open(path, "rb") as f:
return f.read(_MAX_ART_BYTES)
except OSError as e:
logger.debug("Could not read local art %s: %s", path, e)
return None
if scheme in ("http", "https"):
import urllib.error
import urllib.request
req = urllib.request.Request(url, headers={"User-Agent": "media-server/0.x"})
try:
with urllib.request.urlopen(req, timeout=_ART_FETCH_TIMEOUT) as resp:
# Cap reads to defend against unbounded responses.
return resp.read(_MAX_ART_BYTES + 1)[:_MAX_ART_BYTES] or None
except (urllib.error.URLError, urllib.error.HTTPError, OSError) as e:
logger.debug("Could not fetch remote art %s: %s", url, e)
return None
logger.debug("Unsupported art URL scheme: %s", scheme)
return None
async def get_album_art(self) -> Optional[bytes]:
"""Return cached MPRIS art, fetching on first access per track."""
with self._art_lock:
url = self._art_url
cached = self._art_bytes
if cached is not None:
return cached
if not url:
return None
data = await asyncio.to_thread(self._fetch_art_sync, url)
# Store even on None so we don't re-hammer a 404 every second.
with self._art_lock:
if url == self._art_url:
self._art_bytes = data
return data
+60 -1
View File
@@ -3,6 +3,7 @@
import asyncio import asyncio
import logging import logging
import subprocess import subprocess
import threading
from typing import Optional from typing import Optional
from ..models import MediaState, MediaStatus from ..models import MediaState, MediaStatus
@@ -10,10 +11,20 @@ from .media_controller import MediaController
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Cap remote artwork downloads (Spotify's artwork url is http(s)://).
_MAX_ART_BYTES = 8 * 1024 * 1024
_ART_FETCH_TIMEOUT = 5.0 # seconds
class MacOSMediaController(MediaController): class MacOSMediaController(MediaController):
"""Media controller for macOS using osascript and system commands.""" """Media controller for macOS using osascript and system commands."""
def __init__(self) -> None:
# Cached art bytes keyed by the active art URL.
self._art_lock = threading.Lock()
self._art_url: Optional[str] = None
self._art_bytes: Optional[bytes] = None
def _run_osascript(self, script: str) -> Optional[str]: def _run_osascript(self, script: str) -> Optional[str]:
"""Run an AppleScript and return the output.""" """Run an AppleScript and return the output."""
try: try:
@@ -193,12 +204,60 @@ class MacOSMediaController(MediaController):
status.album = info.get("album") status.album = info.get("album")
status.duration = info.get("duration") status.duration = info.get("duration")
status.position = info.get("position") status.position = info.get("position")
status.album_art_url = info.get("art_url") art_url = info.get("art_url")
status.album_art_url = art_url
# Track changes invalidate the cached image bytes — actual
# fetch happens lazily in get_album_art().
with self._art_lock:
if art_url != self._art_url:
self._art_url = art_url
self._art_bytes = None
else: else:
status.state = MediaState.IDLE status.state = MediaState.IDLE
return status return status
def _fetch_art_sync(self, url: str) -> Optional[bytes]:
"""Resolve a Spotify/Music art URL (http(s)://) to bytes.
File-scheme URLs aren't expected on macOS (AppleScript apps return
artwork as remote URLs), so only http(s) is supported.
"""
try:
from urllib.parse import urlparse
parsed = urlparse(url)
except ValueError:
return None
if parsed.scheme.lower() not in ("http", "https"):
return None
import urllib.error
import urllib.request
req = urllib.request.Request(url, headers={"User-Agent": "media-server/0.x"})
try:
with urllib.request.urlopen(req, timeout=_ART_FETCH_TIMEOUT) as resp:
return resp.read(_MAX_ART_BYTES + 1)[:_MAX_ART_BYTES] or None
except (urllib.error.URLError, urllib.error.HTTPError, OSError) as e:
logger.debug("Could not fetch macOS art %s: %s", url, e)
return None
async def get_album_art(self) -> Optional[bytes]:
"""Return cached art bytes, fetching once per track URL."""
with self._art_lock:
url = self._art_url
cached = self._art_bytes
if cached is not None:
return cached
if not url:
return None
data = await asyncio.to_thread(self._fetch_art_sync, url)
with self._art_lock:
if url == self._art_url:
self._art_bytes = data
return data
async def play(self) -> bool: async def play(self) -> bool:
"""Resume playback using media key simulation.""" """Resume playback using media key simulation."""
# Use system media key # Use system media key
@@ -106,3 +106,12 @@ class MediaController(ABC):
True if successful, False otherwise True if successful, False otherwise
""" """
pass pass
async def get_album_art(self) -> bytes | None:
"""Return the current album art bytes, or ``None`` when unavailable.
Default impl returns ``None`` — controllers that can produce art
(Windows via SMTC thumbnail, Linux via mpris:artUrl, macOS via the
Spotify/Music artwork-url field) override this.
"""
return None
+16 -2
View File
@@ -3,6 +3,7 @@
import ctypes import ctypes
import io import io
import logging import logging
import sys
import webbrowser import webbrowser
from pathlib import Path from pathlib import Path
from typing import Callable from typing import Callable
@@ -30,12 +31,25 @@ _IDYES = 6
def _confirm(title: str, message: str) -> bool: def _confirm(title: str, message: str) -> bool:
"""Show a Yes/No dialog using native Windows MessageBox.""" """Show a Yes/No dialog before a destructive tray action.
Uses the native Windows MessageBox on win32; on Linux/macOS we don't
have a no-dependency GUI dialog available (Tk pulls in tkinter, gtk
pulls in PyGObject), so we log + auto-confirm — the tray menu items
themselves require a deliberate click already.
"""
if sys.platform == "win32":
result = ctypes.windll.user32.MessageBoxW( result = ctypes.windll.user32.MessageBoxW(
0, message, title, _MB_YESNO | _MB_ICONQUESTION | _MB_TOPMOST | _MB_SETFOREGROUND 0,
message,
title,
_MB_YESNO | _MB_ICONQUESTION | _MB_TOPMOST | _MB_SETFOREGROUND,
) )
return result == _IDYES return result == _IDYES
logger.info("Tray confirm (auto-yes on non-Windows): %s%s", title, message)
return True
def _create_icon_image(size: int = 64) -> Image.Image: def _create_icon_image(size: int = 64) -> Image.Image:
"""Create a tray icon: green circle with white play triangle.""" """Create a tray icon: green circle with white play triangle."""
+23 -6
View File
@@ -34,19 +34,36 @@ dependencies = [
"pillow>=10.0.0", "pillow>=10.0.0",
"soundcard>=0.4.0", "soundcard>=0.4.0",
"numpy>=1.24.0,<2.0", "numpy>=1.24.0,<2.0",
# screen-brightness-control + monitorcontrol are cross-platform
# (Windows / Linux i2c-dev). Kept in base deps so a plain
# `pip install media-server` yields a working /api/display on any
# platform that has the underlying hardware support.
"screen-brightness-control>=0.20.0",
"monitorcontrol>=3.0.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
windows = [ windows = [
"winsdk>=1.0.0b10", "winsdk>=1.0.0b10",
"pywin32>=306", "pywin32>=306; sys_platform == 'win32'",
"comtypes>=1.2.0", "comtypes>=1.2.0; sys_platform == 'win32'",
"pycaw>=20230407", "pycaw>=20230407; sys_platform == 'win32'",
"screen-brightness-control>=0.20.0", "wmi>=1.5.1; sys_platform == 'win32'",
"wmi>=1.5.1",
"monitorcontrol>=3.0.0",
"pystray>=0.19.0", "pystray>=0.19.0",
] ]
linux = [
# MPRIS / D-Bus media control. Requires libdbus-1-dev, libglib2.0-dev,
# and pkg-config installed on the host before pip install.
"dbus-python>=1.3.2; sys_platform == 'linux'",
"PyGObject>=3.46; sys_platform == 'linux'",
# X11 foreground-window probe (Wayland sessions degrade gracefully).
"python-xlib>=0.33; sys_platform == 'linux'",
]
macos = [
# PyObjC for the foreground-window + active-app probes.
"pyobjc-framework-Cocoa>=10.0; sys_platform == 'darwin'",
"pyobjc-framework-Quartz>=10.0; sys_platform == 'darwin'",
]
dev = [ dev = [
"pytest>=7.0", "pytest>=7.0",
"pytest-asyncio>=0.21", "pytest-asyncio>=0.21",