Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ddbb93537 | |||
| b7e50455ad | |||
| 0006620eb5 | |||
| e7a3f62a9a | |||
| d798fedf55 | |||
| ddf4a6cb29 | |||
| 82710c6457 | |||
| 9b9a2b5c9f |
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
'
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
+54
-1
@@ -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
|
||||||
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)
|
# 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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,8 +461,17 @@ 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:
|
||||||
await websocket.close(code=4003, reason="Origin not allowed")
|
host_header = websocket.headers.get("host", "")
|
||||||
return
|
# 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")
|
||||||
|
return
|
||||||
|
|
||||||
# Verify token
|
# Verify token
|
||||||
from ..auth import auth_enabled, get_token_label, token_label_var
|
from ..auth import auth_enabled, get_token_label, token_label_var
|
||||||
|
|||||||
@@ -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..."
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
DBusGMainLoop(set_as_default=True)
|
# The session-bus connection is deferred until first use. Connecting
|
||||||
self._bus = dbus.SessionBus()
|
# 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)
|
||||||
|
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
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
+114
-39
@@ -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.
|
||||||
result = ctypes.windll.user32.MessageBoxW(
|
|
||||||
0, message, title, _MB_YESNO | _MB_ICONQUESTION | _MB_TOPMOST | _MB_SETFOREGROUND
|
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
|
||||||
return result == _IDYES
|
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:
|
# Frame size we ask the multi-resolution ICO for. Most Windows tray surfaces
|
||||||
"""Create a tray icon: green circle with white play triangle."""
|
# 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:
|
||||||
|
return _select_frame(ico, size)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to load tray icon from %s: %s", ico_path, exc)
|
||||||
|
|
||||||
|
svg_path = icons_dir / "icon.svg"
|
||||||
|
if svg_path.exists():
|
||||||
|
try:
|
||||||
|
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
|
||||||
|
|
||||||
# Try SVG via cairosvg
|
return _create_icon_image(size)
|
||||||
try:
|
|
||||||
import cairosvg
|
|
||||||
|
|
||||||
svg_path = icons_dir / "icon.svg"
|
|
||||||
if svg_path.exists():
|
|
||||||
png_data = cairosvg.svg2png(url=str(svg_path), output_width=64, output_height=64)
|
|
||||||
return Image.open(io.BytesIO(png_data))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return _create_icon_image()
|
|
||||||
|
|
||||||
|
|
||||||
class TrayManager:
|
class TrayManager:
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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]
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user