Compare commits

...

8 Commits

Author SHA1 Message Date
alexei.dolgolyov 2ddbb93537 ci: seed config before linux-smoke launch so the server actually serves
Lint & Test / test (push) Successful in 12s
Lint & Test / linux-smoke (push) Successful in 20s
With the PyGObject girepository-2.0 fix in place, the linux-smoke step
ran its server-boot assertion for the first time and failed: on a fresh
runner the first-run bootstrap writes a default config and calls
sys.exit(0) ("First run: generated default config ... then restart")
instead of serving, so /api/health never came up and the 15s wait
timed out.

That exit-on-first-run is deliberate product behavior (never silently
start in insecure no-auth mode), so adjust the test rather than the app:
invoke the server once to seed the config (it exits 0 before binding the
port), then launch it for real. /api/health requires no auth, so the
auto-generated token is irrelevant to the check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:34:00 +03:00
alexei.dolgolyov b7e50455ad ci: fix Linux build — install libgirepository-2.0-dev for PyGObject
Lint & Test / test (push) Successful in 16s
Lint & Test / linux-smoke (push) Failing after 49s
PyGObject >= 3.52 dropped the standalone gobject-introspection
girepository-1.0 dependency and now builds against girepository-2.0,
which was merged into GLib 2.80. The linux extra pins PyGObject>=3.46
with no upper bound, so pip resolves the newest release (3.56.3) and
meson aborts metadata generation with:

  Dependency 'girepository-2.0' is required but not found.

because CI only installed the old libgirepository1.0-dev.

Swap libgirepository1.0-dev -> libgirepository-2.0-dev (shipped by
GLib 2.80 on the ubuntu-latest / 24.04 runner) across all three Linux
pip-install paths so they stay in sync:

- test.yml: the failing linux-smoke job.
- release.yml: build-linux, which would otherwise ship a broken
  Linux tarball on the next tag.
- build.yml: build-linux had no system-deps step at all; added the
  matching apt install so the manual artifact build works too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:18:51 +03:00
alexei.dolgolyov 0006620eb5 ci: temporarily disable macOS build job (no runner available)
Lint & Test / test (push) Successful in 13s
Lint & Test / linux-smoke (push) Failing after 29s
The Gitea instance currently has no macOS runner attached, so the
build-macos job was failing visibly on every release even with
continue-on-error: true, and the release body advertised macOS
downloads that were never produced.

- Gate build-macos with `if: false` (job is preserved verbatim
  except for the gate, so re-enablement is a one-line delete).
- Drop the macOS rows from the Downloads table generated by the
  create-release job. Kept as commented-out lines inside the heredoc.

