From ddf4a6cb2961cadd12d9d1e7cfedb1e8d536ce5a Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 26 May 2026 12:17:30 +0300 Subject: [PATCH] feat: production-ready Linux & macOS support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .gitea/workflows/release.yml | 68 +++++++++++ .gitea/workflows/test.yml | 61 ++++++++++ README.md | 70 +++++++++-- build-dist-linux.sh | 55 ++++++++- build-dist-macos.sh | 142 ++++++++++++++++++++++ config.example.yaml | 141 ++++++++++++--------- media_server/main.py | 24 ++++ media_server/routes/media.py | 35 +++++- media_server/service/install_linux.sh | 25 +++- media_server/service/media-server.service | 34 +++--- media_server/services/__init__.py | 30 ++++- media_server/services/linux_media.py | 124 ++++++++++++++++++- media_server/services/macos_media.py | 61 +++++++++- media_server/services/media_controller.py | 9 ++ media_server/tray.py | 24 +++- pyproject.toml | 29 ++++- 16 files changed, 823 insertions(+), 109 deletions(-) create mode 100644 build-dist-macos.sh diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 6011f1a..cc48247 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -60,6 +60,8 @@ jobs: | Windows (installer) | \`MediaServer-{tag}-setup.exe\` | | Windows (portable) | \`MediaServer-{tag}-win-x64.zip\` | | 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()) print(json.dumps('\n\n'.join(sections))) @@ -187,6 +189,13 @@ jobs: with: 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 run: | chmod +x build-dist-linux.sh @@ -226,3 +235,62 @@ jobs: -H "Authorization: token $DEPLOY_TOKEN" \ -H "Content-Type: application/octet-stream" \ --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" diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 3c0fe3d..ab008ed 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -34,3 +34,64 @@ jobs: - name: Test 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 + ' diff --git a/README.md b/README.md index e176994..a23f9a4 100644 --- a/README.md +++ b/README.md @@ -285,37 +285,44 @@ All connected WebSocket clients receive a `links_changed` notification when link ## 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 ```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 ```bash -# Install system dependencies -sudo apt-get install python3-dbus python3-gi libdbus-1-dev libglib2.0-dev +# System packages required to build dbus-python + PyGObject from sdist. +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 ```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) ```bash # In Termux pkg install python termux-api -pip install -r requirements.txt +pip install "." ``` Requires Termux and Termux:API apps from F-Droid. @@ -835,11 +842,19 @@ 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 -sudo systemctl enable media-server@$USER -sudo systemctl start media-server@$USER +sudo loginctl enable-linger $USER +``` + +Enable and start the templated unit for your user: + +```bash +sudo systemctl enable --now media-server@$USER ``` View logs: @@ -848,6 +863,39 @@ View logs: 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 ```text diff --git a/build-dist-linux.sh b/build-dist-linux.sh index a931c69..4c011fa 100644 --- a/build-dist-linux.sh +++ b/build-dist-linux.sh @@ -16,12 +16,38 @@ BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-linux-x64" clean_dist "${DIST_DIR}" build 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 --- echo "Creating virtualenv..." python3 -m venv "${DIST_DIR}/venv" source "${DIST_DIR}/venv/bin/activate" pip install --quiet --upgrade pip -pip install --quiet "." +# 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 "." +fi # Remove the installed package (app source is on PYTHONPATH via launcher) rm -rf "${DIST_DIR}"/venv/lib/python*/site-packages/media_server* @@ -47,6 +73,11 @@ LAUNCHER chmod +x "${DIST_DIR}/media-server.sh" # --- 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' #!/usr/bin/env bash set -euo pipefail @@ -61,6 +92,9 @@ if [ "$EUID" -ne 0 ]; then fi 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 [Unit] @@ -74,17 +108,36 @@ WorkingDirectory=${SCRIPT_DIR} ExecStart=${SCRIPT_DIR}/media-server.sh Restart=on-failure RestartSec=5 + +# Required by MPRIS (dbus.SessionBus) and PulseAudio/PipeWire loopback. 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] WantedBy=multi-user.target 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 enable "${SERVICE_NAME}" systemctl start "${SERVICE_NAME}" echo "Service '${SERVICE_NAME}' installed and started." echo "Check status: systemctl status ${SERVICE_NAME}" +echo "Tail logs: journalctl -u ${SERVICE_NAME} -f" SERVICE chmod +x "${DIST_DIR}/install-service.sh" diff --git a/build-dist-macos.sh b/build-dist-macos.sh new file mode 100644 index 0000000..0fa0a98 --- /dev/null +++ b/build-dist-macos.sh @@ -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 + + + + + Label + ${LABEL} + ProgramArguments + + ${SCRIPT_DIR}/media-server.sh + --no-tray + + WorkingDirectory + ${SCRIPT_DIR} + RunAtLoad + + KeepAlive + + SuccessfulExit + + + StandardOutPath + ${LOG_DIR}/stdout.log + StandardErrorPath + ${LOG_DIR}/stderr.log + EnvironmentVariables + + PYTHONUNBUFFERED + 1 + + + +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" diff --git a/config.example.yaml b/config.example.yaml index 0df2c17..13a8ddf 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -21,109 +21,140 @@ host: "127.0.0.1" port: 8765 # 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: - lock_screen: - command: "rundll32.exe user32.dll,LockWorkStation" - label: "Lock Screen" - description: "Lock the workstation" - icon: "mdi:lock" - timeout: 5 - shell: true + # ── Windows ───────────────────────────────────────────────────────── + # lock_screen: + # command: "rundll32.exe user32.dll,LockWorkStation" + # label: "Lock Screen" + # description: "Lock the workstation" + # icon: "mdi:lock" + # 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: - command: "shutdown /h" - label: "Hibernate" - description: "Hibernate the PC" - icon: "mdi:power-sleep" + # ── Linux (systemd / xdg) ─────────────────────────────────────────── + # lock_screen: + # command: "loginctl lock-session" # or: xdg-screensaver lock + # label: "Lock Screen" + # 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 shell: true - sleep: - 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) +# Media folder management from Web UI (default: false) # 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: false +# media_folders_management: true + +# ─── 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: - # Media control callbacks (run after successful action) on_play: command: "echo Play triggered" timeout: 10 shell: true - on_pause: command: "echo Pause triggered" timeout: 10 shell: true - on_stop: command: "echo Stop triggered" timeout: 10 shell: true - on_next: command: "echo Next track" timeout: 10 shell: true - on_previous: command: "echo Previous track" timeout: 10 shell: true - on_volume: command: "echo Volume changed" timeout: 10 shell: true - on_mute: command: "echo Mute toggled" timeout: 10 shell: true - on_seek: command: "echo Seek triggered" timeout: 10 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: command: "echo Turn on callback" timeout: 10 shell: true - 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 shell: true - on_toggle: command: "echo Toggle callback" timeout: 10 - shell: true \ No newline at end of file + shell: true diff --git a/media_server/main.py b/media_server/main.py index 13b23e8..7474ce4 100644 --- a/media_server/main.py +++ b/media_server/main.py @@ -112,6 +112,30 @@ async def lifespan(app: FastAPI): else: 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 ` 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 cleanup_task: asyncio.Task | None = None analyzer = None diff --git a/media_server/routes/media.py b/media_server/routes/media.py index c1db1e9..e382e32 100644 --- a/media_server/routes/media.py +++ b/media_server/routes/media.py @@ -18,7 +18,7 @@ from fastapi.responses import Response from ..auth import verify_token, verify_token_or_query from ..config import settings 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 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 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: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -326,14 +326,43 @@ async def get_artwork( @router.get("/visualizer/status") 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 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 { "available": analyzer.available, "running": analyzer.running, "current_device": analyzer.current_device, + "unavailable_reason": reason, } diff --git a/media_server/service/install_linux.sh b/media_server/service/install_linux.sh index d5f6b12..3cbb039 100644 --- a/media_server/service/install_linux.sh +++ b/media_server/service/install_linux.sh @@ -57,25 +57,38 @@ install_service() { # Create installation directory mkdir -p "$INSTALL_DIR" - # Copy source files - cp -r "$(dirname "$0")/../"* "$INSTALL_DIR/" + # Resolve the source-tree root (two levels up from this script: + # 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 echo_info "Creating Python virtual environment..." 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..." "$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..." - cp "$INSTALL_DIR/service/media-server.service" "$SERVICE_FILE" + cp "$INSTALL_DIR/media_server/service/media-server.service" "$SERVICE_FILE" # Reload systemd 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 if [[ ! -f "/home/$SUDO_USER/.config/media-server/config.yaml" ]]; then echo_info "Generating configuration file..." diff --git a/media_server/service/media-server.service b/media_server/service/media-server.service index ba06537..65817be 100644 --- a/media_server/service/media-server.service +++ b/media_server/service/media-server.service @@ -3,34 +3,38 @@ Description=Media Server - REST API for controlling system media playback After=network.target 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] Type=simple User=%i Group=%i -# Environment variables (optional - can also use config file) -# Environment=MEDIA_SERVER_HOST=0.0.0.0 -# Environment=MEDIA_SERVER_PORT=8765 -# Environment=MEDIA_SERVER_API_TOKEN=your-secret-token - -# Working directory +# Working directory (override via drop-in if you install elsewhere) WorkingDirectory=/opt/media-server -# Start command - adjust path to your Python environment -ExecStart=/opt/media-server/venv/bin/python -m media_server.main +# Start command — adjust to match where you installed the venv. --no-tray +# avoids pulling pystray into a headless service environment. +ExecStart=/opt/media-server/venv/bin/python -m media_server.main --no-tray -# Restart policy -Restart=always +Restart=on-failure 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 +PrivateTmp=true ProtectSystem=strict ProtectHome=read-only -PrivateTmp=true - -# Required for D-Bus access (MPRIS) -Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%U/bus +ReadWritePaths=/home/%i/.config/media-server /home/%i/.cache/media-server [Install] WantedBy=multi-user.target diff --git a/media_server/services/__init__.py b/media_server/services/__init__.py index 39de409..3a65eec 100644 --- a/media_server/services/__init__.py +++ b/media_server/services/__init__.py @@ -65,7 +65,12 @@ def get_media_controller() -> "MediaController": 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() if system == "Windows": from .windows_media import get_current_album_art as _get_art @@ -73,6 +78,22 @@ def get_current_album_art() -> bytes | 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]]: """Get list of available audio output devices (Windows only for now).""" system = platform.system() @@ -82,4 +103,9 @@ def get_audio_devices() -> list[dict[str, str]]: 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", +] diff --git a/media_server/services/linux_media.py b/media_server/services/linux_media.py index b022677..1c217d1 100644 --- a/media_server/services/linux_media.py +++ b/media_server/services/linux_media.py @@ -2,14 +2,23 @@ import asyncio import logging +import os import subprocess +import threading from typing import Any, Optional +from urllib.parse import unquote, urlparse from ..models import MediaState, MediaStatus from .media_controller import MediaController 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 try: import dbus @@ -35,13 +44,54 @@ class LinuxMediaController(MediaController): "Linux media control requires dbus-python package. " "Install with: sudo apt-get install python3-dbus" ) - DBusGMainLoop(set_as_default=True) - self._bus = dbus.SessionBus() + # 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//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) + 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 ` is set.", + e, + ) + self._bus_init_logged = True + return None def _get_active_player(self) -> Optional[str]: """Find an active MPRIS media player on the bus.""" + bus = self._get_bus() + if bus is None: + return None try: - bus_names = self._bus.list_names() + bus_names = bus.list_names() mpris_players = [ name for name in bus_names if name.startswith(self.MPRIS_PREFIX) ] @@ -181,7 +231,15 @@ class LinuxMediaController(MediaController): if artists: status.artist = str(artists[0]) if isinstance(artists, list) else str(artists) 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) if length: status.duration = int(length) / 1_000_000 @@ -273,3 +331,61 @@ class LinuxMediaController(MediaController): except Exception as e: logger.error(f"Failed to open file {file_path}: {e}") 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 diff --git a/media_server/services/macos_media.py b/media_server/services/macos_media.py index 86b1642..238e1f7 100644 --- a/media_server/services/macos_media.py +++ b/media_server/services/macos_media.py @@ -3,6 +3,7 @@ import asyncio import logging import subprocess +import threading from typing import Optional from ..models import MediaState, MediaStatus @@ -10,10 +11,20 @@ from .media_controller import MediaController 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): """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]: """Run an AppleScript and return the output.""" try: @@ -193,12 +204,60 @@ class MacOSMediaController(MediaController): status.album = info.get("album") status.duration = info.get("duration") 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: status.state = MediaState.IDLE 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: """Resume playback using media key simulation.""" # Use system media key diff --git a/media_server/services/media_controller.py b/media_server/services/media_controller.py index d69ac55..008ace7 100644 --- a/media_server/services/media_controller.py +++ b/media_server/services/media_controller.py @@ -106,3 +106,12 @@ class MediaController(ABC): True if successful, False otherwise """ 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 diff --git a/media_server/tray.py b/media_server/tray.py index 9d27b10..cd01a8e 100644 --- a/media_server/tray.py +++ b/media_server/tray.py @@ -3,6 +3,7 @@ import ctypes import io import logging +import sys import webbrowser from pathlib import Path from typing import Callable @@ -30,11 +31,24 @@ _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 + """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( + 0, + message, + title, + _MB_YESNO | _MB_ICONQUESTION | _MB_TOPMOST | _MB_SETFOREGROUND, + ) + 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: diff --git a/pyproject.toml b/pyproject.toml index a730d50..43717f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,19 +34,36 @@ dependencies = [ "pillow>=10.0.0", "soundcard>=0.4.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] windows = [ "winsdk>=1.0.0b10", - "pywin32>=306", - "comtypes>=1.2.0", - "pycaw>=20230407", - "screen-brightness-control>=0.20.0", - "wmi>=1.5.1", - "monitorcontrol>=3.0.0", + "pywin32>=306; sys_platform == 'win32'", + "comtypes>=1.2.0; sys_platform == 'win32'", + "pycaw>=20230407; sys_platform == 'win32'", + "wmi>=1.5.1; sys_platform == 'win32'", "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 = [ "pytest>=7.0", "pytest-asyncio>=0.21",