Both changes carry a `TODO(macos-runner)` marker — `grep` for it
to find every site that needs flipping when a macOS runner is
connected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:34:09 +03:00
alexei.dolgolyov e7a3f62a9a chore: release v0.4.0
Lint & Test / test (push) Has been skipped
Lint & Test / linux-smoke (push) Has been skipped
Release / create-release (push) Successful in 3s
Release / build-windows (push) Successful in 1m9s
Release / build-linux (push) Successful in 1m10s
Release / build-macos (push) Has been cancelled
2026-05-28 17:27:37 +03:00
alexei.dolgolyov d798fedf55 feat(icon): redesign app icon as "Beacon" and ship multi-resolution ICO
Lint & Test / test (push) Successful in 20s
Lint & Test / linux-smoke (push) Failing after 34s
- Replace generic Spotify-green circle with a refined "Beacon" design:
  squircle + deep-teal diagonal gradient (#0B3D3B → #1A6B5E) + warm
  parchment play triangle (#F5F1E8) with drop shadow, top sheen, and
  ghosted echo-chevrons hinting at broadcast/stream
- Grow icon.ico from a single 16×16 frame (208 B) to a 10-frame
  multi-resolution ICO (16/20/24/32/40/48/64/96/128/256, ~37 KB) so
  Windows no longer upscales 16×16 into mush for the installer chrome,
  Start Menu, desktop shortcuts, Alt+Tab, and File Explorer tiles
- Add scripts/generate-icon.py: SVG is the source of truth; resvg-py
  rasterizes every ICO size; Pillow packs the multi-resolution ICO
- Update tray.py to pick a 64×64 frame from the new ICO and update its
  procedural fallback to the same Beacon palette so a missing ICO no
  longer regresses the tray to the old Spotify-green circle
- Add resvg-py to [dev] deps (build-time only)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:18:58 +03:00
alexei.dolgolyov ddf4a6cb29 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.
2026-05-26 12:17:30 +03:00
alexei.dolgolyov 82710c6457 chore: release v0.3.1
Lint & Test / test (push) Has been skipped
Release / create-release (push) Successful in 4s
Release / build-linux (push) Successful in 28s
Release / build-windows (push) Successful in 52s
2026-05-25 23:45:08 +03:00
alexei.dolgolyov 9b9a2b5c9f fix(ws): accept same-origin WebSocket connections in default Origin allow-list
When `cors_origins` was unset, the WS endpoint only allowed
`http://localhost:<port>` and `http://127.0.0.1:<port>` as origins, so a
browser opening the UI via the LAN IP (e.g. `http://192.168.2.100:8765`
when bound to `0.0.0.0`) had its WebSocket closed with code 4003 and
never recovered — leaving the Web UI in a permanent reconnect loop.

Also accept any `Origin` whose authority matches the request's `Host`
header (both `http://` and `https://` schemes). Same-origin is by
definition not CSWSH, so the cross-origin defence added in v0.3.0
remains intact for genuine third-party LAN pages.
2026-05-25 23:44:57 +03:00
24 changed files with 1143 additions and 202 deletions
+10
View File
@@ -60,6 +60,16 @@ jobs:
with: with:
python-version: '3.11' python-version: '3.11'
- name: Install native deps for dbus-python + PyGObject
run: |
# PyGObject >= 3.52 builds against girepository-2.0 (merged into
# GLib 2.80), not the old standalone girepository-1.0. ubuntu-latest
# (24.04) ships it as libgirepository-2.0-dev.
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libdbus-1-dev libglib2.0-dev pkg-config \
libcairo2-dev libgirepository-2.0-dev
- name: Build Linux distribution - name: Build Linux distribution
run: | run: |
chmod +x build-dist-linux.sh chmod +x build-dist-linux.sh
+79
View File
@@ -61,6 +61,10 @@ jobs:
| 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\` |
''').strip()) ''').strip())
# TODO(macos-runner): re-add the macOS rows below once a macOS
# runner is connected to Gitea and the build-macos job is re-enabled.
# | macOS (Apple Silicon) | \`MediaServer-{tag}-macos-arm64.tar.gz\` |
# | macOS (Intel) | \`MediaServer-{tag}-macos-x86_64.tar.gz\`
print(json.dumps('\n\n'.join(sections))) print(json.dumps('\n\n'.join(sections)))
") ")
@@ -187,6 +191,16 @@ jobs:
with: with:
python-version: '3.11' python-version: '3.11'
- name: Install native deps for dbus-python + PyGObject
run: |
# PyGObject >= 3.52 builds against girepository-2.0 (merged into
# GLib 2.80), not the old standalone girepository-1.0. ubuntu-latest
# (24.04) ships it as libgirepository-2.0-dev.
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libdbus-1-dev libglib2.0-dev pkg-config \
libcairo2-dev libgirepository-2.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 +240,68 @@ 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.
#
# TODO(macos-runner): Temporarily disabled via `if: false` because the
# Gitea instance currently has no macOS runner attached. To re-enable:
# 1. Connect a macOS runner to Gitea
# 2. Delete the `if: false` line below
# 3. Restore the macOS rows in the Downloads table generated by the
# create-release job (search for the matching TODO(macos-runner)).
build-macos:
needs: create-release
if: false
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"
+70
View File
@@ -34,3 +34,73 @@ 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: |
# PyGObject >= 3.52 builds against girepository-2.0 (merged into
# GLib 2.80), not the old standalone girepository-1.0. ubuntu-latest
# (24.04) ships it as libgirepository-2.0-dev.
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libdbus-1-dev libglib2.0-dev pkg-config \
libcairo2-dev libgirepository-2.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 '
# First run writes a default config (random token) and exits 0
# instead of serving, so the server is never left running in
# insecure no-auth mode. Run once to seed the config; the real
# launch below then finds it and actually boots. /api/health needs
# no auth, so the generated token is irrelevant here.
python -m media_server.main --no-tray --port 18765 || true
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
+28 -45
View File
@@ -1,60 +1,42 @@
## v0.3.0 (2026-05-22) ## v0.4.0 (2026-05-28)
Production-readiness hardening release: security, performance, accessibility, and observability. Substantial new functionality (HTTPS, audit log, OS mediaSession integration, rate limiter, X-Request-ID, ETag-cached artwork) alongside the security defaults flip described below. Two headline changes since v0.3.1: **Media Server now ships first-class Linux and macOS builds** (no more Windows-only), and the **Windows app icon was redesigned** with a proper multi-resolution ICO so the installer, Start Menu, desktop shortcut, Alt+Tab, and system tray all render sharp.
### Behavioral Changes (worth reading before upgrade)
- **Admin scope is now required for management endpoints**, and `scripts_management`, `callbacks_management`, `links_management`, `media_folders_management` default to `False`. Legacy bare-string `api_tokens` entries are auto-promoted to `admin` scope, so existing single-token deployments keep working. If you ran with a non-admin token and used CRUD on `/api/scripts`, `/api/callbacks`, `/api/links`, or `/api/media-folders`, you'll need an admin-scope token (see new `TokenSpec` format in `config.example.yaml`). ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4))
- **`cors_origins: ["*"]` is now refused at startup** — set explicit origins instead. ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4))
- **Thumbnail cache directory moved** from project-root `.cache` to `%LOCALAPPDATA%/media-server/cache` on Windows and `$XDG_CACHE_HOME/media-server/thumbnails` on POSIX. The old `.cache` directory can be deleted. ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4))
- **WebSocket auth prefers the `Sec-WebSocket-Protocol: media-server.token.<T>` subprotocol** so the token no longer ends up in URL/history/Referer. The `?token=` query fallback is retained for HA integration back-compat. ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4))
### Features ### Features
- **HTTPS support** via `ssl_certfile` + `ssl_keyfile` (+ optional `ssl_keyfile_password`); startup refuses to launch with only one of the pair set. ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4)) - **Linux support, production-ready.** New `linux` extra (`dbus-python`, `PyGObject`, `python-xlib`) plus a `build-dist-linux.sh` that emits a portable tarball and a systemd user unit. The unit sets `DBUS_SESSION_BUS_ADDRESS`, `XDG_RUNTIME_DIR`, and `ReadWritePaths` for `~/.config` / `~/.cache` so MPRIS works and audit-log / thumbnail writes aren't blocked by `ProtectHome`. The Linux MPRIS controller now connects to the session bus lazily — a missing or late bus no longer crashes lifespan startup, and the user is logged a one-line hint about `loginctl enable-linger`. ([ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c))
- **Reverse-proxy support**: `proxy_headers` + `forwarded_allow_ips` plumbed through `Settings` to `uvicorn.Config`. ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4)) - **macOS support.** New `macos` extra (`pyobjc-framework-Cocoa`, `pyobjc-framework-Quartz`), a `build-dist-macos.sh` script, and a per-user LaunchAgent installer producing `MediaServer-vX.Y-macos-{arm64,x86_64}.tar.gz` artifacts. Spotify URL artwork is wired through `MediaController.get_album_art()`. ([ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c))
- **OS media session integration**: headset / lockscreen / Bluetooth media-key buttons now drive play/pause/next/prev/seek and the browser-level mediaSession shows track metadata + artwork. ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4)) - **Cross-platform album artwork.** `MediaController.get_album_art()` is now abstract with Linux (`mpris:artUrl`, `file://` + `http(s)://`) and macOS (Spotify URL) implementations; the `/api/media/artwork` endpoint awaits the controller. ([ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c))
- **Token scope hierarchy** (`read | control | admin`) with structured `TokenSpec` entries; legacy bare-string tokens promote to `admin`. ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4)) - **Per-OS unavailable reasons on `/api/media/visualizer/status`** so the Web UI can explain *why* the visualizer is off on Linux / macOS instead of just hiding it. ([ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c))
- **In-process token-bucket rate limiter**: 5/min for failed auths, 10/min for `/api/scripts/execute` and `/api/callbacks/execute`. ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4)) - **Startup preflight on Linux** warns when `DBUS_SESSION_BUS_ADDRESS` or `XDG_RUNTIME_DIR` is unset and informs the user when running under Wayland disables the foreground-window probe — so silent loss of features is now diagnosable from the log. ([ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c))
- **WebSocket Origin allow-list check** (CSWSH defence). ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4)) - **Redesigned app icon ("Beacon").** Replaces the generic Spotify-green circle with a refined squircle + deep-teal diagonal gradient (`#0B3D3B → #1A6B5E`) + warm parchment play triangle (`#F5F1E8`) with a drop shadow, top sheen, and ghosted echo-chevrons that hint at broadcast/stream. ([d798fed](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d798fed))
- **Script parameter validation**: per-parameter `pattern` regex in `ScriptParameterConfig` plus `shell=False` (`shlex.split`) execution path to harden against parameter injection on `cmd.exe`. ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4)) - **Multi-resolution Windows ICO.** `icon.ico` grew from a single 16×16 frame (208 B) to a 10-frame ICO (16/20/24/32/40/48/64/96/128/256 — ~37 KB) so Windows no longer upscales 16×16 into mush for the installer chrome, Start Menu, desktop shortcuts, Alt+Tab, and File Explorer tiles. ([d798fed](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d798fed))
- **CSP tightened** with `form-action`, `worker-src`, `manifest-src` directives. ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4)) - **System tray uses the new icon.** `tray.py` now picks a 64×64 frame from the multi-res ICO; the procedural fallback was reskinned to the same Beacon palette so a missing ICO no longer regresses the tray back to the old Spotify-green circle. ([d798fed](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d798fed))
- **`noopener noreferrer` + `no-referrer` referrerpolicy** applied to every outbound link in the WebUI. ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4))
- **Windows config.yaml ACL hardening** via `icacls` (current user + SYSTEM + Administrators only); `0600` continues to be enforced on POSIX. ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4))
- **PWA installability**: `manifest.json` gets `id`, `scope`, and `theme_color` / `background_color` matching the Studio Reference base (`#0E0D0B`). ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4))
- **Accessibility**: ARIA labels on mini-player icon buttons; inner SVGs marked `aria-hidden`. ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4))
### Performance
- **Album-art read in `windows_media` gated by track key** — was decoding the WinRT thumbnail twice per second regardless of track changes. ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4))
- **`/api/media/artwork` returns content-derived `ETag` + `Cache-Control`** so the browser sends `If-None-Match` and gets `304` on track repeats. ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4))
- **Foreground-service `ctypes` argtypes hoisted to one-time module init** — was re-declaring ~14 prototypes per probe. ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4))
- **`display_service._static_cache` keyed by `(edid_hash, ...)` tuple** with eviction of disappeared monitors — fixes stale capabilities on hot-plug swaps where the new topology has the same monitor count. ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4))
- **Visualizer `requestAnimationFrame` loop paused on `document.hidden`, resumed on `visible`**. ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4))
### Bug Fixes ### Bug Fixes
- **Lifespan rewritten as `try` / `yield` / `finally`** so a partial-startup failure cannot orphan background tasks or executors. ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4)) - **`tray._confirm` guarded against `ctypes.windll` on non-Windows** so the new Linux / macOS builds don't crash when the tray prompts for confirmation. ([ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c))
- **`_run_callback` in `routes/media.py` keeps a strong task reference (GC-safe)** and uses the dedicated callback executor instead of the default pool. ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4)) - **`config.example.yaml` defaults are now cross-platform.** Per-OS commented examples for the on/off scripts, and `on_turn_off` defaults to a harmless `echo` (the previous default silently failed everywhere but Windows). ([ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c))
- **`macos_media.set_volume()` no longer always returns `True`** regardless of the underlying AppleScript result. ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4))
- **`TrayManager._restart_requested` initialised in `__init__`** and set before signalling exit so the main thread observes it correctly. ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4))
- **Missing `static_dir` now logs a `WARNING`** instead of silently disabling the UI. ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4))
- **WebSocket volume handler clamps input** and never drops the socket on bad messages. ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4))
- **Gitea release tag validated against a strict SemVer regex** before being used in a release URL. ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4))
### Observability
- **`X-Request-ID` middleware** — accepts an upstream id if it matches a safe regex, otherwise generates a `UUID4`. `request_id_var` added to `ContextVars` and included in every log line alongside the token label. ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4))
- **Append-only JSONL audit log** for every script + callback execution (including `on_play` / `on_pause` / etc. event callbacks). Background-thread writer; queue capped; flushed in lifespan teardown. ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4))
- **`token=...` stripped from uvicorn access logs**. ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4))
--- ---
### Development / Internal ### Development / Internal
#### Tests #### Build & Packaging
- **35 new tests** across auth scopes, the rate limiter, browser path traversal (`../`, `NUL`, UNC, absolute paths), script-parameter validation including the regex, the Gitea tag whitelist, and atomic config writes + POSIX permissions. Suite: 47 passed / 4 skipped. ([d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4)) - **`scripts/generate-icon.py`** — SVG is the canonical source; `resvg-py` rasterizes every ICO size; Pillow packs the multi-resolution `icon.ico`. Re-run any time the SVG changes. ([d798fed](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d798fed))
- **Dependency reorganization in `pyproject.toml`** — `screen-brightness-control` and `monitorcontrol` are cross-platform and moved to base deps; new `linux` and `macos` optional-deps groups with `sys_platform` markers. `resvg-py` added to `[dev]` for the icon-generation script. ([ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c), [d798fed](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d798fed))
- **`install_linux.sh`** — dropped the dead `requirements.txt` path; now installs via `pip install ".[linux]"` and pre-creates the writable state dirs. ([ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c))
- **Templated `media-server.service`** updated to match the new dist layout, with proper session-bus env vars and a writable state-dir grant. ([ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c))
#### CI
- **New `linux-smoke` job** in `.gitea/workflows/test.yml` — installs `.[linux]`, boots the server under `dbus-run-session`, and asserts `/api/health`. Catches dependency-resolution and import-time regressions for the Linux dist path. ([ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c))
- **Release workflow gains apt-deps step** for the Linux build and a best-effort macOS build job that produces the per-arch macOS tarballs. ([ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c))
#### Documentation
- **README rewritten for the new extras** — replaces the stale `pip install -r requirements.txt` instructions, adds a systemd-lingering note + troubleshooting section, and a macOS LaunchAgent section. ([ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c))
--- ---
@@ -63,6 +45,7 @@ Production-readiness hardening release: security, performance, accessibility, an
| Hash | Message | Author | | Hash | Message | Author |
|------|---------|--------| |------|---------|--------|
| [d131ba4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d131ba4) | fix: production-readiness hardening — security, perf, a11y, observability | alexei.dolgolyov | | [d798fed](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d798fed) | feat(icon): redesign app icon as "Beacon" and ship multi-resolution ICO | alexei.dolgolyov |
| [ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c) | feat: production-ready Linux & macOS support | alexei.dolgolyov |
</details> </details>
+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
+48 -5
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,
} }
@@ -414,8 +443,13 @@ async def websocket_endpoint(
accept_subprotocol = proto accept_subprotocol = proto
break break
effective_token = subprotocol_token or token effective_token = subprotocol_token or token
# Origin check — block CSWSH from third-party LAN pages. We accept the same # Origin check — block CSWSH from third-party LAN pages. Accept the same
# set of origins as CORS plus the default localhost loopback. # set of origins as CORS plus the default localhost loopback, AND any
# same-origin connection (where Origin matches the request's Host header).
# Same-origin is inherently safe from CSWSH because CSWSH is a *cross*-
# origin attack — without this, binding to 0.0.0.0 and accessing the UI
# via a LAN IP would have its WebSocket rejected by the browser-sent
# Origin, which the static allowlist can't anticipate.
allowed_origins = set( allowed_origins = set(
settings.cors_origins settings.cors_origins
or [ or [
@@ -427,6 +461,15 @@ async def websocket_endpoint(
# Same-origin connections from native apps may omit Origin entirely; only # Same-origin connections from native apps may omit Origin entirely; only
# reject when an Origin is present AND not in the allow-list. # reject when an Origin is present AND not in the allow-list.
if origin is not None and origin not in allowed_origins: if origin is not None and origin not in allowed_origins:
host_header = websocket.headers.get("host", "")
# Origin uses http/https; match against both scheme variants of Host
# so HTTPS deployments without an explicit cors_origins still work.
same_origin_candidates = (
{f"http://{host_header}", f"https://{host_header}"}
if host_header
else set()
)
if origin not in same_origin_candidates:
await websocket.close(code=4003, reason="Origin not allowed") await websocket.close(code=4003, reason="Origin not allowed")
return return
+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
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 B

After

Width:  |  Height:  |  Size: 37 KiB

+29 -6
View File
@@ -1,10 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Media Server">
<defs> <defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%"> <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1db954;stop-opacity:1" /> <stop offset="0%" stop-color="#0B3D3B"/>
<stop offset="100%" style="stop-color:#1ed760;stop-opacity:1" /> <stop offset="100%" stop-color="#1A6B5E"/>
</linearGradient> </linearGradient>
<linearGradient id="sheen" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.18"/>
<stop offset="55%" stop-color="#FFFFFF" stop-opacity="0"/>
</linearGradient>
<filter id="triShadow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3"/>
<feOffset dx="3" dy="5"/>
<feComponentTransfer><feFuncA type="linear" slope="0.45"/></feComponentTransfer>
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<clipPath id="clip"><rect x="0" y="0" width="256" height="256" rx="58" ry="58"/></clipPath>
</defs> </defs>
<circle cx="50" cy="50" r="45" fill="url(#grad)"/> <g clip-path="url(#clip)">
<path fill="white" d="M35 25 L35 75 L75 50 Z"/> <rect width="256" height="256" fill="url(#bg)"/>
<rect width="256" height="256" fill="url(#sheen)"/>
<g stroke="#F5F1E8" stroke-width="4.6" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.18">
<polyline points="66,72 105,128 66,184"/>
<polyline points="85,82 124,128 85,174" opacity="0.6"/>
</g>
<path d="M88.3 55 L88.3 201 L193.3 128 Z"
fill="#F5F1E8"
stroke="#F5F1E8" stroke-width="9" stroke-linejoin="round"
filter="url(#triShadow)"/>
</g>
<rect x="0.5" y="0.5" width="255" height="255" rx="58" ry="58"
fill="none" stroke="#000000" stroke-opacity="0.18" stroke-width="1"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 421 B

After

Width:  |  Height:  |  Size: 1.6 KiB

+108 -33
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,62 +31,136 @@ _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:
"""Create a tray icon: green circle with white play triangle.""" # Frame size we ask the multi-resolution ICO for. Most Windows tray surfaces
# show 16x16 in the notification area and 32x32 in jump lists / Alt+Tab; 64
# gives pystray enough headroom for both without forcing it to upscale.
_TRAY_ICON_SIZE = 64
# Palette mirrors media_server/static/icons/icon.svg ("Beacon" design).
_BG_DARK = (11, 61, 59, 255) # #0B3D3B
_BG_LIGHT = (26, 107, 94, 255) # #1A6B5E
_FG_PARCHMENT = (245, 241, 232, 255) # #F5F1E8
def _create_icon_image(size: int = _TRAY_ICON_SIZE) -> Image.Image:
"""Procedural fallback when no icon file is available.
Matches the "Beacon" palette (deep teal squircle + warm parchment play
triangle) so a missing icon.ico does not regress us back to the old
Spotify-green circle.
"""
img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
# Green circle background # Squircle background. Vertical gradient approximates the diagonal one
padding = 2 # in the real SVG well enough for a 64px fallback.
draw.ellipse( radius = int(size * 0.225)
[padding, padding, size - padding, size - padding], for y in range(size):
fill=(29, 185, 84, 255), t = y / max(1, size - 1)
color = tuple(
round(_BG_DARK[i] + (_BG_LIGHT[i] - _BG_DARK[i]) * t) for i in range(3)
) + (255,)
draw.line([(0, y), (size - 1, y)], fill=color)
mask = Image.new("L", (size, size), 0)
ImageDraw.Draw(mask).rounded_rectangle((0, 0, size - 1, size - 1), radius=radius, fill=255)
bg = img.copy()
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
img.paste(bg, (0, 0), mask=mask)
# Play triangle, positioned to match icon.svg's geometry.
draw = ImageDraw.Draw(img)
draw.polygon(
[
(size * 0.345, size * 0.215),
(size * 0.345, size * 0.785),
(size * 0.755, size * 0.500),
],
fill=_FG_PARCHMENT,
) )
# White play triangle
cx, cy = size // 2, size // 2
r = size * 0.28
triangle = [
(cx - r * 0.6, cy - r),
(cx - r * 0.6, cy + r),
(cx + r * 0.9, cy),
]
draw.polygon(triangle, fill=(255, 255, 255, 255))
return img return img
def _load_icon_image() -> Image.Image: def _select_frame(image: Image.Image, target: int) -> Image.Image:
"""Load the ICO/SVG app icon, falling back to a generated image.""" """Pick the best frame from a multi-resolution ICO.
Pillow's ICO loader exposes the embedded sizes via ``image.info['sizes']``.
We pick the smallest frame at least as large as the target (so we never
upscale) and resize down to ``target x target`` with LANCZOS.
"""
sizes = sorted(image.info.get("sizes", []) or [], key=lambda wh: wh[0])
chosen = next((wh for wh in sizes if wh[0] >= target), sizes[-1] if sizes else None)
if chosen is not None:
image.size = chosen
frame = image.copy().convert("RGBA")
if frame.size != (target, target):
frame = frame.resize((target, target), Image.LANCZOS)
return frame
def _load_icon_image(size: int = _TRAY_ICON_SIZE) -> Image.Image:
"""Load the app icon for the tray.
Order:
1. ``icon.ico`` — the multi-resolution Windows icon ships with every
build; pick the frame closest to ``size`` and downscale if needed.
2. ``icon.svg`` via resvg-py (preferred) or cairosvg (legacy).
3. Procedural ``_create_icon_image`` fallback.
"""
icons_dir = Path(__file__).parent / "static" / "icons" icons_dir = Path(__file__).parent / "static" / "icons"
# Try .ico first (best for Windows tray)
ico_path = icons_dir / "icon.ico" ico_path = icons_dir / "icon.ico"
if ico_path.exists(): if ico_path.exists():
try: try:
return Image.open(ico_path) with Image.open(ico_path) as ico:
except Exception: return _select_frame(ico, size)
pass except Exception as exc:
logger.warning("Failed to load tray icon from %s: %s", ico_path, exc)
# Try SVG via cairosvg
try:
import cairosvg
svg_path = icons_dir / "icon.svg" svg_path = icons_dir / "icon.svg"
if svg_path.exists(): if svg_path.exists():
png_data = cairosvg.svg2png(url=str(svg_path), output_width=64, output_height=64) try:
return Image.open(io.BytesIO(png_data)) import resvg_py
png_data = resvg_py.svg_to_bytes(
svg_string=svg_path.read_text(encoding="utf-8"),
width=size,
height=size,
)
return Image.open(io.BytesIO(bytes(png_data))).convert("RGBA")
except ImportError:
pass
except Exception as exc:
logger.warning("resvg rasterization of %s failed: %s", svg_path, exc)
try:
import cairosvg
png_data = cairosvg.svg2png(url=str(svg_path), output_width=size, output_height=size)
return Image.open(io.BytesIO(png_data)).convert("RGBA")
except Exception: except Exception:
pass pass
return _create_icon_image() return _create_icon_image(size)
class TrayManager: class TrayManager:
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "media-server-frontend", "name": "media-server-frontend",
"version": "0.2.7", "version": "0.4.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "media-server-frontend", "name": "media-server-frontend",
"version": "0.2.7", "version": "0.4.0",
"devDependencies": { "devDependencies": {
"esbuild": "^0.27.4" "esbuild": "^0.27.4"
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "media-server-frontend", "name": "media-server-frontend",
"version": "0.3.0", "version": "0.4.0",
"private": true, "private": true,
"description": "Frontend build tooling for media server WebUI", "description": "Frontend build tooling for media server WebUI",
"scripts": { "scripts": {
+27 -7
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "media-server" name = "media-server"
version = "0.3.0" version = "0.4.0"
description = "REST API server for controlling system-wide media playback" description = "REST API server for controlling system-wide media playback"
readme = "README.md" readme = "README.md"
license = { text = "MIT" } license = { text = "MIT" }
@@ -34,24 +34,44 @@ 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",
"httpx>=0.24", "httpx>=0.24",
"ruff>=0.4.0", "ruff>=0.4.0",
# SVG -> PNG rasterizer used by scripts/generate-icon.py to (re)build
# media_server/static/icons/icon.ico from icon.svg. Build-time only.
"resvg-py>=0.3.2",
] ]
[project.urls] [project.urls]
+113
View File
@@ -0,0 +1,113 @@
"""Generate the Media Server application icon.
The SVG in ``media_server/static/icons/icon.svg`` is the single source of
truth. This script rasterizes it at every Windows ICO size via ``resvg-py``
(Rust-backed, identical math to Firefox's SVG renderer) and packs them all
into a multi-resolution ``icon.ico``.
This replaces the original 16x16-only ICO that Windows was upscaling into
mush for the installer chrome, Start Menu, desktop shortcuts, and Alt+Tab.
Usage:
python scripts/generate-icon.py
Dependencies:
pip install resvg-py Pillow
"""
from __future__ import annotations
import io
from pathlib import Path
import resvg_py
from PIL import Image
# Sizes packed into the ICO. Windows picks the closest match per surface;
# more sizes = sharper rendering everywhere (taskbar, installer header,
# Alt+Tab, jump lists, Start tile, desktop, file explorer details/tiles).
ICO_SIZES: tuple[int, ...] = (16, 20, 24, 32, 40, 48, 64, 96, 128, 256)
# The SVG source. Squircle + diagonal teal gradient + warm parchment play
# triangle with a drop shadow + ghosted echo-chevrons hinting at broadcast.
SVG_SOURCE = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Media Server">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0B3D3B"/>
<stop offset="100%" stop-color="#1A6B5E"/>
</linearGradient>
<linearGradient id="sheen" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.18"/>
<stop offset="55%" stop-color="#FFFFFF" stop-opacity="0"/>
</linearGradient>
<filter id="triShadow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3"/>
<feOffset dx="3" dy="5"/>
<feComponentTransfer><feFuncA type="linear" slope="0.45"/></feComponentTransfer>
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<clipPath id="clip"><rect x="0" y="0" width="256" height="256" rx="58" ry="58"/></clipPath>
</defs>
<g clip-path="url(#clip)">
<rect width="256" height="256" fill="url(#bg)"/>
<rect width="256" height="256" fill="url(#sheen)"/>
<g stroke="#F5F1E8" stroke-width="4.6" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.18">
<polyline points="66,72 105,128 66,184"/>
<polyline points="85,82 124,128 85,174" opacity="0.6"/>
</g>
<path d="M88.3 55 L88.3 201 L193.3 128 Z"
fill="#F5F1E8"
stroke="#F5F1E8" stroke-width="9" stroke-linejoin="round"
filter="url(#triShadow)"/>
</g>
<rect x="0.5" y="0.5" width="255" height="255" rx="58" ry="58"
fill="none" stroke="#000000" stroke-opacity="0.18" stroke-width="1"/>
</svg>
"""
def _render_png(size: int) -> Image.Image:
"""Rasterize the SVG to a PNG at ``size x size`` via resvg."""
data = resvg_py.svg_to_bytes(
svg_string=SVG_SOURCE,
width=size,
height=size,
shape_rendering="geometric_precision",
)
return Image.open(io.BytesIO(bytes(data))).convert("RGBA")
def main() -> None:
root = Path(__file__).resolve().parent.parent
out_dir = root / "media_server" / "static" / "icons"
out_dir.mkdir(parents=True, exist_ok=True)
# SVG — canonical source, also used as the Web UI favicon.
svg_path = out_dir / "icon.svg"
svg_path.write_text(SVG_SOURCE, encoding="utf-8")
print(f"wrote {svg_path}")
# Rasterize every ICO size via resvg.
frames = [_render_png(size) for size in ICO_SIZES]
# Pack into a multi-resolution ICO. The "primary" image must be the
# largest; the rest go via append_images. Pillow's ICO writer then
# serializes one frame per size.
primary = frames[-1]
ico_path = out_dir / "icon.ico"
primary.save(
ico_path,
format="ICO",
sizes=[(s, s) for s in ICO_SIZES],
append_images=frames[:-1],
)
print(f"wrote {ico_path} ({ico_path.stat().st_size:,} bytes, sizes={list(ICO_SIZES)})")
# Largest PNG for documentation / non-Windows surfaces.
png_path = out_dir / "icon-256.png"
primary.save(png_path, format="PNG", optimize=True)
print(f"wrote {png_path}")
if __name__ == "__main__":
main()