Compare commits

...

10 Commits

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:18:58 +03:00
alexei.dolgolyov ddf4a6cb29 feat: production-ready Linux & macOS support
Lint & Test / test (push) Successful in 20s
Lint & Test / linux-smoke (push) Failing after 34s
- Add `linux` (dbus-python, PyGObject, python-xlib) and `macos`
  (pyobjc) extras to pyproject.toml with sys_platform markers; move
  cross-platform screen-brightness-control + monitorcontrol to base deps.
- build-dist-linux.sh: install `.[linux]`, pkg-config pre-flight for
  dbus-1/glib-2.0, emit a systemd unit with DBUS_SESSION_BUS_ADDRESS +
  XDG_RUNTIME_DIR + ReadWritePaths for ~/.config and ~/.cache so MPRIS
  works and audit-log / thumbnail writes aren't blocked by ProtectHome.
- New build-dist-macos.sh + per-user LaunchAgent installer producing
  MediaServer-vX.Y-macos-{arm64,x86_64}.tar.gz.
- Templated media-server.service updated to match the dist layout with
  proper session-bus env vars and a writable state-dir grant.
- install_linux.sh: drop dead requirements.txt path; install via
  `pip install ".[linux]"` and pre-create the writable state dirs.
- Cross-platform album artwork: abstract MediaController.get_album_art()
  with Linux (mpris:artUrl, file:// + http(s)://) and macOS (Spotify URL)
  impls; routes/media artwork endpoint now awaits the controller.
- LinuxMediaController connects to the session bus lazily — failure no
  longer crashes lifespan startup; MPRIS calls return idle until the bus
  is reachable. Logged once at INFO with a hint about
  `loginctl enable-linger`.
- Startup preflight on Linux warns if DBUS_SESSION_BUS_ADDRESS or
  XDG_RUNTIME_DIR is unset and informs the user when Wayland disables
  the foreground probe.
- /api/media/visualizer/status now reports a per-OS unavailable_reason.
- tray._confirm guarded against ctypes.windll on non-Windows.
- config.example.yaml: per-OS commented script examples; on_turn_off
  default is now a no-op echo (used to silently fail off Windows).
- README: replace stale `pip install -r requirements.txt` instructions
  with the new extras; add systemd lingering doc + troubleshooting
  section; add macOS LaunchAgent section.
- CI: new linux-smoke job (installs `.[linux]`, boots the server under
  dbus-run-session, asserts /api/health). Release workflow gains
  apt-deps step for the Linux build and a best-effort macOS build job.
2026-05-26 12:17:30 +03:00
alexei.dolgolyov 82710c6457 chore: release v0.3.1
Lint & Test / test (push) Has been skipped
Release / create-release (push) Successful in 4s
Release / build-linux (push) Successful in 28s
Release / build-windows (push) Successful in 52s
2026-05-25 23:45:08 +03:00
alexei.dolgolyov 9b9a2b5c9f fix(ws): accept same-origin WebSocket connections in default Origin allow-list
When `cors_origins` was unset, the WS endpoint only allowed
`http://localhost:<port>` and `http://127.0.0.1:<port>` as origins, so a
browser opening the UI via the LAN IP (e.g. `http://192.168.2.100:8765`
when bound to `0.0.0.0`) had its WebSocket closed with code 4003 and
never recovered — leaving the Web UI in a permanent reconnect loop.

Also accept any `Origin` whose authority matches the request's `Host`
header (both `http://` and `https://` schemes). Same-origin is by
definition not CSWSH, so the cross-origin defence added in v0.3.0
remains intact for genuine third-party LAN pages.
2026-05-25 23:44:57 +03:00
alexei.dolgolyov b023d72165 chore: release v0.3.0
Lint & Test / test (push) Has been skipped
Release / create-release (push) Successful in 4s
Release / build-linux (push) Successful in 1m29s
Release / build-windows (push) Successful in 1m42s
2026-05-22 22:41:11 +03:00
alexei.dolgolyov d131ba461c fix: production-readiness hardening — security, perf, a11y, observability
Lint & Test / test (push) Successful in 20s
Security
- Default scripts_management, callbacks_management, links_management, and
  media_folders_management to False so a leaked token cannot escalate to RCE
  through admin CRUD endpoints.
- TokenSpec + scope hierarchy (read | control | admin); legacy bare-string
  api_tokens entries promote to admin for back-compat. Management endpoints
  now require admin scope.
- WebSocket subprotocol auth (Sec-WebSocket-Protocol: media-server.token.<T>)
  preferred over ?token= query so the token no longer lands in URL/history/
  Referer; query fallback retained for HA integration back-compat.
- Origin allow-list check on the WS endpoint (CSWSH defence).
- In-process token-bucket rate limiter: 5/min for failed auths,
  10/min for /api/scripts/execute and /api/callbacks/execute.
- shell=False subprocess path (shlex.split) + per-parameter regex `pattern`
  in ScriptParameterConfig to harden shell=true scripts against parameter
  injection (Windows cmd.exe env-var expansion).
- CSP gains form-action, worker-src, manifest-src directives.
- Refuse cors_origins=["*"] at startup; strip token=... from uvicorn access
  logs; validate Gitea release tag against strict SemVer regex.
- noopener noreferrer + no-referrer referrerpolicy on every outbound link.
- icacls hardening of config.yaml on Windows (current user + SYSTEM +
  Administrators only); 0600 still enforced on POSIX.
- WS volume handler clamps input and never drops the socket on bad messages.

Performance
- Album-art read in windows_media gated by track key — was decoding the
  WinRT thumbnail twice per second regardless of track changes.
- /api/media/artwork returns content-derived ETag + Cache-Control so the
  browser sends If-None-Match and gets 304s on track repeats.
- Foreground-service ctypes argtypes hoisted to one-time module init
  (was re-declaring ~14 prototypes per probe).
- 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.
- Visualizer rAF loop paused on document.hidden, resumed on visible.

Reliability / bug fixes
- Lifespan rewritten as try/yield/finally so a partial-startup failure
  cannot orphan background tasks or executors.
- _run_callback in routes/media.py keeps a strong task ref (GC-safe) and
  uses the dedicated callback executor instead of the default pool.
- macos_media.set_volume() no longer always returns True.
- TrayManager._restart_requested initialised in __init__; set before
  signalling exit so the main thread observes it correctly.
- Missing static_dir now logs a WARNING instead of silent UI disable.

UX / accessibility / PWA
- manifest.json theme_color and background_color match the Studio Reference
  base (#0E0D0B); added id and scope for PWA installability.
- ARIA on mini-player icon buttons; inner SVGs marked aria-hidden.
- OS mediaSession API wired so headset / lockscreen / Bluetooth buttons
  drive play/pause/next/prev/seek and show track metadata + artwork.

Observability
- X-Request-ID middleware (accept upstream id if it matches a safe regex,
  otherwise UUID4); request_id_var added to ContextVars and included in
  every log line alongside the token label.
- Audit log (append-only JSONL) for every script + callback execution,
  including the on_play/on_pause/etc. event callbacks. Background-thread
  writer; queue capped; flushed in lifespan teardown.

Deployment
- proxy_headers + forwarded_allow_ips plumbed through Settings →
  uvicorn.Config for reverse-proxy installs.
- HTTPS support via ssl_certfile + ssl_keyfile (+ optional password);
  startup refuses to launch with only one of the pair set.
- Thumbnail cache moved from project-root .cache to
  %LOCALAPPDATA%/media-server/cache (Windows) and
  $XDG_CACHE_HOME/media-server/thumbnails (POSIX).

Tests
- 35 new tests across auth scopes, rate limiter, browser path traversal
  (../ NUL UNC absolute), script-param validation incl. regex, Gitea tag
  whitelist, config atomic write + POSIX perms. 47 passed / 4 skipped.
2026-05-22 22:25:54 +03:00
51 changed files with 2743 additions and 368 deletions
+10
View File
@@ -60,6 +60,16 @@ jobs:
with: with:
python-version: '3.11' python-version: '3.11'
- name: Install native deps for dbus-python + PyGObject
run: |
# PyGObject >= 3.52 builds against girepository-2.0 (merged into
# GLib 2.80), not the old standalone girepository-1.0. ubuntu-latest
# (24.04) ships it as libgirepository-2.0-dev.
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libdbus-1-dev libglib2.0-dev pkg-config \
libcairo2-dev libgirepository-2.0-dev
- name: Build Linux distribution - name: Build Linux distribution
run: | run: |
chmod +x build-dist-linux.sh chmod +x build-dist-linux.sh
+79
View File
@@ -61,6 +61,10 @@ jobs:
| Windows (portable) | \`MediaServer-{tag}-win-x64.zip\` | | Windows (portable) | \`MediaServer-{tag}-win-x64.zip\` |
| Linux | \`MediaServer-{tag}-linux-x64.tar.gz\` | | Linux | \`MediaServer-{tag}-linux-x64.tar.gz\` |
''').strip()) ''').strip())
# TODO(macos-runner): re-add the macOS rows below once a macOS
# runner is connected to Gitea and the build-macos job is re-enabled.
# | macOS (Apple Silicon) | \`MediaServer-{tag}-macos-arm64.tar.gz\` |
# | macOS (Intel) | \`MediaServer-{tag}-macos-x86_64.tar.gz\`
print(json.dumps('\n\n'.join(sections))) print(json.dumps('\n\n'.join(sections)))
") ")
@@ -187,6 +191,16 @@ jobs:
with: with:
python-version: '3.11' python-version: '3.11'
- name: Install native deps for dbus-python + PyGObject
run: |
# PyGObject >= 3.52 builds against girepository-2.0 (merged into
# GLib 2.80), not the old standalone girepository-1.0. ubuntu-latest
# (24.04) ships it as libgirepository-2.0-dev.
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libdbus-1-dev libglib2.0-dev pkg-config \
libcairo2-dev libgirepository-2.0-dev
- name: Build Linux distribution - name: Build Linux distribution
run: | run: |
chmod +x build-dist-linux.sh chmod +x build-dist-linux.sh
@@ -226,3 +240,68 @@ jobs:
-H "Authorization: token $DEPLOY_TOKEN" \ -H "Authorization: token $DEPLOY_TOKEN" \
-H "Content-Type: application/octet-stream" \ -H "Content-Type: application/octet-stream" \
--data-binary "@$FILE" --data-binary "@$FILE"
# --- Build macOS tarball (best-effort; requires a macos runner) ---
# PyObjC wheels are macOS-only, so this job must run on a real Mac.
#
# TODO(macos-runner): Temporarily disabled via `if: false` because the
# Gitea instance currently has no macOS runner attached. To re-enable:
# 1. Connect a macOS runner to Gitea
# 2. Delete the `if: false` line below
# 3. Restore the macOS rows in the Downloads table generated by the
# create-release job (search for the matching TODO(macos-runner)).
build-macos:
needs: create-release
if: false
runs-on: macos-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build frontend
run: npm ci && npm run build
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Build macOS distribution
run: |
chmod +x build-dist-macos.sh
./build-dist-macos.sh "${{ gitea.ref_name }}"
- name: Upload assets to release
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
FILE=$(ls build/MediaServer-*-macos-*.tar.gz | head -1)
NAME=$(basename "$FILE")
EXISTING=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
-H "Authorization: token $DEPLOY_TOKEN")
ASSET_ID=$(echo "$EXISTING" | python3 -c "
import sys, json
assets = json.load(sys.stdin)
for a in assets:
if a['name'] == '$NAME':
print(a['id'])
break
" 2>/dev/null || true)
if [ -n "$ASSET_ID" ]; then
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$ASSET_ID" \
-H "Authorization: token $DEPLOY_TOKEN"
fi
curl -s -X POST \
"$BASE_URL/releases/$RELEASE_ID/assets?name=$NAME" \
-H "Authorization: token $DEPLOY_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$FILE"
+70
View File
@@ -34,3 +34,73 @@ jobs:
- name: Test - name: Test
run: pytest --tb=short -q || test $? -eq 5 run: pytest --tb=short -q || test $? -eq 5
# Linux smoke test: install the linux extra in the same way build-dist-linux.sh
# does, then boot the server and hit /api/health. Catches dependency-resolution
# and import-time regressions for the Linux distribution path.
linux-smoke:
if: "!startsWith(github.event.head_commit.message, 'chore: release')"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build frontend
run: npm ci && npm run build
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install Linux system deps for dbus-python + PyGObject
run: |
# PyGObject >= 3.52 builds against girepository-2.0 (merged into
# GLib 2.80), not the old standalone girepository-1.0. ubuntu-latest
# (24.04) ships it as libgirepository-2.0-dev.
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libdbus-1-dev libglib2.0-dev pkg-config \
libcairo2-dev libgirepository-2.0-dev
- name: Install with linux extra
run: |
pip install --upgrade pip
pip install ".[linux]"
- name: Smoke — server boots and /api/health responds
run: |
# Headless Linux runners have no PulseAudio and no display
# server, so we disable the visualizer + update checker, and we
# use `dbus-run-session` to give LinuxMediaController a real
# session bus to talk to (otherwise dbus.SessionBus() would
# raise during startup). This isn't a full MPRIS integration
# test — it only proves the dispatcher selects the Linux
# controller, all imports resolve, and /api/health returns 200.
sudo apt-get install -y --no-install-recommends dbus-x11
export MEDIA_SERVER_VISUALIZER_ENABLED=false
export MEDIA_SERVER_UPDATE_CHECK_ENABLED=false
dbus-run-session -- bash -c '
# First run writes a default config (random token) and exits 0
# instead of serving, so the server is never left running in
# insecure no-auth mode. Run once to seed the config; the real
# launch below then finds it and actually boots. /api/health needs
# no auth, so the generated token is irrelevant here.
python -m media_server.main --no-tray --port 18765 || true
python -m media_server.main --no-tray --port 18765 &
SERVER_PID=$!
for i in $(seq 1 30); do
if curl -sf "http://127.0.0.1:18765/api/health" >/dev/null; then
echo "Health check passed"
kill $SERVER_PID
wait $SERVER_PID 2>/dev/null || true
exit 0
fi
sleep 0.5
done
echo "Server did not respond within 15s"
kill $SERVER_PID 2>/dev/null || true
exit 1
'
+59 -11
View File
@@ -285,37 +285,44 @@ All connected WebSocket clients receive a `links_changed` notification when link
## Installation ## Installation
Dependencies are declared in `pyproject.toml`. Pick the extra that matches
your OS — the Python deps differ enough between Windows / Linux / macOS
that there's no single `pip install` line.
### Installing on Windows ### Installing on Windows
```bash ```bash
pip install -r requirements.txt pip install ".[windows]"
``` ```
Required packages: `winsdk`, `pywin32`, `pycaw`, `comtypes` Pulls in `winsdk`, `pywin32`, `pycaw`, `comtypes`, `pystray`, etc.
### Installing on Linux ### Installing on Linux
```bash ```bash
# Install system dependencies # System packages required to build dbus-python + PyGObject from sdist.
sudo apt-get install python3-dbus python3-gi libdbus-1-dev libglib2.0-dev sudo apt-get install -y python3-pip python3-venv \
libdbus-1-dev libglib2.0-dev pkg-config
pip install -r requirements.txt pip install ".[linux]"
``` ```
### Installing on macOS ### Installing on macOS
```bash ```bash
pip install -r requirements.txt pip install ".[macos]"
``` ```
No additional dependencies - uses built-in `osascript`. Pulls in `pyobjc-framework-Cocoa` + `pyobjc-framework-Quartz` for the
foreground-window probe; AppleScript-based media control uses the
built-in `osascript`.
### Installing on Android (Termux) ### Installing on Android (Termux)
```bash ```bash
# In Termux # In Termux
pkg install python termux-api pkg install python termux-api
pip install -r requirements.txt pip install "."
``` ```
Requires Termux and Termux:API apps from F-Droid. Requires Termux and Termux:API apps from F-Droid.
@@ -835,11 +842,19 @@ Install:
sudo ./service/install_linux.sh install sudo ./service/install_linux.sh install
``` ```
Enable and start for your user: **Enable user lingering** — required so `/run/user/$UID/bus` (the D-Bus
session socket needed for MPRIS) exists even when no graphical session
is active. Without this the server boots but every `/api/media/*` call
silently returns idle.
```bash ```bash
sudo systemctl enable media-server@$USER sudo loginctl enable-linger $USER
sudo systemctl start media-server@$USER ```
Enable and start the templated unit for your user:
```bash
sudo systemctl enable --now media-server@$USER
``` ```
View logs: View logs:
@@ -848,6 +863,39 @@ View logs:
journalctl -u media-server@$USER -f journalctl -u media-server@$USER -f
``` ```
**Troubleshooting:**
- *"`/api/media/status` always returns `idle`"* — check the service log for
`D-Bus session bus not available`. Most commonly: lingering isn't
enabled, or the unit is using the wrong `XDG_RUNTIME_DIR` (`%U` must
expand to the user's numeric UID).
- *"Visualizer permanently unavailable"* — PulseAudio/PipeWire must
expose monitor sources. `pactl list sources short | grep monitor`
should list at least one entry; if not, install `pipewire-pulse` and
restart your session.
- *"Volume control silently fails"* — `pactl` must be on `PATH` and the
user's PulseAudio/PipeWire server must be reachable
(`PULSE_RUNTIME_PATH=/run/user/$UID/pulse`).
- *"Foreground window is always `null`"* — expected under Wayland; the
compositor hides window info from unprivileged clients. X11 sessions
work normally.
### macOS (LaunchAgent)
The distribution tarball ships an installer:
```bash
./install-launchagent.sh
```
This drops `~/Library/LaunchAgents/com.dolgolyov.media-server.plist`,
starts the service immediately, and re-launches it at every login. Logs
go to `~/Library/Logs/media-server/{stdout,stderr}.log`. To stop:
```bash
./uninstall-launchagent.sh
```
## Command Line Options ## Command Line Options
```text ```text
+38 -3
View File
@@ -1,8 +1,42 @@
## v0.2.7 (2026-05-19) ## v0.4.0 (2026-05-28)
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.
### Features
- **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))
- **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))
- **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))
- **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))
- **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))
- **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))
- **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))
- **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))
### Bug Fixes ### Bug Fixes
- **Display tab sliders + accent picker now respond to clicks/drags:** Both the brightness and contrast sliders on the Display tab, and the accent-color picker in the header, were rendering dynamic HTML with inline `oninput` / `onchange` / `onclick` attributes — every one of which the server's strict `script-src 'self'` CSP silently dropped. The result: brightness and contrast couldn't be changed from the WebUI at all, and picking a custom accent did nothing. Replaced the inline attributes with `data-*` markers and wired proper `addEventListener` calls (delegated on the slider container, direct on the accent dropdown), so the controls work under the strict CSP without any `unsafe-inline` / `unsafe-hashes` relaxation. ([e1c8474](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/e1c8474)) - **`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))
- **`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))
---
### Development / Internal
#### Build & Packaging
- **`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))
--- ---
@@ -11,6 +45,7 @@
| Hash | Message | Author | | Hash | Message | Author |
|------|---------|--------| |------|---------|--------|
| [e1c8474](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/e1c8474) | fix(csp): wire display sliders and accent picker without inline on* | 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
View File
@@ -16,12 +16,38 @@ BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-linux-x64"
clean_dist "${DIST_DIR}" build clean_dist "${DIST_DIR}" build
verify_frontend verify_frontend
# --- Sanity check: native deps required for dbus-python + PyGObject ---
# These compile from sdist on Linux. We don't try to install them via the
# distro package manager here (the CI image must already have them); fail
# with a clear message instead of cryptic gcc/pkg-config errors deep
# inside pip.
need_pkg() {
local pkg="$1"
if ! command -v pkg-config >/dev/null 2>&1; then
echo "ERROR: pkg-config is required (apt: pkg-config / dnf: pkgconf-pkg-config)" >&2
exit 1
fi
if ! pkg-config --exists "$pkg" 2>/dev/null; then
echo "ERROR: $pkg headers missing — install libdbus-1-dev libglib2.0-dev (apt)" >&2
echo " or dbus-devel glib2-devel (dnf) before running this script." >&2
exit 1
fi
}
need_pkg dbus-1
need_pkg glib-2.0
# --- Create self-contained virtualenv --- # --- Create self-contained virtualenv ---
echo "Creating virtualenv..." echo "Creating virtualenv..."
python3 -m venv "${DIST_DIR}/venv" python3 -m venv "${DIST_DIR}/venv"
source "${DIST_DIR}/venv/bin/activate" source "${DIST_DIR}/venv/bin/activate"
pip install --quiet --upgrade pip pip install --quiet --upgrade pip
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"
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
set -euo pipefail
# Build macOS distribution (self-contained venv + tarball)
# Usage: ./build-dist-macos.sh [VERSION]
#
# Must be run on macOS (PyObjC wheels are platform-specific). For CI use
# the github-hosted macos-latest runner.
source "$(dirname "$0")/build-common.sh"
if [ "$(uname -s)" != "Darwin" ]; then
echo "ERROR: build-dist-macos.sh must run on macOS (uname=$(uname -s))" >&2
exit 1
fi
detect_version "${1:-}"
echo "Building Media Server v${VERSION_CLEAN} for macOS"
# Detect host architecture for archive naming (arm64 = Apple Silicon, x86_64 = Intel).
ARCH="$(uname -m)"
DIST_DIR="dist/media-server"
BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-macos-${ARCH}"
clean_dist "${DIST_DIR}" build
verify_frontend
# --- Create self-contained virtualenv ---
echo "Creating virtualenv..."
python3 -m venv "${DIST_DIR}/venv"
# shellcheck disable=SC1091
source "${DIST_DIR}/venv/bin/activate"
pip install --quiet --upgrade pip
if ! pip install --quiet ".[macos]"; then
echo "WARN: '.[macos]' extra unavailable; installing base deps only" >&2
pip install --quiet "."
fi
# Remove the installed package (app source is on PYTHONPATH via launcher)
rm -rf "${DIST_DIR}"/venv/lib/python*/site-packages/media_server*
rm -rf "${DIST_DIR}"/venv/lib/python*/site-packages/media_server*.dist-info
deactivate
# Trim venv site-packages — macOS native ext is .so, dylibs are .dylib
MAC_SP=$(echo "${DIST_DIR}"/venv/lib/python*/site-packages)
cleanup_site_packages "$MAC_SP" "so" "dylib"
copy_app_files "$DIST_DIR"
# --- Launcher ---
cat > "${DIST_DIR}/media-server.sh" << 'LAUNCHER'
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
export PYTHONPATH="$SCRIPT_DIR/app"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/venv/bin/activate"
exec python -m media_server.main "$@"
LAUNCHER
chmod +x "${DIST_DIR}/media-server.sh"
# --- LaunchAgent installer ---
# LaunchAgents run as the user, with the user's GUI session — exactly what
# we want for AppleScript / Music.app / Spotify control. KeepAlive auto-
# restarts on crash; RunAtLoad starts at login.
cat > "${DIST_DIR}/install-launchagent.sh" << 'LAUNCHAGENT'
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
LABEL="com.dolgolyov.media-server"
PLIST_DIR="${HOME}/Library/LaunchAgents"
PLIST="${PLIST_DIR}/${LABEL}.plist"
LOG_DIR="${HOME}/Library/Logs/media-server"
mkdir -p "$PLIST_DIR" "$LOG_DIR"
cat > "$PLIST" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>${SCRIPT_DIR}/media-server.sh</string>
<string>--no-tray</string>
</array>
<key>WorkingDirectory</key>
<string>${SCRIPT_DIR}</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>StandardOutPath</key>
<string>${LOG_DIR}/stdout.log</string>
<key>StandardErrorPath</key>
<string>${LOG_DIR}/stderr.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PYTHONUNBUFFERED</key>
<string>1</string>
</dict>
</dict>
</plist>
EOF
# Reload if already loaded, then load fresh.
launchctl unload "$PLIST" 2>/dev/null || true
launchctl load -w "$PLIST"
echo "LaunchAgent '${LABEL}' installed and started."
echo "Plist: $PLIST"
echo "Logs: $LOG_DIR/{stdout,stderr}.log"
echo "Stop: launchctl unload \"$PLIST\""
echo "Start: launchctl load -w \"$PLIST\""
LAUNCHAGENT
chmod +x "${DIST_DIR}/install-launchagent.sh"
# Convenience uninstaller.
cat > "${DIST_DIR}/uninstall-launchagent.sh" << 'UNINSTALL'
#!/usr/bin/env bash
set -euo pipefail
LABEL="com.dolgolyov.media-server"
PLIST="${HOME}/Library/LaunchAgents/${LABEL}.plist"
launchctl unload "$PLIST" 2>/dev/null || true
rm -f "$PLIST"
echo "LaunchAgent '${LABEL}' removed (config preserved under ~/.config/media-server)."
UNINSTALL
chmod +x "${DIST_DIR}/uninstall-launchagent.sh"
# --- Package ---
echo "Creating archive..."
cp -r "${DIST_DIR}" "${BUILD_OUTPUT}"
tar -czf "${BUILD_OUTPUT}.tar.gz" -C build "MediaServer-v${VERSION_CLEAN}-macos-${ARCH}"
echo "Build complete: ${BUILD_OUTPUT}.tar.gz"
+85 -54
View File
@@ -21,108 +21,139 @@ host: "127.0.0.1"
port: 8765 port: 8765
# allow_lan_without_auth: true # uncomment + change host to 0.0.0.0 for LAN-open mode # allow_lan_without_auth: true # uncomment + change host to 0.0.0.0 for LAN-open mode
# Custom scripts # ─── Custom scripts ─────────────────────────────────────────────────────
#
# Examples below are platform-specific. Uncomment the block that matches
# your OS — YAML keys must be unique, so don't ship multiple
# `lock_screen:` entries.
scripts: scripts:
lock_screen: # ── Windows ─────────────────────────────────────────────────────────
command: "rundll32.exe user32.dll,LockWorkStation" # lock_screen:
label: "Lock Screen" # command: "rundll32.exe user32.dll,LockWorkStation"
description: "Lock the workstation" # label: "Lock Screen"
icon: "mdi:lock" # description: "Lock the workstation"
timeout: 5 # icon: "mdi:lock"
shell: true # timeout: 5
# shell: true
# sleep:
# command: "rundll32.exe powrprof.dll,SetSuspendState 0,1,0"
# label: "Sleep"
# icon: "mdi:sleep"
# timeout: 10
# shell: true
# shutdown:
# command: "shutdown /s /t 0"
# label: "Shutdown"
# icon: "mdi:power"
# timeout: 10
# shell: true
hibernate: # ── Linux (systemd / xdg) ───────────────────────────────────────────
command: "shutdown /h" # lock_screen:
label: "Hibernate" # command: "loginctl lock-session" # or: xdg-screensaver lock
description: "Hibernate the PC" # label: "Lock Screen"
icon: "mdi:power-sleep" # icon: "mdi:lock"
# timeout: 5
# shell: true
# sleep:
# command: "systemctl suspend"
# label: "Sleep"
# icon: "mdi:sleep"
# timeout: 10
# shell: true
# shutdown:
# command: "systemctl poweroff"
# label: "Shutdown"
# icon: "mdi:power"
# timeout: 10
# shell: true
# ── macOS ───────────────────────────────────────────────────────────
# lock_screen:
# command: "pmset displaysleepnow"
# label: "Lock Screen"
# icon: "mdi:lock"
# timeout: 5
# shell: true
# sleep:
# command: "pmset sleepnow"
# label: "Sleep"
# icon: "mdi:sleep"
# timeout: 5
# shell: true
# shutdown:
# command: "osascript -e 'tell application \"System Events\" to shut down'"
# label: "Shutdown"
# icon: "mdi:power"
# timeout: 10
# shell: true
# Cross-platform smoke test (always defined so first-run users see the UI populate).
example_script:
command: "echo Hello from Media Server!"
description: "Example script - echoes a message"
timeout: 10 timeout: 10
shell: true shell: true
sleep: # Media folder management from Web UI (default: false)
command: "rundll32.exe powrprof.dll,SetSuspendState 0,1,0"
label: "Sleep"
description: "Put PC to sleep"
icon: "mdi:sleep"
timeout: 10
shell: true
shutdown:
command: "shutdown /s /t 0"
label: "Shutdown"
description: "Shutdown the PC immediately"
icon: "mdi:power"
timeout: 10
shell: true
restart:
command: "shutdown /r /t 0"
label: "Restart"
description: "Restart the PC immediately"
icon: "mdi:restart"
timeout: 10
shell: true
# Media folder management from Web UI (default: true)
# When enabled, media folders can be added, edited, and deleted from the Settings tab. # When enabled, media folders can be added, edited, and deleted from the Settings tab.
# Set to false to disable folder management from the UI. # media_folders_management: true
# media_folders_management: false
# ─── Callback scripts (executed by integration events) ──────────────────
#
# Callbacks are OS-agnostic — if you want side effects, fill in commands
# below. The defaults are no-op echos so first-run logs don't error out.
# Callback scripts (executed after media actions)
# All callbacks are optional - if not defined, the action runs without callback
callbacks: callbacks:
# Media control callbacks (run after successful action)
on_play: on_play:
command: "echo Play triggered" command: "echo Play triggered"
timeout: 10 timeout: 10
shell: true shell: true
on_pause: on_pause:
command: "echo Pause triggered" command: "echo Pause triggered"
timeout: 10 timeout: 10
shell: true shell: true
on_stop: on_stop:
command: "echo Stop triggered" command: "echo Stop triggered"
timeout: 10 timeout: 10
shell: true shell: true
on_next: on_next:
command: "echo Next track" command: "echo Next track"
timeout: 10 timeout: 10
shell: true shell: true
on_previous: on_previous:
command: "echo Previous track" command: "echo Previous track"
timeout: 10 timeout: 10
shell: true shell: true
on_volume: on_volume:
command: "echo Volume changed" command: "echo Volume changed"
timeout: 10 timeout: 10
shell: true shell: true
on_mute: on_mute:
command: "echo Mute toggled" command: "echo Mute toggled"
timeout: 10 timeout: 10
shell: true shell: true
on_seek: on_seek:
command: "echo Seek triggered" command: "echo Seek triggered"
timeout: 10 timeout: 10
shell: true shell: true
# Turn on/off/toggle (callback-only actions, no default behavior) # Turn on/off/toggle (callback-only actions, no default behavior).
# The Windows-only example used to ship as the default for `on_turn_off`,
# which silently failed on Linux/macOS. Defaults are now no-ops — pick a
# platform-appropriate command below.
on_turn_on: on_turn_on:
command: "echo Turn on callback" command: "echo Turn on callback"
timeout: 10 timeout: 10
shell: true shell: true
on_turn_off: on_turn_off:
command: "rundll32.exe user32.dll,LockWorkStation" # Windows: "rundll32.exe user32.dll,LockWorkStation"
# Linux: "loginctl lock-session"
# macOS: "pmset displaysleepnow"
command: "echo Turn off callback"
timeout: 5 timeout: 5
shell: true shell: true
on_toggle: on_toggle:
command: "echo Toggle callback" command: "echo Toggle callback"
timeout: 10 timeout: 10
+34 -2
View File
@@ -13,6 +13,8 @@ security = HTTPBearer(auto_error=False)
# Context variable to store current request's token label # Context variable to store current request's token label
token_label_var: ContextVar[str] = ContextVar("token_label", default="unknown") token_label_var: ContextVar[str] = ContextVar("token_label", default="unknown")
# Per-request correlation ID — generated in middleware if upstream didn't send one.
request_id_var: ContextVar[str] = ContextVar("request_id", default="-")
def auth_enabled() -> bool: def auth_enabled() -> bool:
@@ -29,12 +31,42 @@ def get_token_label(token: str) -> Optional[str]:
Returns: Returns:
The label for the token, or None if invalid The label for the token, or None if invalid
""" """
for label, stored_token in settings.api_tokens.items(): for label, spec in settings.api_tokens.items():
if secrets.compare_digest(stored_token, token): if secrets.compare_digest(spec.token, token):
return label return label
return None return None
def token_has_scope(label: str, required: str) -> bool:
"""Whether the token identified by `label` grants `required` scope."""
spec = settings.api_tokens.get(label)
if spec is None:
# Unknown label = no auth or anonymous; treat as full access only
# when auth is disabled entirely (matches existing behaviour).
return not auth_enabled()
return spec.grants(required)
def require_scope(scope: str):
"""Build a FastAPI dependency that enforces the given scope.
Use as ``Depends(require_scope("admin"))`` on management endpoints. When
auth is disabled the dependency is a no-op (anonymous access).
"""
async def _checker(label: str = Depends(verify_token)) -> str:
if not auth_enabled():
return label
if not token_has_scope(label, scope):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Token '{label}' lacks required scope: {scope}",
)
return label
return _checker
async def verify_token( async def verify_token(
request: Request, request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security), credentials: HTTPAuthorizationCredentials = Depends(security),
+157 -11
View File
@@ -7,12 +7,49 @@ from pathlib import Path
from typing import Optional from typing import Optional
import yaml import yaml
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Token scopes form a strict hierarchy: admin > control > read. Helper utility
# used by both auth.py and the validator below.
SCOPE_HIERARCHY: dict[str, frozenset[str]] = {
"read": frozenset({"read"}),
"control": frozenset({"read", "control"}),
"admin": frozenset({"read", "control", "admin"}),
}
ALL_SCOPES: frozenset[str] = frozenset(SCOPE_HIERARCHY.keys())
class TokenSpec(BaseModel):
"""Per-token authentication entry with explicit scopes."""
token: str = Field(..., min_length=8, description="Secret token value")
scopes: list[str] = Field(
default_factory=lambda: ["admin"],
description="Granted scopes (subset of read|control|admin).",
)
@field_validator("scopes")
@classmethod
def _validate_scopes(cls, v: list[str]) -> list[str]:
if not v:
raise ValueError("scopes must list at least one of read|control|admin")
unknown = set(v) - ALL_SCOPES
if unknown:
raise ValueError(f"unknown scopes: {sorted(unknown)}; valid={sorted(ALL_SCOPES)}")
return v
def grants(self, required: str) -> bool:
"""Whether this token grants the requested scope (with hierarchy expansion)."""
granted: set[str] = set()
for s in self.scopes:
granted |= SCOPE_HIERARCHY.get(s, frozenset({s}))
return required in granted
class MediaFolderConfig(BaseModel): class MediaFolderConfig(BaseModel):
"""Configuration for a media folder.""" """Configuration for a media folder."""
@@ -48,6 +85,13 @@ class ScriptParameterConfig(BaseModel):
options: Optional[list[str]] = Field( options: Optional[list[str]] = Field(
default=None, description="Allowed values (select type only)" default=None, description="Allowed values (select type only)"
) )
pattern: Optional[str] = Field(
default=None,
description=(
"Optional regex (Python flavour) that string-typed values must match."
" Use to harden parameters that flow into shell=true scripts."
),
)
class ScriptConfig(BaseModel): class ScriptConfig(BaseModel):
@@ -108,19 +152,84 @@ class Settings(BaseSettings):
), ),
) )
# Reverse-proxy deployment: when serving the API behind nginx/Caddy/Traefik,
# uvicorn must trust the X-Forwarded-* headers from the proxy so that the
# `Origin` allow-list, request URLs, and logs reflect the public-facing
# values. Off by default — only enable when there's a real proxy in front
# (otherwise clients can spoof their own IP).
proxy_headers: bool = Field(
default=False,
description="Honor X-Forwarded-For / X-Forwarded-Proto from upstream proxy.",
)
forwarded_allow_ips: str = Field(
default="127.0.0.1",
description=(
"Comma-separated IPs / CIDRs that uvicorn should trust X-Forwarded-* from."
" Use '*' to trust all (only safe when bound to a private interface)."
),
)
# HTTPS / TLS. Both must be set together to enable TLS; if only one is set
# the server refuses to start. Use `mkcert` or letsencrypt to generate the
# pair; the server reads them at startup.
ssl_certfile: Optional[str] = Field(
default=None,
description="Path to TLS certificate (PEM). Pair with ssl_keyfile.",
)
ssl_keyfile: Optional[str] = Field(
default=None,
description="Path to TLS private key (PEM). Pair with ssl_certfile.",
)
ssl_keyfile_password: Optional[str] = Field(
default=None,
description="Optional password for the private key if encrypted.",
)
# Admin-grade operations (script / callback / link / folder create/update/delete). # Admin-grade operations (script / callback / link / folder create/update/delete).
# When True the same token used for read/play can also persist arbitrary shell # When True the same token used for read/play can also persist arbitrary shell
# commands. Disable to make the API read+execute only. # commands. Default False so a single leaked token cannot escalate to RCE; opt
scripts_management: bool = Field(default=True, description="Allow scripts CRUD via API") # in explicitly to manage scripts/callbacks/links via the Web UI.
callbacks_management: bool = Field(default=True, description="Allow callbacks CRUD via API") scripts_management: bool = Field(default=False, description="Allow scripts CRUD via API")
links_management: bool = Field(default=True, description="Allow links CRUD via API") callbacks_management: bool = Field(default=False, description="Allow callbacks CRUD via API")
links_management: bool = Field(default=False, description="Allow links CRUD via API")
# Authentication (empty = auth disabled, anyone can access the API) # Authentication (empty = auth disabled, anyone can access the API).
api_tokens: dict[str, str] = Field( #
# Each entry can be either:
# • a bare string (legacy form, treated as scopes = ["admin"] for back-compat), OR
# • a mapping with explicit scopes, e.g.
# "ha": {token: "<token>", scopes: ["read", "control"]}
# "kiosk": {token: "<token>", scopes: ["read"]}
# "ops": {token: "<token>", scopes: ["admin"]}
#
# Available scopes:
# read — GET /api/* (status, list, browse) but no state-changing calls.
# control — read + media transport, display/audio, script EXECUTE, callback EXECUTE.
# admin — control + CRUD on scripts/callbacks/links/folders.
#
# Validation normalises both forms to TokenSpec at load time.
api_tokens: dict[str, TokenSpec] = Field(
default_factory=dict, default_factory=dict,
description="Named API tokens for access control (label: token pairs). Empty = no auth.", description=(
"Named API tokens. Value can be a bare token string (= admin scope) or"
" a {token, scopes} mapping. See TokenSpec for scope definitions."
),
) )
@field_validator("api_tokens", mode="before")
@classmethod
def _normalise_tokens(cls, v):
"""Accept legacy `label: <bare-token>` form and promote to TokenSpec."""
if not isinstance(v, dict):
return v
out: dict[str, dict | TokenSpec] = {}
for label, entry in v.items():
if isinstance(entry, str):
out[label] = {"token": entry, "scopes": ["admin"]}
else:
out[label] = entry
return out
# Media controller settings # Media controller settings
poll_interval: float = Field( poll_interval: float = Field(
default=1.0, description="Media status poll interval in seconds" default=1.0, description="Media status poll interval in seconds"
@@ -156,7 +265,7 @@ class Settings(BaseSettings):
description="Media folders available for browsing in the media browser", description="Media folders available for browsing in the media browser",
) )
media_folders_management: bool = Field( media_folders_management: bool = Field(
default=True, default=False,
description="Allow adding, editing, and deleting media folders from the Web UI", description="Allow adding, editing, and deleting media folders from the Web UI",
) )
@@ -263,8 +372,11 @@ def generate_default_config(path: Optional[Path] = None) -> Path:
config = { config = {
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 8765, "port": 8765,
# Default token grants "admin" scope (full access). To create a
# read-only or control-only token, add a second entry:
# ha_readonly: {token: "<token>", scopes: ["read"]}
"api_tokens": { "api_tokens": {
"default": default_token, "default": {"token": default_token, "scopes": ["admin"]},
}, },
"poll_interval": 1.0, "poll_interval": 1.0,
"log_level": "INFO", "log_level": "INFO",
@@ -298,8 +410,16 @@ def _write_yaml_atomic(path: Path, data: dict) -> None:
def _restrict_config_perms(path: Path) -> None: def _restrict_config_perms(path: Path) -> None:
"""On POSIX, ensure config file is readable only by owner (0600).""" """Ensure config file is readable only by its owner.
POSIX → ``chmod 0600``. On Windows the default NTFS ACL leaves the file
readable by every interactive user on the machine (Users group has Read),
which is bad given the file stores plaintext API tokens. Use ``icacls`` to
grant exclusive access to the current user + SYSTEM + Administrators and
strip inheritance.
"""
if os.name == "nt": if os.name == "nt":
_restrict_config_perms_windows(path)
return return
try: try:
os.chmod(path, 0o600) os.chmod(path, 0o600)
@@ -308,5 +428,31 @@ def _restrict_config_perms(path: Path) -> None:
logger.debug("Could not chmod %s", path, exc_info=True) logger.debug("Could not chmod %s", path, exc_info=True)
def _restrict_config_perms_windows(path: Path) -> None:
"""Apply restrictive NTFS ACL to a config file (Windows only)."""
import subprocess
try:
username = os.environ.get("USERNAME") or os.environ.get("USER")
if not username:
logger.debug("Cannot detect current user; skipping icacls hardening")
return
# Disable inheritance and remove every existing ACE, then grant access
# only to current user, SYSTEM, and Administrators. /Q suppresses
# progress output; /C lets per-file errors not abort the batch.
subprocess.run(
["icacls", str(path), "/inheritance:r"],
check=False, capture_output=True, timeout=5,
)
for principal in (username, "SYSTEM", "Administrators"):
subprocess.run(
["icacls", str(path), "/grant:r", f"{principal}:(R,W)"],
check=False, capture_output=True, timeout=5,
)
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
# `icacls` missing or sandboxed — leave the default ACL in place.
logger.debug("icacls hardening failed for %s", path, exc_info=True)
# Global settings instance # Global settings instance
settings = Settings.load_from_yaml() settings = Settings.load_from_yaml()
+279 -98
View File
@@ -15,7 +15,7 @@ from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from . import __version__ from . import __version__
from .auth import get_token_label, token_label_var from .auth import get_token_label, request_id_var, token_label_var
from .config import generate_default_config, get_config_dir, settings from .config import generate_default_config, get_config_dir, settings
from .routes import ( from .routes import (
audio_router, audio_router,
@@ -33,10 +33,34 @@ from .services.websocket_manager import ws_manager
class TokenLabelFilter(logging.Filter): class TokenLabelFilter(logging.Filter):
"""Add token label to log records.""" """Add token label + request_id to log records."""
def filter(self, record): def filter(self, record):
record.token_label = token_label_var.get("unknown") record.token_label = token_label_var.get("unknown")
record.request_id = request_id_var.get("-")
return True
class _StripTokenQueryFilter(logging.Filter):
"""Strip `token=...` from query strings before they hit the access log.
uvicorn's default access log format includes the full request line, so
`/api/media/artwork?token=SECRET` would otherwise be persisted verbatim
in stdout/journald/file sinks.
"""
import re as _re
_TOKEN_RE = _re.compile(r"([?&])token=[^&\s\"']+")
def filter(self, record): # type: ignore[override]
if isinstance(record.args, tuple):
record.args = tuple(
self._TOKEN_RE.sub(r"\1token=REDACTED", a) if isinstance(a, str) else a
for a in record.args
)
if isinstance(record.msg, str) and "token=" in record.msg:
record.msg = self._TOKEN_RE.sub(r"\1token=REDACTED", record.msg)
return True return True
@@ -49,17 +73,34 @@ def setup_logging():
logging.basicConfig( logging.basicConfig(
level=getattr(logging, settings.log_level.upper()), level=getattr(logging, settings.log_level.upper()),
format="%(asctime)s - %(name)s - [%(token_label)s] - %(levelname)s - %(message)s", format=(
"%(asctime)s - %(name)s - [%(token_label)s] [%(request_id)s]"
" - %(levelname)s - %(message)s"
),
handlers=[handler], handlers=[handler],
) )
# Suppress noisy third-party loggers # Suppress noisy third-party loggers
logging.getLogger("screen_brightness_control").setLevel(logging.ERROR) logging.getLogger("screen_brightness_control").setLevel(logging.ERROR)
# Make sure the uvicorn access log never persists tokens leaked into the
# query string (the artwork + WS endpoints accept `?token=` for browser
# compatibility — see verify_token_or_query).
strip_filter = _StripTokenQueryFilter()
for name in ("uvicorn.access", "uvicorn"):
logging.getLogger(name).addFilter(strip_filter)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Application lifespan handler.""" """Application lifespan handler.
All long-lived resources started during startup are kept in local refs and
torn down in a `finally:` so a partial-startup failure cannot orphan tasks
or thread pools.
"""
import asyncio
setup_logging() setup_logging()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.info(f"Media Server starting on {settings.host}:{settings.port}") logger.info(f"Media Server starting on {settings.host}:{settings.port}")
@@ -71,92 +112,149 @@ 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")
# Start WebSocket status monitor # Linux preflight: most MPRIS / PulseAudio failures are environmental
controller = get_media_controller() # (no DBUS_SESSION_BUS_ADDRESS, missing XDG_RUNTIME_DIR, systemd service
await ws_manager.start_status_monitor(controller.get_status) # started before logind). Surface that early so the failure mode is a
logger.info("WebSocket status monitor started") # warning at boot instead of silent "/api/media/status returns idle".
import os
# Start update checker
update_checker = None
if settings.update_check_enabled:
from .services.gitea_release_provider import GiteaReleaseProvider
from .services.update_checker import UpdateChecker
provider = GiteaReleaseProvider()
update_checker = UpdateChecker(provider, __version__)
await update_checker.start(settings.update_check_interval)
# Store globally so health endpoint can access cached result
app.state.update_checker = update_checker
# Schedule periodic thumbnail cache cleanup so the 500 MB cap is actually
# enforced. Runs once at startup and then hourly until shutdown.
from .services.thumbnail_service import ThumbnailService
async def _thumbnail_cleanup_loop() -> None:
while True:
try:
await asyncio.to_thread(ThumbnailService.cleanup_cache)
except Exception as e:
logger.warning("Thumbnail cache cleanup failed: %s", e)
try:
await asyncio.sleep(3600)
except asyncio.CancelledError:
break
import asyncio
cleanup_task = asyncio.create_task(_thumbnail_cleanup_loop())
# Register audio visualizer (capture starts on-demand when clients subscribe)
analyzer = None
if settings.visualizer_enabled:
from .services.audio_analyzer import get_audio_analyzer
analyzer = get_audio_analyzer(
num_bins=settings.visualizer_bins,
target_fps=settings.visualizer_fps,
device_name=settings.visualizer_device,
)
if analyzer.available:
await ws_manager.start_audio_monitor(analyzer)
logger.info("Audio visualizer available (capture on-demand)")
else:
logger.info("Audio visualizer unavailable (install soundcard + numpy)")
yield
# Stop update checker
if update_checker is not None:
await update_checker.stop()
# Cancel periodic thumbnail cleanup
cleanup_task.cancel()
try:
await cleanup_task
except asyncio.CancelledError:
pass
# Stop audio visualizer
await ws_manager.stop_audio_monitor()
if analyzer and analyzer.running:
analyzer.stop()
# Stop WebSocket status monitor
await ws_manager.stop_status_monitor()
# Shut down dedicated thread pools so pending scripts don't leak threads
from .routes.callbacks import shutdown_callback_executor
from .routes.scripts import shutdown_script_executor
shutdown_script_executor()
shutdown_callback_executor()
# Clean up platform-specific resources
import platform as _platform import platform as _platform
if _platform.system() == "Windows": if _platform.system() == "Linux":
from .services.windows_media import shutdown_executor missing = [
shutdown_executor() 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)."
)
logger.info("Media Server shutting down") update_checker = None
cleanup_task: asyncio.Task | None = None
analyzer = None
status_monitor_started = False
try:
# Start WebSocket status monitor
controller = get_media_controller()
await ws_manager.start_status_monitor(controller.get_status)
status_monitor_started = True
logger.info("WebSocket status monitor started")
# Start update checker
if settings.update_check_enabled:
from .services.gitea_release_provider import GiteaReleaseProvider
from .services.update_checker import UpdateChecker
provider = GiteaReleaseProvider()
update_checker = UpdateChecker(provider, __version__)
await update_checker.start(settings.update_check_interval)
# Store globally so health endpoint can access cached result
app.state.update_checker = update_checker
# Schedule periodic thumbnail cache cleanup so the 500 MB cap is actually
# enforced. Runs once at startup and then hourly until shutdown.
from .services.thumbnail_service import ThumbnailService
async def _thumbnail_cleanup_loop() -> None:
while True:
try:
await asyncio.to_thread(ThumbnailService.cleanup_cache)
except Exception as e:
logger.warning("Thumbnail cache cleanup failed: %s", e)
try:
await asyncio.sleep(3600)
except asyncio.CancelledError:
break
cleanup_task = asyncio.create_task(_thumbnail_cleanup_loop())
# Register audio visualizer (capture starts on-demand when clients subscribe)
if settings.visualizer_enabled:
from .services.audio_analyzer import get_audio_analyzer
analyzer = get_audio_analyzer(
num_bins=settings.visualizer_bins,
target_fps=settings.visualizer_fps,
device_name=settings.visualizer_device,
)
if analyzer.available:
await ws_manager.start_audio_monitor(analyzer)
logger.info("Audio visualizer available (capture on-demand)")
else:
logger.info("Audio visualizer unavailable (install soundcard + numpy)")
yield
finally:
# Stop update checker
if update_checker is not None:
try:
await update_checker.stop()
except Exception:
logger.exception("Error stopping update checker")
# Cancel periodic thumbnail cleanup
if cleanup_task is not None:
cleanup_task.cancel()
try:
await cleanup_task
except asyncio.CancelledError:
pass
except Exception:
logger.exception("Error awaiting thumbnail cleanup task")
# Stop audio visualizer
try:
await ws_manager.stop_audio_monitor()
except Exception:
logger.exception("Error stopping audio monitor")
if analyzer and analyzer.running:
try:
analyzer.stop()
except Exception:
logger.exception("Error stopping audio analyzer")
# Stop WebSocket status monitor
if status_monitor_started:
try:
await ws_manager.stop_status_monitor()
except Exception:
logger.exception("Error stopping status monitor")
# Shut down dedicated thread pools so pending scripts don't leak threads
try:
from .routes.callbacks import shutdown_callback_executor
from .routes.scripts import shutdown_script_executor
shutdown_script_executor()
shutdown_callback_executor()
except Exception:
logger.exception("Error shutting down script/callback executors")
# Flush audit log writer
try:
from .services.audit_log import shutdown_audit_log
shutdown_audit_log()
except Exception:
logger.exception("Error flushing audit log")
# Clean up platform-specific resources
import platform as _platform
if _platform.system() == "Windows":
try:
from .services.windows_media import shutdown_executor
shutdown_executor()
except Exception:
logger.exception("Error shutting down windows_media executor")
logger.info("Media Server shutting down")
def create_app() -> FastAPI: def create_app() -> FastAPI:
@@ -173,7 +271,15 @@ def create_app() -> FastAPI:
# CORS — restrict to same-origin by default; users that integrate the API # CORS — restrict to same-origin by default; users that integrate the API
# from another origin (e.g. Home Assistant on a different host) can set # from another origin (e.g. Home Assistant on a different host) can set
# cors_origins in config.yaml. # cors_origins in config.yaml. Refuse "*" outright: combined with the
# admin endpoints this would let any origin in the universe run
# arbitrary shell. If users genuinely need every origin, they can list
# them explicitly.
if any(o.strip() == "*" for o in settings.cors_origins):
raise RuntimeError(
"cors_origins must not contain '*' — list exact origins instead. "
"This protects the script-execution endpoints from any-origin abuse."
)
cors_origins = settings.cors_origins or [ cors_origins = settings.cors_origins or [
f"http://localhost:{settings.port}", f"http://localhost:{settings.port}",
f"http://127.0.0.1:{settings.port}", f"http://127.0.0.1:{settings.port}",
@@ -186,6 +292,23 @@ def create_app() -> FastAPI:
allow_headers=["Authorization", "Content-Type"], allow_headers=["Authorization", "Content-Type"],
) )
# Request correlation ID — accept upstream X-Request-ID if it's a sane
# ASCII id, otherwise mint a fresh UUID4. Emitted on the response so
# clients can quote it back in bug reports.
import re
import uuid as _uuid
_REQ_ID_RE = re.compile(r"^[A-Za-z0-9._\-]{1,128}$")
@app.middleware("http")
async def request_id_middleware(request: Request, call_next):
incoming = request.headers.get("x-request-id", "")
req_id = incoming if _REQ_ID_RE.match(incoming) else _uuid.uuid4().hex[:16]
request_id_var.set(req_id)
response = await call_next(request)
response.headers["X-Request-ID"] = req_id
return response
# Security headers — strict CSP for the bundled UI, disallow framing, hide referrer. # Security headers — strict CSP for the bundled UI, disallow framing, hide referrer.
@app.middleware("http") @app.middleware("http")
async def security_headers_middleware(request: Request, call_next): async def security_headers_middleware(request: Request, call_next):
@@ -200,6 +323,9 @@ def create_app() -> FastAPI:
"style-src 'self' 'unsafe-inline'; " "style-src 'self' 'unsafe-inline'; "
"font-src 'self' data:; " "font-src 'self' data:; "
"frame-ancestors 'none'; " "frame-ancestors 'none'; "
"form-action 'self'; "
"worker-src 'self'; "
"manifest-src 'self'; "
"base-uri 'self'" "base-uri 'self'"
), ),
) )
@@ -208,32 +334,63 @@ def create_app() -> FastAPI:
response.headers.setdefault("Referrer-Policy", "no-referrer") response.headers.setdefault("Referrer-Policy", "no-referrer")
return response return response
# Add token logging middleware # Add token logging middleware + auth-failure rate limit
from fastapi.responses import JSONResponse
from .services.rate_limit import check as ratelimit_check
from .services.rate_limit import get_peer
@app.middleware("http") @app.middleware("http")
async def token_logging_middleware(request: Request, call_next): async def token_logging_middleware(request: Request, call_next):
"""Extract token label and set in context for logging.""" """Extract token label, set in context, and rate-limit failed auths."""
if not settings.api_tokens: if not settings.api_tokens:
token_label_var.set("anonymous") token_label_var.set("anonymous")
else: else:
token_label = "unknown" token_label = "unknown"
token_present = False
token_valid = False
# Try Authorization header # Try Authorization header
auth_header = request.headers.get("authorization", "") auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "): if auth_header.startswith("Bearer "):
token_present = True
token = auth_header[7:] token = auth_header[7:]
label = get_token_label(token) label = get_token_label(token)
if label: if label:
token_label = label token_label = label
token_valid = True
# Try query parameter (for artwork endpoint) # Try query parameter (for artwork endpoint)
elif "token" in request.query_params: elif "token" in request.query_params:
token_present = True
token = request.query_params["token"] token = request.query_params["token"]
label = get_token_label(token) label = get_token_label(token)
if label: if label:
token_label = label token_label = label
token_valid = True
token_label_var.set(token_label) token_label_var.set(token_label)
# Brute-force gate: a peer that produces a wrong/missing token gets
# 5 failures per minute before being throttled. Static-asset
# requests (GET /static/*, /, /sw.js) and the docs endpoint are
# exempt — they're served unauthenticated by design.
if token_present and not token_valid:
path = request.url.path
if not (
path == "/" or path == "/sw.js"
or path.startswith("/static/")
or path.startswith("/docs") or path.startswith("/openapi")
or path.startswith("/redoc")
):
allowed, retry_after = ratelimit_check("auth", get_peer(request))
if not allowed:
return JSONResponse(
status_code=429,
content={"detail": "Too many authentication failures"},
headers={"Retry-After": str(int(retry_after or 60))},
)
response = await call_next(request) response = await call_next(request)
return response return response
@@ -266,6 +423,11 @@ def create_app() -> FastAPI:
async def serve_ui(): async def serve_ui():
"""Serve the Web UI.""" """Serve the Web UI."""
return FileResponse(static_dir / "index.html") return FileResponse(static_dir / "index.html")
else:
logging.getLogger(__name__).warning(
"static_dir not found at %s — Web UI disabled (API only)",
static_dir,
)
return app return app
@@ -316,8 +478,9 @@ def main():
print(f"Config directory: {get_config_dir()}") print(f"Config directory: {get_config_dir()}")
if settings.api_tokens: if settings.api_tokens:
print("\nAPI Tokens:") print("\nAPI Tokens:")
for label, token in settings.api_tokens.items(): for label, spec in settings.api_tokens.items():
print(f" {label:20} {token}") scope_str = ",".join(spec.scopes)
print(f" {label:20} {spec.token} [scopes: {scope_str}]")
else: else:
print("\nAuthentication is DISABLED (no tokens configured)") print("\nAuthentication is DISABLED (no tokens configured)")
return return
@@ -374,6 +537,27 @@ def main():
use_tray = PYSTRAY_AVAILABLE and not args.no_tray use_tray = PYSTRAY_AVAILABLE and not args.no_tray
# Validate TLS pair consistency before either path so we don't fail late.
if bool(settings.ssl_certfile) ^ bool(settings.ssl_keyfile):
_fatal(
"ERROR: ssl_certfile and ssl_keyfile must both be set, or both unset."
)
def _uvicorn_kwargs() -> dict:
kw: dict = {
"host": args.host,
"port": args.port,
"log_level": settings.log_level.lower(),
"proxy_headers": settings.proxy_headers,
"forwarded_allow_ips": settings.forwarded_allow_ips,
}
if settings.ssl_certfile and settings.ssl_keyfile:
kw["ssl_certfile"] = settings.ssl_certfile
kw["ssl_keyfile"] = settings.ssl_keyfile
if settings.ssl_keyfile_password:
kw["ssl_keyfile_password"] = settings.ssl_keyfile_password
return kw
if use_tray: if use_tray:
import asyncio import asyncio
import threading import threading
@@ -381,9 +565,7 @@ def main():
# Run uvicorn in a background thread so tray owns the main thread message loop # Run uvicorn in a background thread so tray owns the main thread message loop
uv_config = uvicorn.Config( uv_config = uvicorn.Config(
"media_server.main:app", "media_server.main:app",
host=args.host, **_uvicorn_kwargs(),
port=args.port,
log_level=settings.log_level.lower(),
) )
server = uvicorn.Server(uv_config) server = uvicorn.Server(uv_config)
@@ -421,9 +603,8 @@ def main():
else: else:
uvicorn.run( uvicorn.run(
"media_server.main:app", "media_server.main:app",
host=args.host,
port=args.port,
reload=False, reload=False,
**_uvicorn_kwargs(),
) )
+9 -1
View File
@@ -36,12 +36,20 @@ def _spawn_background(coro) -> asyncio.Task:
def _require_folder_management() -> None: def _require_folder_management() -> None:
"""Raise 403 if media folder management is disabled in config.""" """Raise 403 if media folder management is disabled OR caller lacks admin scope."""
if not settings.media_folders_management: if not settings.media_folders_management:
raise HTTPException( raise HTTPException(
status_code=403, status_code=403,
detail="Media folder management is disabled. Set media_folders_management: true in config.yaml to enable.", detail="Media folder management is disabled. Set media_folders_management: true in config.yaml to enable.",
) )
from ..auth import auth_enabled, token_has_scope, token_label_var
if auth_enabled():
label = token_label_var.get("unknown")
if not token_has_scope(label, "admin"):
raise HTTPException(
status_code=403,
detail=f"Token '{label}' lacks required scope: admin",
)
async def _broadcast_after_open(controller, label: str, max_wait: float = 2.0) -> None: async def _broadcast_after_open(controller, label: str, max_wait: float = 2.0) -> None:
+41 -1
View File
@@ -8,12 +8,14 @@ import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from ..auth import verify_token from ..auth import verify_token
from ..config import CallbackConfig, settings from ..config import CallbackConfig, settings
from ..config_manager import config_manager from ..config_manager import config_manager
from ..services.rate_limit import check as ratelimit_check
from ..services.rate_limit import get_peer
router = APIRouter(prefix="/api/callbacks", tags=["callbacks"]) router = APIRouter(prefix="/api/callbacks", tags=["callbacks"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -28,6 +30,7 @@ def shutdown_callback_executor() -> None:
def _require_callbacks_management() -> None: def _require_callbacks_management() -> None:
"""Authorise a callbacks-CRUD operation. Operator flag + per-token admin scope."""
if not settings.callbacks_management: if not settings.callbacks_management:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
@@ -36,6 +39,14 @@ def _require_callbacks_management() -> None:
" in config.yaml to enable." " in config.yaml to enable."
), ),
) )
from ..auth import auth_enabled, token_has_scope, token_label_var
if auth_enabled():
label = token_label_var.get("unknown")
if not token_has_scope(label, "admin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Token '{label}' lacks required scope: admin",
)
class CallbackInfo(BaseModel): class CallbackInfo(BaseModel):
@@ -122,6 +133,7 @@ async def list_callbacks(_: str = Depends(verify_token)) -> list[CallbackInfo]:
@router.post("/execute/{callback_name}") @router.post("/execute/{callback_name}")
async def execute_callback( async def execute_callback(
callback_name: str, callback_name: str,
http_request: Request,
_: str = Depends(verify_token), _: str = Depends(verify_token),
) -> CallbackExecuteResponse: ) -> CallbackExecuteResponse:
"""Execute a callback for debugging purposes. """Execute a callback for debugging purposes.
@@ -132,6 +144,16 @@ async def execute_callback(
Returns: Returns:
Execution result including stdout, stderr, and exit code Execution result including stdout, stderr, and exit code
""" """
# Rate-limit callback execution per peer (10/min) — callbacks also run
# subprocesses and need the same protection as scripts.
allowed, retry_after = ratelimit_check("execute", get_peer(http_request))
if not allowed:
raise HTTPException(
status_code=429,
detail="Too many callback executions, slow down",
headers={"Retry-After": str(int(retry_after or 60))},
)
# Validate callback name # Validate callback name
_validate_callback_name(callback_name) _validate_callback_name(callback_name)
@@ -146,6 +168,8 @@ async def execute_callback(
logger.info(f"Executing callback for debugging: {callback_name}") logger.info(f"Executing callback for debugging: {callback_name}")
from ..services.audit_log import record_script_execution
try: try:
# Execute in dedicated thread pool to not block the default executor # Execute in dedicated thread pool to not block the default executor
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
@@ -159,6 +183,15 @@ async def execute_callback(
), ),
) )
record_script_execution(
kind="callback",
name=callback_name,
exit_code=result["exit_code"],
duration=result.get("execution_time"),
stdout=result.get("stdout"),
stderr=result.get("stderr"),
)
return CallbackExecuteResponse( return CallbackExecuteResponse(
success=result["exit_code"] == 0, success=result["exit_code"] == 0,
callback=callback_name, callback=callback_name,
@@ -170,6 +203,13 @@ async def execute_callback(
except Exception as e: except Exception as e:
logger.error(f"Callback execution error: {e}") logger.error(f"Callback execution error: {e}")
record_script_execution(
kind="callback",
name=callback_name,
exit_code=None,
duration=None,
error=str(e),
)
return CallbackExecuteResponse( return CallbackExecuteResponse(
success=False, success=False,
callback=callback_name, callback=callback_name,
+9
View File
@@ -39,11 +39,20 @@ def _validate_icon(icon: str) -> str:
def _require_links_management() -> None: def _require_links_management() -> None:
"""Authorise a links-CRUD operation. Operator flag + per-token admin scope."""
if not settings.links_management: if not settings.links_management:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Links management is disabled. Set links_management: true in config.yaml to enable.", detail="Links management is disabled. Set links_management: true in config.yaml to enable.",
) )
from ..auth import auth_enabled, token_has_scope, token_label_var
if auth_enabled():
label = token_label_var.get("unknown")
if not token_has_scope(label, "admin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Token '{label}' lacks required scope: admin",
)
class LinkInfo(BaseModel): class LinkInfo(BaseModel):
+176 -28
View File
@@ -3,13 +3,22 @@
import asyncio import asyncio
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect, status from fastapi import (
APIRouter,
Depends,
HTTPException,
Query,
Request,
WebSocket,
WebSocketDisconnect,
status,
)
from fastapi.responses import Response 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__)
@@ -17,19 +26,28 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/media", tags=["media"]) router = APIRouter(prefix="/api/media", tags=["media"])
# Strong refs to background tasks so the asyncio GC can't drop them before
# they run. Mirrors the pattern used in routes/browser.py.
_background_callback_tasks: set[asyncio.Task] = set()
def _run_callback(callback_name: str) -> None: def _run_callback(callback_name: str) -> None:
"""Fire-and-forget a callback if configured. Failures are logged but don't block.""" """Fire-and-forget a callback if configured. Failures are logged but don't block."""
if not settings.callbacks or callback_name not in settings.callbacks: if not settings.callbacks or callback_name not in settings.callbacks:
return return
async def _execute(): async def _execute():
# Use the dedicated callback executor (not the default loop pool) so a
# misbehaving callback can't starve the rest of the app's sync tasks.
from ..services.audit_log import record_script_execution
from .callbacks import _callback_executor
from .scripts import _run_script from .scripts import _run_script
try: try:
callback = settings.callbacks[callback_name] callback = settings.callbacks[callback_name]
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
result = await loop.run_in_executor( result = await loop.run_in_executor(
None, _callback_executor,
lambda: _run_script( lambda: _run_script(
command=callback.command, command=callback.command,
timeout=callback.timeout, timeout=callback.timeout,
@@ -37,6 +55,14 @@ def _run_callback(callback_name: str) -> None:
working_dir=callback.working_dir, working_dir=callback.working_dir,
), ),
) )
record_script_execution(
kind="event-callback",
name=callback_name,
exit_code=result["exit_code"],
duration=result.get("execution_time"),
stdout=result.get("stdout"),
stderr=result.get("stderr"),
)
if result["exit_code"] != 0: if result["exit_code"] != 0:
logger.warning( logger.warning(
"Callback %s failed with exit code %s: %s", "Callback %s failed with exit code %s: %s",
@@ -46,8 +72,18 @@ def _run_callback(callback_name: str) -> None:
) )
except Exception as e: except Exception as e:
logger.error("Callback %s error: %s", callback_name, e) logger.error("Callback %s error: %s", callback_name, e)
from ..services.audit_log import record_script_execution as _rec
_rec(
kind="event-callback",
name=callback_name,
exit_code=None,
duration=None,
error=str(e),
)
asyncio.create_task(_execute()) task = asyncio.create_task(_execute())
_background_callback_tasks.add(task)
task.add_done_callback(_background_callback_tasks.discard)
@router.get("/status", response_model=MediaStatus) @router.get("/status", response_model=MediaStatus)
@@ -242,41 +278,91 @@ async def toggle(_: str = Depends(verify_token)) -> dict:
@router.get("/artwork") @router.get("/artwork")
async def get_artwork(_: str = Depends(verify_token_or_query)) -> Response: async def get_artwork(
request: Request,
_: str = Depends(verify_token_or_query),
) -> Response:
"""Get the current album artwork. """Get the current album artwork.
Returns: Returns the bytes with a content-derived ETag so the browser can serve a
The album art image as PNG/JPEG 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,
detail="No album artwork available", detail="No album artwork available",
) )
# Try to detect image type from magic bytes # Detect image type from magic bytes
content_type = "image/png" # Default
if art_bytes[:3] == b"\xff\xd8\xff": if art_bytes[:3] == b"\xff\xd8\xff":
content_type = "image/jpeg" content_type = "image/jpeg"
elif art_bytes[:8] == b"\x89PNG\r\n\x1a\n": elif art_bytes[:8] == b"\x89PNG\r\n\x1a\n":
content_type = "image/png" content_type = "image/png"
elif art_bytes[:4] == b"RIFF" and art_bytes[8:12] == b"WEBP": elif art_bytes[:4] == b"RIFF" and len(art_bytes) > 12 and art_bytes[8:12] == b"WEBP":
content_type = "image/webp" content_type = "image/webp"
elif art_bytes[:2] == b"BM":
content_type = "image/bmp"
else:
content_type = "application/octet-stream"
return Response(content=art_bytes, media_type=content_type) # Content-derived ETag (blake2b-128 — non-crypto cache key, ruff S324-safe)
import hashlib
etag = '"' + hashlib.blake2b(art_bytes, digest_size=16).hexdigest() + '"'
if request.headers.get("if-none-match") == etag:
return Response(status_code=status.HTTP_304_NOT_MODIFIED, headers={"ETag": etag})
return Response(
content=art_bytes,
media_type=content_type,
headers={
"ETag": etag,
"Cache-Control": "private, max-age=0, must-revalidate",
},
)
@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,
} }
@@ -323,12 +409,17 @@ async def set_visualizer_device(
@router.websocket("/ws") @router.websocket("/ws")
async def websocket_endpoint( async def websocket_endpoint(
websocket: WebSocket, websocket: WebSocket,
token: str | None = Query(None, description="API authentication token"), token: str | None = Query(None, description="API authentication token (legacy)"),
) -> None: ) -> None:
"""WebSocket endpoint for real-time media status updates. """WebSocket endpoint for real-time media status updates.
Authentication is done via query parameter since WebSocket Authentication is accepted from two sources, in priority order:
doesn't support custom headers in the browser. 1. ``Sec-WebSocket-Protocol`` subprotocol of the form
``media-server.token.<TOKEN>``. This is the preferred path because
the token never lands in the URL, request logs, or browser history.
The browser WebSocket API supports custom subprotocols natively.
2. ``?token=<TOKEN>`` query parameter (legacy, kept for back-compat
with older clients and the HA integration).
Messages sent to client: Messages sent to client:
- {"type": "status", "data": {...}} - Initial status on connect - {"type": "status", "data": {...}} - Initial status on connect
@@ -339,11 +430,54 @@ async def websocket_endpoint(
- {"type": "ping"} - Keepalive, server responds with {"type": "pong"} - {"type": "ping"} - Keepalive, server responds with {"type": "pong"}
- {"type": "get_status"} - Request current status - {"type": "get_status"} - Request current status
""" """
# Pull token from subprotocol if present. WebSocket spec lets either side
# negotiate exactly one subprotocol back; we accept the token one and
# answer with the same string so browsers consider the negotiation
# successful.
subprotocol_token: str | None = None
accept_subprotocol: str | None = None
raw_protocols = websocket.headers.get("sec-websocket-protocol", "")
for proto in (p.strip() for p in raw_protocols.split(",") if p.strip()):
if proto.startswith("media-server.token."):
subprotocol_token = proto[len("media-server.token."):]
accept_subprotocol = proto
break
effective_token = subprotocol_token or token
# Origin check — block CSWSH from third-party LAN pages. Accept the same
# 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(
settings.cors_origins
or [
f"http://localhost:{settings.port}",
f"http://127.0.0.1:{settings.port}",
]
)
origin = websocket.headers.get("origin")
# Same-origin connections from native apps may omit Origin entirely; only
# reject when an Origin is present AND not in the allow-list.
if origin is not None and origin not in allowed_origins:
host_header = websocket.headers.get("host", "")
# Origin uses http/https; match against both scheme variants of Host
# so HTTPS deployments without an explicit cors_origins still work.
same_origin_candidates = (
{f"http://{host_header}", f"https://{host_header}"}
if host_header
else set()
)
if origin not in same_origin_candidates:
await websocket.close(code=4003, reason="Origin not allowed")
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
if auth_enabled(): if auth_enabled():
label = get_token_label(token) if token else None label = get_token_label(effective_token) if effective_token else None
if label is None: if label is None:
await websocket.close(code=4001, reason="Invalid authentication token") await websocket.close(code=4001, reason="Invalid authentication token")
return return
@@ -351,16 +485,25 @@ async def websocket_endpoint(
else: else:
token_label_var.set("anonymous") token_label_var.set("anonymous")
await ws_manager.connect(websocket) # Accept with the negotiated subprotocol if one was used. Starlette's
# connect() calls accept() with no subprotocol — we need to accept first
# explicitly to echo the subprotocol back, then hand off to the manager.
if accept_subprotocol is not None:
await websocket.accept(subprotocol=accept_subprotocol)
await ws_manager.connect(websocket, already_accepted=True)
else:
await ws_manager.connect(websocket)
try: try:
while True: while True:
# Wait for messages from client (for keepalive/ping) # Wait for messages from client (for keepalive/ping)
data = await websocket.receive_json() data = await websocket.receive_json()
if data.get("type") == "ping": msg_type = data.get("type") if isinstance(data, dict) else None
if msg_type == "ping":
await websocket.send_json({"type": "pong"}) await websocket.send_json({"type": "pong"})
elif data.get("type") == "get_status": elif msg_type == "get_status":
# Allow manual status request # Allow manual status request
controller = get_media_controller() controller = get_media_controller()
status_data = await controller.get_status() status_data = await controller.get_status()
@@ -368,15 +511,20 @@ async def websocket_endpoint(
"type": "status", "type": "status",
"data": status_data.model_dump(), "data": status_data.model_dump(),
}) })
elif data.get("type") == "volume": elif msg_type == "volume":
# Low-latency volume control via WebSocket # Low-latency volume control via WebSocket. Coerce, clamp, and
volume = data.get("volume") # never drop the socket on a single bad message — that would
if volume is not None: # turn the WS into a one-shot DoS for any holder of a token.
controller = get_media_controller() try:
await controller.set_volume(int(volume)) volume = int(data.get("volume"))
elif data.get("type") == "enable_visualizer": except (TypeError, ValueError):
continue
volume = max(0, min(100, volume))
controller = get_media_controller()
await controller.set_volume(volume)
elif msg_type == "enable_visualizer":
await ws_manager.subscribe_visualizer(websocket) await ws_manager.subscribe_visualizer(websocket)
elif data.get("type") == "disable_visualizer": elif msg_type == "disable_visualizer":
await ws_manager.unsubscribe_visualizer(websocket) await ws_manager.unsubscribe_visualizer(websocket)
except WebSocketDisconnect: except WebSocketDisconnect:
+81 -2
View File
@@ -10,12 +10,14 @@ import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from ..auth import verify_token from ..auth import verify_token
from ..config import ScriptConfig, ScriptParameterConfig, settings from ..config import ScriptConfig, ScriptParameterConfig, settings
from ..config_manager import config_manager from ..config_manager import config_manager
from ..services.rate_limit import check as ratelimit_check
from ..services.rate_limit import get_peer
from ..services.websocket_manager import ws_manager from ..services.websocket_manager import ws_manager
router = APIRouter(prefix="/api/scripts", tags=["scripts"]) router = APIRouter(prefix="/api/scripts", tags=["scripts"])
@@ -31,6 +33,12 @@ def shutdown_script_executor() -> None:
def _require_scripts_management() -> None: def _require_scripts_management() -> None:
"""Authorise a scripts-CRUD operation.
Two gates: the operator-level `scripts_management` flag in config.yaml,
AND the per-token `admin` scope check (read from request-context). Either
failure → 403.
"""
if not settings.scripts_management: if not settings.scripts_management:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
@@ -39,6 +47,14 @@ def _require_scripts_management() -> None:
" in config.yaml to enable." " in config.yaml to enable."
), ),
) )
from ..auth import auth_enabled, token_has_scope, token_label_var
if auth_enabled():
label = token_label_var.get("unknown")
if not token_has_scope(label, "admin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Token '{label}' lacks required scope: admin",
)
class ScriptExecuteRequest(BaseModel): class ScriptExecuteRequest(BaseModel):
@@ -215,6 +231,28 @@ def _validate_params(
# string — just convert to str # string — just convert to str
value = str(value) value = str(value)
# Optional regex constraint, validated against the *string form* of the
# value. This is the only practical defence for string parameters that
# flow into shell=true scripts via env vars (Windows cmd.exe expands
# `%VAR%` after argument parsing, so embedded `&`/`|`/`%` would inject
# commands). Authors of shell scripts should ALWAYS define a pattern.
if pdef.pattern:
try:
if not re.fullmatch(pdef.pattern, str(value)):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"Parameter '{pname}' value {value!r} does not match"
f" required pattern: {pdef.pattern}"
),
)
except re.error as e:
# Bad pattern in config — fail closed.
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Parameter '{pname}' has invalid pattern: {e}",
) from e
env_vars[f"SCRIPT_PARAM_{pname.upper()}"] = str(value) env_vars[f"SCRIPT_PARAM_{pname.upper()}"] = str(value)
return env_vars return env_vars
@@ -223,6 +261,7 @@ def _validate_params(
@router.post("/execute/{script_name}") @router.post("/execute/{script_name}")
async def execute_script( async def execute_script(
script_name: str, script_name: str,
http_request: Request,
request: ScriptExecuteRequest | None = None, request: ScriptExecuteRequest | None = None,
_: str = Depends(verify_token), _: str = Depends(verify_token),
) -> ScriptExecuteResponse: ) -> ScriptExecuteResponse:
@@ -235,6 +274,16 @@ async def execute_script(
Returns: Returns:
Execution result including stdout, stderr, and exit code Execution result including stdout, stderr, and exit code
""" """
# Rate-limit script execution per peer so a leaked token can't be used to
# spam the shell-exec endpoint.
allowed, retry_after = ratelimit_check("execute", get_peer(http_request))
if not allowed:
raise HTTPException(
status_code=429,
detail="Too many script executions, slow down",
headers={"Retry-After": str(int(retry_after or 60))},
)
# Check if script exists # Check if script exists
if script_name not in settings.scripts: if script_name not in settings.scripts:
raise HTTPException( raise HTTPException(
@@ -249,6 +298,8 @@ async def execute_script(
logger.info(f"Executing script: {script_name}") logger.info(f"Executing script: {script_name}")
from ..services.audit_log import record_script_execution
try: try:
# Execute in dedicated thread pool to not block the default executor # Execute in dedicated thread pool to not block the default executor
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
@@ -263,6 +314,15 @@ async def execute_script(
), ),
) )
record_script_execution(
kind="script",
name=script_name,
exit_code=result["exit_code"],
duration=result.get("execution_time"),
stdout=result.get("stdout"),
stderr=result.get("stderr"),
)
return ScriptExecuteResponse( return ScriptExecuteResponse(
success=result["exit_code"] == 0, success=result["exit_code"] == 0,
script=script_name, script=script_name,
@@ -274,6 +334,13 @@ async def execute_script(
except Exception as e: except Exception as e:
logger.error(f"Script execution error: {e}") logger.error(f"Script execution error: {e}")
record_script_execution(
kind="script",
name=script_name,
exit_code=None,
duration=None,
error=str(e),
)
return ScriptExecuteResponse( return ScriptExecuteResponse(
success=False, success=False,
script=script_name, script=script_name,
@@ -313,9 +380,21 @@ def _run_script(
else: else:
popen_kwargs["start_new_session"] = True popen_kwargs["start_new_session"] = True
# When shell=False, the user-provided command string is split via shlex
# (POSIX rules — also works for Windows args without backslashes). This
# disables shell metacharacter expansion entirely, so SCRIPT_PARAM_* env
# vars referenced as $FOO / %FOO% will be treated as literal text by the
# process, not interpreted by a shell. Use shell=false for any script
# whose params come from external input.
if shell:
run_command: str | list[str] = command
else:
import shlex
run_command = shlex.split(command, posix=(sys.platform != "win32"))
try: try:
result = subprocess.run( result = subprocess.run(
command, run_command,
shell=shell, shell=shell,
cwd=working_dir, cwd=working_dir,
capture_output=True, capture_output=True,
+19 -6
View File
@@ -57,25 +57,38 @@ install_service() {
# Create installation directory # Create installation directory
mkdir -p "$INSTALL_DIR" mkdir -p "$INSTALL_DIR"
# Copy source files # Resolve the source-tree root (two levels up from this script:
cp -r "$(dirname "$0")/../"* "$INSTALL_DIR/" # media_server/service/install_linux.sh → repo root).
SOURCE_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
# Copy the source tree (pyproject.toml + media_server/ package)
cp -r "$SOURCE_ROOT/." "$INSTALL_DIR/"
# Create virtual environment # Create virtual environment
echo_info "Creating Python virtual environment..." echo_info "Creating Python virtual environment..."
python3 -m venv "$INSTALL_DIR/venv" python3 -m venv "$INSTALL_DIR/venv"
# Install Python dependencies # Install Python dependencies from pyproject.toml (with linux extra).
# cd into the install dir so pip resolves `.[linux]` against the local
# pyproject — passing a path-with-extras (`$INSTALL_DIR[linux]`) trips
# the requirement-spec parser on some pip versions.
echo_info "Installing Python dependencies..." echo_info "Installing Python dependencies..."
"$INSTALL_DIR/venv/bin/pip" install --upgrade pip "$INSTALL_DIR/venv/bin/pip" install --upgrade pip
"$INSTALL_DIR/venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt" (cd "$INSTALL_DIR" && "$INSTALL_DIR/venv/bin/pip" install ".[linux]")
# Install systemd service file # Install systemd service file (templated unit)
echo_info "Installing systemd service..." echo_info "Installing systemd service..."
cp "$INSTALL_DIR/service/media-server.service" "$SERVICE_FILE" cp "$INSTALL_DIR/media_server/service/media-server.service" "$SERVICE_FILE"
# Reload systemd # Reload systemd
systemctl daemon-reload systemctl daemon-reload
# Pre-create writable state dirs so the unit's ReadWritePaths grant
# actually has something to grant.
sudo -u "$SUDO_USER" mkdir -p \
"/home/$SUDO_USER/.config/media-server" \
"/home/$SUDO_USER/.cache/media-server"
# Generate config if not exists # Generate config if not exists
if [[ ! -f "/home/$SUDO_USER/.config/media-server/config.yaml" ]]; then if [[ ! -f "/home/$SUDO_USER/.config/media-server/config.yaml" ]]; then
echo_info "Generating configuration file..." echo_info "Generating configuration file..."
+19 -15
View File
@@ -3,34 +3,38 @@ Description=Media Server - REST API for controlling system media playback
After=network.target sound.target After=network.target sound.target
Wants=sound.target Wants=sound.target
# Templated unit. Enable as:
# sudo systemctl enable --now media-server@$USER
# %i is the user name supplied after the '@'; %U is the matching numeric UID.
[Service] [Service]
Type=simple Type=simple
User=%i User=%i
Group=%i Group=%i
# Environment variables (optional - can also use config file) # Working directory (override via drop-in if you install elsewhere)
# Environment=MEDIA_SERVER_HOST=0.0.0.0
# Environment=MEDIA_SERVER_PORT=8765
# Environment=MEDIA_SERVER_API_TOKEN=your-secret-token
# Working directory
WorkingDirectory=/opt/media-server WorkingDirectory=/opt/media-server
# Start command - adjust path to your Python environment # Start command adjust to match where you installed the venv. --no-tray
ExecStart=/opt/media-server/venv/bin/python -m media_server.main # avoids pulling pystray into a headless service environment.
ExecStart=/opt/media-server/venv/bin/python -m media_server.main --no-tray
# Restart policy Restart=on-failure
Restart=always
RestartSec=10 RestartSec=10
# Security hardening # Required for MPRIS (dbus.SessionBus) and PulseAudio/PipeWire loopback.
Environment=PYTHONUNBUFFERED=1
Environment=XDG_RUNTIME_DIR=/run/user/%U
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%U/bus
# Light sandboxing. ProtectHome=read-only by itself blocks the app's own
# audit.log / thumbnail cache writes — ReadWritePaths re-opens just the
# two state dirs the server owns.
NoNewPrivileges=true NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict ProtectSystem=strict
ProtectHome=read-only ProtectHome=read-only
PrivateTmp=true ReadWritePaths=/home/%i/.config/media-server /home/%i/.cache/media-server
# Required for D-Bus access (MPRIS)
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%U/bus
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+28 -2
View File
@@ -65,7 +65,12 @@ def get_media_controller() -> "MediaController":
def get_current_album_art() -> bytes | None: def get_current_album_art() -> bytes | None:
"""Get the current album art bytes (Windows only for now).""" """Get the current album art bytes (synchronous, Windows-cached path).
Windows pre-populates a module-level cache via the WinRT polling thread,
so this stays sync. For Linux/macOS the controller fetches on demand —
use ``get_current_album_art_async()`` from FastAPI handlers instead.
"""
system = platform.system() system = platform.system()
if system == "Windows": if system == "Windows":
from .windows_media import get_current_album_art as _get_art from .windows_media import get_current_album_art as _get_art
@@ -73,6 +78,22 @@ def get_current_album_art() -> bytes | None:
return None return None
async def get_current_album_art_async() -> bytes | None:
"""Cross-platform album art fetch. Awaits the controller's impl.
Falls back to the sync Windows cache when running on Windows so we don't
pay an extra coroutine hop for the existing path.
"""
system = platform.system()
if system == "Windows":
return get_current_album_art()
controller = get_media_controller()
try:
return await controller.get_album_art()
except Exception: # noqa: BLE001 — art is best-effort; never break the route
return None
def get_audio_devices() -> list[dict[str, str]]: def get_audio_devices() -> list[dict[str, str]]:
"""Get list of available audio output devices (Windows only for now).""" """Get list of available audio output devices (Windows only for now)."""
system = platform.system() system = platform.system()
@@ -82,4 +103,9 @@ def get_audio_devices() -> list[dict[str, str]]:
return [] return []
__all__ = ["get_media_controller", "get_current_album_art", "get_audio_devices"] __all__ = [
"get_media_controller",
"get_current_album_art",
"get_current_album_art_async",
"get_audio_devices",
]
+120
View File
@@ -0,0 +1,120 @@
"""Append-only audit log for sensitive actions (script + callback execution).
Writes a single JSONL line per event to ``<config_dir>/audit.log``. The log is
write-only from the app's perspective — it never reads back, and rotation is
left to the operator (the file size is dominated by stdout/stderr truncation,
which is already capped at 10 KB per stream in `_run_script`).
Designed to be cheap: the write goes through a small background thread so the
hot path never blocks on disk I/O, and a failure to write is logged at WARNING
but never raised to callers.
"""
from __future__ import annotations
import json
import logging
import queue
import threading
import time
from typing import Any
from ..auth import token_label_var
from ..config import get_config_dir
logger = logging.getLogger(__name__)
# Cap on stdout/stderr inside the audit record so a chatty script doesn't
# explode the log. Mirrors the 10k cap used by _run_script.
_OUTPUT_CAP = 2000
_audit_queue: "queue.Queue[dict[str, Any] | None]" = queue.Queue(maxsize=1000)
_audit_thread: threading.Thread | None = None
_audit_lock = threading.Lock()
def _ensure_writer_started() -> None:
global _audit_thread
with _audit_lock:
if _audit_thread is not None and _audit_thread.is_alive():
return
_audit_thread = threading.Thread(
target=_audit_writer_loop,
name="audit-log",
daemon=True,
)
_audit_thread.start()
def _audit_writer_loop() -> None:
log_path = get_config_dir() / "audit.log"
while True:
try:
record = _audit_queue.get()
except Exception:
return
if record is None:
return
try:
line = json.dumps(record, ensure_ascii=False, default=str)
with open(log_path, "a", encoding="utf-8") as f:
f.write(line + "\n")
except OSError as e:
logger.warning("Failed to write audit record: %s", e)
def _truncate(value: str | None) -> str | None:
if value is None:
return None
if len(value) <= _OUTPUT_CAP:
return value
return value[:_OUTPUT_CAP] + f"\n…[truncated, {len(value) - _OUTPUT_CAP} chars]"
def record_script_execution(
*,
kind: str,
name: str,
exit_code: int | None,
duration: float | None,
stdout: str | None = None,
stderr: str | None = None,
error: str | None = None,
) -> None:
"""Append a single audit record. Never raises."""
_ensure_writer_started()
try:
record = {
"ts": time.time(),
"iso": time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime()),
"token_label": token_label_var.get("unknown"),
"kind": kind,
"name": name,
"exit_code": exit_code,
"duration_s": round(duration, 4) if duration is not None else None,
"success": exit_code == 0 if exit_code is not None else False,
"stdout": _truncate(stdout),
"stderr": _truncate(stderr),
"error": error,
}
_audit_queue.put_nowait(record)
except queue.Full:
# Backpressure: drop oldest record to make room. We'd rather lose an
# old entry than block the script that just ran.
try:
_audit_queue.get_nowait()
_audit_queue.put_nowait(record)
except queue.Empty:
pass
except Exception as e:
logger.warning("Failed to enqueue audit record: %s", e)
def shutdown_audit_log() -> None:
"""Flush the audit queue on app shutdown."""
try:
_audit_queue.put_nowait(None)
except queue.Full:
pass
if _audit_thread is not None:
_audit_thread.join(timeout=2)
+38 -14
View File
@@ -192,10 +192,11 @@ _CACHE_TTL = 5.0 # seconds
# Per-monitor cache of static capabilities (option lists + support flags). # Per-monitor cache of static capabilities (option lists + support flags).
# DDC/CI capability discovery is the slow part — it only changes when a # DDC/CI capability discovery is the slow part — it only changes when a
# monitor is replaced or rewired, so we probe it once per monitor and reuse # monitor is replaced or rewired, so we probe it once per monitor and reuse
# it across refreshes. Cleared on explicit `rediscover` or when the monitor # it across refreshes. Keyed by a stable identity tuple
# count changes (cheap stale-detection for hot-plug events). # (manufacturer, model, edid_hash) so that hot-plug swaps where the new
_static_cache: dict[int, dict] = {} # topology has the same number of monitors but different devices still
_static_cache_monitor_count: int = -1 # refresh the cache for the new monitor instead of serving stale capabilities.
_static_cache: dict[tuple, dict] = {}
def _enum_name(value, enum_cls=None) -> str | None: def _enum_name(value, enum_cls=None) -> str | None:
@@ -353,7 +354,7 @@ def list_monitors(force_refresh: bool = False, rediscover: bool = False) -> list
next probe re-runs DDC/CI capability discovery. Use after hot-plug next probe re-runs DDC/CI capability discovery. Use after hot-plug
or when a monitor's reported capabilities change. or when a monitor's reported capabilities change.
""" """
global _monitor_cache, _cache_time, _static_cache_monitor_count global _monitor_cache, _cache_time
if ( if (
not force_refresh not force_refresh
@@ -372,12 +373,11 @@ def list_monitors(force_refresh: bool = False, rediscover: bool = False) -> list
info_list = sbc.list_monitors_info() info_list = sbc.list_monitors_info()
brightnesses = sbc.get_brightness() brightnesses = sbc.get_brightness()
# Invalidate the static cache on explicit rediscover OR on topology # Explicit rediscover wipes the whole cache; otherwise rely on stable
# change (hot-plug / disconnect). Both indicate the cached probe is # per-monitor keys (manufacturer|model|edid_hash) so a hot-plug swap
# potentially stale. # invalidates the entry for the missing monitor automatically.
if rediscover or len(info_list) != _static_cache_monitor_count: if rediscover:
_static_cache.clear() _static_cache.clear()
_static_cache_monitor_count = len(info_list)
mc = _load_monitorcontrol() mc = _load_monitorcontrol()
ddc_monitors = [] ddc_monitors = []
@@ -387,6 +387,9 @@ def list_monitors(force_refresh: bool = False, rediscover: bool = False) -> list
except Exception: except Exception:
pass pass
import hashlib
seen_keys: set[tuple] = set()
for i, info in enumerate(info_list): for i, info in enumerate(info_list):
name = info.get("name", f"Monitor {i}") name = info.get("name", f"Monitor {i}")
model = info.get("model", "") model = info.get("model", "")
@@ -400,6 +403,21 @@ def list_monitors(force_refresh: bool = False, rediscover: bool = False) -> list
edid = info.get("edid", "") edid = info.get("edid", "")
resolution = _parse_edid_resolution(edid) if edid else None resolution = _parse_edid_resolution(edid) if edid else None
# Stable cache key — EDID hash is unique per physical monitor.
# Fall back to (manufacturer, model, serial-ish) when EDID is
# missing, then to the legacy index as a last resort.
if edid:
edid_hash = hashlib.blake2b(
edid.encode("utf-8") if isinstance(edid, str) else bytes(edid),
digest_size=8,
).hexdigest()
cache_key: tuple = ("edid", edid_hash)
elif manufacturer or model:
cache_key = ("mm", manufacturer, model, name)
else:
cache_key = ("idx", i)
seen_keys.add(cache_key)
static: dict = {} static: dict = {}
dynamic: dict = {} dynamic: dict = {}
@@ -409,13 +427,13 @@ def list_monitors(force_refresh: bool = False, rediscover: bool = False) -> list
if power_supported and i < len(ddc_monitors): if power_supported and i < len(ddc_monitors):
try: try:
with ddc_monitors[i] as mon: with ddc_monitors[i] as mon:
if i not in _static_cache: if cache_key not in _static_cache:
_static_cache[i] = _probe_static_open(mon, mc, i) _static_cache[cache_key] = _probe_static_open(mon, mc, i)
static = _static_cache[i] static = _static_cache[cache_key]
dynamic = _probe_dynamic_open(mon, mc, i, static) dynamic = _probe_dynamic_open(mon, mc, i, static)
except Exception as e: except Exception as e:
logger.debug("Monitor %d: DDC/CI session failed: %s", i, e) logger.debug("Monitor %d: DDC/CI session failed: %s", i, e)
static = _static_cache.get(i, {}) static = _static_cache.get(cache_key, {})
monitors.append(MonitorInfo( monitors.append(MonitorInfo(
id=i, id=i,
@@ -439,6 +457,12 @@ def list_monitors(force_refresh: bool = False, rediscover: bool = False) -> list
available_picture_modes=static.get("available_picture_modes", []), available_picture_modes=static.get("available_picture_modes", []),
picture_mode_supported=static.get("picture_mode_supported", False), picture_mode_supported=static.get("picture_mode_supported", False),
)) ))
# Evict cache entries for monitors that disappeared from this scan so
# the next hot-plug of a different monitor with the same identity
# tuple (e.g. same model) doesn't hit a stale entry first.
for stale_key in list(_static_cache.keys()):
if stale_key not in seen_keys:
_static_cache.pop(stale_key, None)
except Exception as e: except Exception as e:
logger.error("Failed to enumerate monitors: %s", e) logger.error("Failed to enumerate monitors: %s", e)
+36 -7
View File
@@ -86,9 +86,29 @@ class _Cache:
_cache = _Cache() _cache = _Cache()
# Win32 handles + signatures are declared once at module load (when running on
# Windows). The TTL cache fires this hundreds of times per minute; redoing the
# DLL load + ~10 argtype assignments per call was the largest chunk of probe
# cost. Keep these guarded behind a lazy init so non-Windows platforms don't
# pay the import.
_WIN32_INITIALIZED = False
_win32_user32 = None
_win32_kernel32 = None
_win32_psapi = None
def _init_win32_apis() -> None:
"""Declare ctypes argtypes/restype on every Win32 call we make.
CRITICAL: ctypes defaults to `c_int` (32-bit) for HANDLE/HWND/HMONITOR
which silently truncates 64-bit pointer values on x64 — that corrupts the
handle so `CloseHandle()` can either fail or close the wrong kernel
object, and pointer-equality comparisons (monitor index lookup) miss.
"""
global _WIN32_INITIALIZED, _win32_user32, _win32_kernel32, _win32_psapi
if _WIN32_INITIALIZED:
return
def _probe_windows() -> ForegroundInfo:
"""Probe foreground window state on Windows via Win32 API."""
import ctypes import ctypes
import ctypes.wintypes as wt import ctypes.wintypes as wt
@@ -96,11 +116,6 @@ def _probe_windows() -> ForegroundInfo:
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
psapi = ctypes.WinDLL("psapi", use_last_error=True) psapi = ctypes.WinDLL("psapi", use_last_error=True)
# CRITICAL: declare argtypes/restype on every Win32 call that returns a
# HANDLE/HWND/HMONITOR. ctypes defaults to `c_int` (32-bit) which
# silently truncates 64-bit pointer values on x64 — that corrupts the
# handle so `CloseHandle()` can either fail or close the wrong kernel
# object, and pointer-equality comparisons (monitor index lookup) miss.
user32.GetForegroundWindow.restype = wt.HWND user32.GetForegroundWindow.restype = wt.HWND
user32.GetWindowThreadProcessId.argtypes = [wt.HWND, ctypes.POINTER(wt.DWORD)] user32.GetWindowThreadProcessId.argtypes = [wt.HWND, ctypes.POINTER(wt.DWORD)]
user32.GetWindowThreadProcessId.restype = wt.DWORD user32.GetWindowThreadProcessId.restype = wt.DWORD
@@ -137,6 +152,20 @@ def _probe_windows() -> ForegroundInfo:
psapi.GetModuleFileNameExW.argtypes = [wt.HANDLE, wt.HMODULE, wt.LPWSTR, wt.DWORD] psapi.GetModuleFileNameExW.argtypes = [wt.HANDLE, wt.HMODULE, wt.LPWSTR, wt.DWORD]
psapi.GetModuleFileNameExW.restype = wt.DWORD psapi.GetModuleFileNameExW.restype = wt.DWORD
_win32_user32, _win32_kernel32, _win32_psapi = user32, kernel32, psapi
_WIN32_INITIALIZED = True
def _probe_windows() -> ForegroundInfo:
"""Probe foreground window state on Windows via Win32 API."""
import ctypes
import ctypes.wintypes as wt
_init_win32_apis()
user32 = _win32_user32
kernel32 = _win32_kernel32
psapi = _win32_psapi
hwnd = user32.GetForegroundWindow() hwnd = user32.GetForegroundWindow()
if not hwnd: if not hwnd:
return ForegroundInfo(available=True, error="no foreground window") return ForegroundInfo(available=True, error="no foreground window")
@@ -2,6 +2,7 @@
import json import json
import logging import logging
import re
import urllib.error import urllib.error
import urllib.request import urllib.request
from typing import Optional from typing import Optional
@@ -15,6 +16,11 @@ _DEFAULT_BASE_URL = "https://git.dolgolyov-family.by"
_DEFAULT_OWNER = "alexei.dolgolyov" _DEFAULT_OWNER = "alexei.dolgolyov"
_DEFAULT_REPO = "media-player-server" _DEFAULT_REPO = "media-player-server"
# Restrictive tag whitelist — prevents a hostile Gitea response (or MITM) from
# injecting `..`, slashes, or URL-altering characters into the release URL we
# broadcast to clients. SemVer + pre-release suffix only.
_TAG_RE = re.compile(r"^v?\d+\.\d+\.\d+(?:[\w.\-+]{0,32})?$")
class GiteaReleaseProvider(ReleaseProvider): class GiteaReleaseProvider(ReleaseProvider):
"""Fetches the latest release from a Gitea repository.""" """Fetches the latest release from a Gitea repository."""
@@ -53,6 +59,9 @@ class GiteaReleaseProvider(ReleaseProvider):
continue continue
tag = release.get("tag_name", "") tag = release.get("tag_name", "")
if not isinstance(tag, str) or not _TAG_RE.match(tag):
logger.warning("Rejecting malformed release tag from upstream: %r", tag)
continue
version = tag.lstrip("v") version = tag.lstrip("v")
if not version: if not version:
continue continue
+120 -4
View File
@@ -2,14 +2,23 @@
import asyncio import asyncio
import logging import logging
import os
import subprocess import subprocess
import threading
from typing import Any, Optional from typing import Any, Optional
from urllib.parse import unquote, urlparse
from ..models import MediaState, MediaStatus from ..models import MediaState, MediaStatus
from .media_controller import MediaController from .media_controller import MediaController
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Cap remote artwork downloads so a hostile / huge Spotify image can't
# blow up RAM. Real album art rarely exceeds ~2 MB; 8 MB is a comfortable
# upper bound that also covers loss-less PNGs.
_MAX_ART_BYTES = 8 * 1024 * 1024
_ART_FETCH_TIMEOUT = 5.0 # seconds
# Linux-specific imports # Linux-specific imports
try: try:
import dbus import dbus
@@ -35,13 +44,54 @@ class LinuxMediaController(MediaController):
"Linux media control requires dbus-python package. " "Linux media control requires dbus-python package. "
"Install with: sudo apt-get install python3-dbus" "Install with: sudo apt-get install python3-dbus"
) )
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
+66 -3
View File
@@ -3,6 +3,7 @@
import asyncio import asyncio
import logging import logging
import subprocess import subprocess
import threading
from typing import Optional from typing import Optional
from ..models import MediaState, MediaStatus from ..models import MediaState, MediaStatus
@@ -10,10 +11,20 @@ from .media_controller import MediaController
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Cap remote artwork downloads (Spotify's artwork url is http(s)://).
_MAX_ART_BYTES = 8 * 1024 * 1024
_ART_FETCH_TIMEOUT = 5.0 # seconds
class MacOSMediaController(MediaController): class MacOSMediaController(MediaController):
"""Media controller for macOS using osascript and system commands.""" """Media controller for macOS using osascript and system commands."""
def __init__(self) -> None:
# Cached art bytes keyed by the active art URL.
self._art_lock = threading.Lock()
self._art_url: Optional[str] = None
self._art_bytes: Optional[bytes] = None
def _run_osascript(self, script: str) -> Optional[str]: def _run_osascript(self, script: str) -> Optional[str]:
"""Run an AppleScript and return the output.""" """Run an AppleScript and return the output."""
try: try:
@@ -193,12 +204,60 @@ class MacOSMediaController(MediaController):
status.album = info.get("album") status.album = info.get("album")
status.duration = info.get("duration") status.duration = info.get("duration")
status.position = info.get("position") status.position = info.get("position")
status.album_art_url = info.get("art_url") art_url = info.get("art_url")
status.album_art_url = art_url
# Track changes invalidate the cached image bytes — actual
# fetch happens lazily in get_album_art().
with self._art_lock:
if art_url != self._art_url:
self._art_url = art_url
self._art_bytes = None
else: else:
status.state = MediaState.IDLE status.state = MediaState.IDLE
return status return status
def _fetch_art_sync(self, url: str) -> Optional[bytes]:
"""Resolve a Spotify/Music art URL (http(s)://) to bytes.
File-scheme URLs aren't expected on macOS (AppleScript apps return
artwork as remote URLs), so only http(s) is supported.
"""
try:
from urllib.parse import urlparse
parsed = urlparse(url)
except ValueError:
return None
if parsed.scheme.lower() not in ("http", "https"):
return None
import urllib.error
import urllib.request
req = urllib.request.Request(url, headers={"User-Agent": "media-server/0.x"})
try:
with urllib.request.urlopen(req, timeout=_ART_FETCH_TIMEOUT) as resp:
return resp.read(_MAX_ART_BYTES + 1)[:_MAX_ART_BYTES] or None
except (urllib.error.URLError, urllib.error.HTTPError, OSError) as e:
logger.debug("Could not fetch macOS art %s: %s", url, e)
return None
async def get_album_art(self) -> Optional[bytes]:
"""Return cached art bytes, fetching once per track URL."""
with self._art_lock:
url = self._art_url
cached = self._art_bytes
if cached is not None:
return cached
if not url:
return None
data = await asyncio.to_thread(self._fetch_art_sync, url)
with self._art_lock:
if url == self._art_url:
self._art_bytes = data
return data
async def play(self) -> bool: async def play(self) -> bool:
"""Resume playback using media key simulation.""" """Resume playback using media key simulation."""
# Use system media key # Use system media key
@@ -264,8 +323,12 @@ class MacOSMediaController(MediaController):
async def set_volume(self, volume: int) -> bool: async def set_volume(self, volume: int) -> bool:
"""Set system volume.""" """Set system volume."""
result = self._run_osascript(f"set volume output volume {volume}") # osascript returns empty string on success and None on failure (the
return result is not None or True # osascript returns empty on success # _run_osascript helper catches subprocess errors). The previous
# `result is not None or True` always returned True regardless of
# outcome — surface real failures so the route can return 503.
result = self._run_osascript(f"set volume output volume {int(volume)}")
return result is not None
async def toggle_mute(self) -> bool: async def toggle_mute(self) -> bool:
"""Toggle mute state.""" """Toggle mute state."""
@@ -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
+95
View File
@@ -0,0 +1,95 @@
"""In-process token-bucket rate limiter.
Light enough for a single-process app: one dict keyed by ``(bucket, peer)``
guarded by a thread lock. No extra dependency, no Redis. Good enough for
defeating credential-stuffing and runaway clients on a LAN; not a substitute
for an upstream WAF in a public deployment.
Buckets:
auth — failed-auth attempts, 5/min/peer (used in auth middleware)
execute — script + callback execute calls, 10/min/peer (LAN-friendly)
default — generic POST/DELETE writes, 60/min/peer
"""
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass
from typing import Optional
logger = logging.getLogger(__name__)
@dataclass
class BucketConfig:
capacity: float # max tokens (= burst size)
refill_per_sec: float # tokens added per second
# Defaults — tuned for "trusted LAN" use; operator can override via Settings.
BUCKETS: dict[str, BucketConfig] = {
"auth": BucketConfig(capacity=5, refill_per_sec=5 / 60), # 5/min
"execute": BucketConfig(capacity=10, refill_per_sec=10 / 60), # 10/min
"default": BucketConfig(capacity=60, refill_per_sec=60 / 60), # 60/min
}
_state: dict[tuple[str, str], tuple[float, float]] = {}
_lock = threading.Lock()
_LAST_CLEANUP = 0.0
def _evict_stale_locked(now: float) -> None:
"""Drop entries whose buckets are full (= idle for capacity / refill seconds)."""
global _LAST_CLEANUP
if now - _LAST_CLEANUP < 60:
return
_LAST_CLEANUP = now
stale = []
for key, (tokens, last) in _state.items():
bucket = BUCKETS.get(key[0])
if bucket is None:
continue
if tokens >= bucket.capacity and (now - last) > 3600:
stale.append(key)
for key in stale:
_state.pop(key, None)
def check(bucket: str, peer: str) -> tuple[bool, Optional[float]]:
"""Try to consume one token from ``(bucket, peer)``.
Returns:
(allowed, retry_after_seconds). When allowed=True retry_after is None.
When allowed=False, retry_after is the seconds to wait for one more token.
"""
cfg = BUCKETS.get(bucket) or BUCKETS["default"]
now = time.monotonic()
with _lock:
_evict_stale_locked(now)
tokens, last = _state.get((bucket, peer), (cfg.capacity, now))
elapsed = max(0.0, now - last)
tokens = min(cfg.capacity, tokens + elapsed * cfg.refill_per_sec)
if tokens >= 1:
tokens -= 1
_state[(bucket, peer)] = (tokens, now)
return True, None
deficit = 1 - tokens
retry = deficit / cfg.refill_per_sec if cfg.refill_per_sec > 0 else 60
_state[(bucket, peer)] = (tokens, now)
return False, retry
def get_peer(request) -> str:
"""Best-effort peer identifier from a Starlette request.
Honors X-Forwarded-For (only when settings.proxy_headers is True, which is
already enforced by uvicorn's middleware) so a reverse-proxied install
still rate-limits per real client.
"""
client = getattr(request, "client", None)
if client and client.host:
return client.host
return "unknown"
+16 -5
View File
@@ -26,12 +26,23 @@ class ThumbnailService:
def get_cache_dir() -> Path: def get_cache_dir() -> Path:
"""Get the thumbnail cache directory path. """Get the thumbnail cache directory path.
Returns: Returns user-writable platform cache dir so installs under
Path to the cache directory (project-local). ``%PROGRAMFILES%`` / ``/opt`` work without elevated permissions.
Mirrors the platform branching of ``config.get_config_dir``.
""" """
# Store cache in project directory: media-server/.cache/thumbnails/ import os
project_root = Path(__file__).parent.parent.parent
cache_dir = project_root / ".cache" / "thumbnails" if os.name == "nt":
# %LOCALAPPDATA% so the cache survives roaming-profile sync.
base = Path(os.environ.get("LOCALAPPDATA")
or os.environ.get("APPDATA")
or Path.home() / "AppData" / "Local")
cache_dir = base / "media-server" / "cache" / "thumbnails"
else:
# XDG_CACHE_HOME convention; falls back to ~/.cache.
xdg = os.environ.get("XDG_CACHE_HOME")
base = Path(xdg) if xdg else Path.home() / ".cache"
cache_dir = base / "media-server" / "thumbnails"
cache_dir.mkdir(parents=True, exist_ok=True) cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir return cache_dir
+9 -3
View File
@@ -33,9 +33,15 @@ class ConnectionManager:
self._audio_task: asyncio.Task | None = None self._audio_task: asyncio.Task | None = None
self._audio_analyzer = None self._audio_analyzer = None
async def connect(self, websocket: WebSocket) -> None: async def connect(self, websocket: WebSocket, already_accepted: bool = False) -> None:
"""Accept a new WebSocket connection.""" """Accept a new WebSocket connection.
await websocket.accept()
``already_accepted=True`` is for callers that needed to call
``websocket.accept(subprotocol=...)`` themselves (token-via-subprotocol
auth path).
"""
if not already_accepted:
await websocket.accept()
async with self._lock: async with self._lock:
self._active_connections.add(websocket) self._active_connections.add(websocket)
logger.info( logger.info(
+51 -23
View File
@@ -31,8 +31,15 @@ def _thread_loop() -> asyncio.AbstractEventLoop:
_thread_local.loop = loop _thread_local.loop = loop
return loop return loop
# Global storage for current album art (as bytes) # Global storage for current album art (as bytes). Guarded by _art_lock so the
# WinRT polling thread and the FastAPI handler thread don't race on swap.
_current_album_art_bytes: bytes | None = None _current_album_art_bytes: bytes | None = None
_art_lock = threading.Lock()
# Identity of the track whose art is currently in _current_album_art_bytes.
# Used to gate the expensive WinRT thumbnail.open_read_async() so the bytes
# aren't re-decoded on every 500ms status poll.
_current_album_art_key: tuple | None = None
# Lock protecting _position_cache and _track_skip_pending from concurrent access # Lock protecting _position_cache and _track_skip_pending from concurrent access
_position_lock = threading.Lock() _position_lock = threading.Lock()
@@ -56,8 +63,9 @@ _track_skip_pending = {
def get_current_album_art() -> bytes | None: def get_current_album_art() -> bytes | None:
"""Get the current album art bytes.""" """Get the current album art bytes (thread-safe snapshot)."""
return _current_album_art_bytes with _art_lock:
return _current_album_art_bytes
# Windows-specific imports # Windows-specific imports
try: try:
@@ -379,28 +387,48 @@ def _sync_get_media_status() -> dict[str, Any]:
except Exception as e: except Exception as e:
logger.debug(f"Timeline parse error: {e}") logger.debug(f"Timeline parse error: {e}")
# Try to get album art (requires media_props) # Try to get album art (requires media_props). Gated by track key so
# the WinRT IPC + bytes copy only runs when the track actually
# changes; otherwise we just preserve the existing cached bytes.
if media_props: if media_props:
try: track_key = (
thumbnail = media_props.thumbnail getattr(media_props, "title", "") or "",
if thumbnail: getattr(media_props, "artist", "") or "",
stream = loop.run_until_complete(thumbnail.open_read_async()) getattr(media_props, "album_title", "") or "",
if stream: )
size = stream.size global _current_album_art_bytes, _current_album_art_key
if size > 0 and size < 10 * 1024 * 1024: # Max 10MB if track_key == _current_album_art_key and _current_album_art_bytes:
from winsdk.windows.storage.streams import DataReader # Same track — reuse cached art bytes without touching WinRT.
reader = DataReader(stream) result["album_art_url"] = "/api/media/artwork"
loop.run_until_complete(reader.load_async(size)) else:
buffer = bytearray(size) try:
reader.read_bytes(buffer) thumbnail = media_props.thumbnail
reader.close() if thumbnail:
stream.close() stream = loop.run_until_complete(thumbnail.open_read_async())
if stream:
size = stream.size
if size > 0 and size < 10 * 1024 * 1024: # Max 10MB
from winsdk.windows.storage.streams import DataReader
reader = DataReader(stream)
loop.run_until_complete(reader.load_async(size))
buffer = bytearray(size)
reader.read_bytes(buffer)
reader.close()
stream.close()
global _current_album_art_bytes with _art_lock:
_current_album_art_bytes = bytes(buffer) _current_album_art_bytes = bytes(buffer)
result["album_art_url"] = "/api/media/artwork" _current_album_art_key = track_key
except Exception as e: result["album_art_url"] = "/api/media/artwork"
logger.debug(f"Failed to get album art: {e}") else:
# No thumbnail on this track — drop stale bytes so
# the ETag flips and clients don't keep showing the
# previous album's cover.
with _art_lock:
_current_album_art_bytes = None
_current_album_art_key = track_key
except Exception as e:
logger.debug(f"Failed to get album art: {e}")
result["source"] = session.source_app_user_model_id result["source"] = session.source_app_user_model_id
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 B

After

Width:  |  Height:  |  Size: 37 KiB

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

Before

Width:  |  Height:  |  Size: 421 B

After

Width:  |  Height:  |  Size: 1.6 KiB

+9 -9
View File
@@ -26,16 +26,16 @@
</div> </div>
</div> </div>
<div class="mini-controls"> <div class="mini-controls">
<button class="mini-control-btn mini-nav-btn" data-onclick="previousTrack()" data-i18n-title="player.previous" title="Previous"> <button class="mini-control-btn mini-nav-btn" data-onclick="previousTrack()" data-i18n-title="player.previous" data-i18n-aria-label="player.previous" title="Previous" aria-label="Previous">
<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg> <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
</button> </button>
<button class="mini-control-btn" data-onclick="togglePlayPause()" id="mini-btn-play-pause" title="Play/Pause"> <button class="mini-control-btn" data-onclick="togglePlayPause()" id="mini-btn-play-pause" title="Play/Pause" aria-label="Play/Pause">
<svg viewBox="0 0 24 24" id="mini-play-pause-icon"> <svg viewBox="0 0 24 24" id="mini-play-pause-icon" aria-hidden="true" focusable="false">
<path d="M8 5v14l11-7z"/> <path d="M8 5v14l11-7z"/>
</svg> </svg>
</button> </button>
<button class="mini-control-btn mini-nav-btn" data-onclick="nextTrack()" data-i18n-title="player.next" title="Next"> <button class="mini-control-btn mini-nav-btn" data-onclick="nextTrack()" data-i18n-title="player.next" data-i18n-aria-label="player.next" title="Next" aria-label="Next">
<svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg> <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
</button> </button>
</div> </div>
<div class="mini-progress-container"> <div class="mini-progress-container">
@@ -48,8 +48,8 @@
</div> </div>
</div> </div>
<div class="mini-volume-container"> <div class="mini-volume-container">
<button class="mini-control-btn" data-onclick="toggleMute()" id="mini-btn-mute" title="Mute"> <button class="mini-control-btn" data-onclick="toggleMute()" id="mini-btn-mute" title="Mute" aria-label="Mute" aria-pressed="false">
<svg viewBox="0 0 24 24" id="mini-mute-icon"> <svg viewBox="0 0 24 24" id="mini-mute-icon" aria-hidden="true" focusable="false">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/> <path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
</svg> </svg>
</button> </button>
@@ -88,7 +88,7 @@
</div> </div>
<div class="header-toolbar"> <div class="header-toolbar">
<div id="headerLinks" class="header-links"></div> <div id="headerLinks" class="header-links"></div>
<a class="header-btn" href="/docs" target="_blank" title="API Documentation" aria-label="API Documentation"> <a class="header-btn" href="/docs" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" title="API Documentation" aria-label="API Documentation">
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 2l5 5h-5V4zM6 20V4h5v7h7v9H6zm2-4h8v2H8v-2zm0-3h8v2H8v-2z"/></svg> <svg viewBox="0 0 24 24"><path fill="currentColor" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 2l5 5h-5V4zM6 20V4h5v7h7v9H6zm2-4h8v2H8v-2zm0-3h8v2H8v-2z"/></svg>
</a> </a>
<button class="header-btn" data-onclick="showAboutDialog()" data-i18n-title="about.button_title" title="About" aria-label="About"> <button class="header-btn" data-onclick="showAboutDialog()" data-i18n-title="about.button_title" title="About" aria-label="About">
+2
View File
@@ -536,6 +536,8 @@ export async function loadHeaderLinks() {
a.href = link.url; a.href = link.url;
a.target = '_blank'; a.target = '_blank';
a.rel = 'noopener noreferrer'; a.rel = 'noopener noreferrer';
// Prevent leaking the WebUI URL (with ?token=) via Referer.
a.referrerPolicy = 'no-referrer';
a.className = 'header-link'; a.className = 'header-link';
a.title = link.label || link.url; a.title = link.label || link.url;
+80
View File
@@ -10,6 +10,7 @@ import {
lastStatus, setLastStatus, currentPlayState, setCurrentPlayState, lastStatus, setLastStatus, currentPlayState, setCurrentPlayState,
POSITION_INTERPOLATION_MS, seek, notifyRemoteVolume, POSITION_INTERPOLATION_MS, seek, notifyRemoteVolume,
getAuthHeaders, hasCredentials, getAuthHeaders, hasCredentials,
togglePlayPause, nextTrack, previousTrack,
} from './core.js'; } from './core.js';
import { updateBackgroundColors } from './background.js'; import { updateBackgroundColors } from './background.js';
import { loadDisplayMonitors } from './links.js'; import { loadDisplayMonitors } from './links.js';
@@ -381,11 +382,85 @@ function buildVisualizerGradient() {
function startVisualizerRender() { function startVisualizerRender() {
if (visualizerAnimFrame) return; if (visualizerAnimFrame) return;
// Don't even queue a frame while the tab is hidden — rAF still fires on
// hidden tabs (throttled but not paused) and would burn CPU + battery
// smoothing into bars no one can see. We resume on `visibilitychange`.
if (typeof document !== 'undefined' && document.hidden) return;
// Cache editorial spectrum bar refs once per start. // Cache editorial spectrum bar refs once per start.
cacheEditorialSpectrumBars(); cacheEditorialSpectrumBars();
renderVisualizerFrame(); renderVisualizerFrame();
} }
// ─── OS Media Session integration ─────────────────────────────
// Hooks the page into the system's media session so headset / lockscreen /
// Bluetooth play-pause-skip buttons drive the active track. Action handlers
// are set once and never re-registered; only the metadata + playback state
// flip when a track changes.
let _mediaSessionInitialised = false;
let _lastMediaSessionKey = '';
function syncMediaSession(status) {
if (typeof navigator === 'undefined' || !('mediaSession' in navigator)) return;
const session = navigator.mediaSession;
if (!_mediaSessionInitialised) {
const setHandler = (name, fn) => {
try { session.setActionHandler(name, fn); } catch { /* unsupported action */ }
};
setHandler('play', () => togglePlayPause());
setHandler('pause', () => togglePlayPause());
setHandler('nexttrack', () => nextTrack());
setHandler('previoustrack', () => previousTrack());
setHandler('seekto', (ev) => { if (ev && typeof ev.seekTime === 'number') seek(ev.seekTime); });
_mediaSessionInitialised = true;
}
// Track-identity key — re-build metadata only when title/artist/album change.
const artworkSrc = status && status.album_art_url ? '/api/media/artwork' : '';
const key = `${status.title || ''}|${status.artist || ''}|${status.album || ''}|${artworkSrc}`;
if (key !== _lastMediaSessionKey) {
_lastMediaSessionKey = key;
try {
session.metadata = new MediaMetadata({
title: status.title || '',
artist: status.artist || '',
album: status.album || '',
artwork: artworkSrc ? [{ src: artworkSrc, sizes: '512x512', type: 'image/*' }] : [],
});
} catch { /* MediaMetadata unsupported on very old browsers */ }
}
session.playbackState =
status.state === 'playing' ? 'playing'
: status.state === 'paused' ? 'paused'
: 'none';
if (typeof session.setPositionState === 'function'
&& status.duration && status.duration > 0
&& typeof status.position === 'number') {
try {
session.setPositionState({
duration: status.duration,
position: Math.min(status.position, status.duration),
playbackRate: 1.0,
});
} catch { /* invalid range — ignore */ }
}
}
// Pause / resume the visualizer with tab visibility. Idempotent: called once
// at module init below, no-op if no listener support.
if (typeof document !== 'undefined' && document.addEventListener) {
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
stopVisualizerRender();
} else if (frequencyData) {
// Only restart if a payload is live (otherwise startVisualizerRender
// would queue a no-op rAF chain forever waiting for one).
startVisualizerRender();
}
});
}
export function stopVisualizerRender() { export function stopVisualizerRender() {
if (visualizerAnimFrame) { if (visualizerAnimFrame) {
cancelAnimationFrame(visualizerAnimFrame); cancelAnimationFrame(visualizerAnimFrame);
@@ -903,6 +978,11 @@ export function updateUI(status) {
updateMuteIcon(status.muted); updateMuteIcon(status.muted);
// Wire the OS Media Session so headset buttons, lockscreen controls, and
// Bluetooth remotes drive the active media (not the WebUI tab). Cheap and
// idempotent — re-running setActionHandler with the same fn is a no-op.
syncMediaSession(status);
const src = resolveMediaSource(status.source); const src = resolveMediaSource(status.source);
dom.source.textContent = src ? src.name : t('player.unknown_source'); dom.source.textContent = src ? src.name : t('player.unknown_source');
dom.sourceIcon.innerHTML = src?.icon || ''; dom.sourceIcon.innerHTML = src?.icon || '';
+3
View File
@@ -80,6 +80,9 @@ export async function displayQuickAccess() {
card.href = link.url; card.href = link.url;
card.target = '_blank'; card.target = '_blank';
card.rel = 'noopener noreferrer'; card.rel = 'noopener noreferrer';
// Prevent the WebUI's URL (which may carry ?token=...) from
// leaking to third-party sites via Referer.
card.referrerPolicy = 'no-referrer';
if (link.icon) { if (link.icon) {
const iconEl = document.createElement('div'); const iconEl = document.createElement('div');
+15 -2
View File
@@ -88,9 +88,22 @@ export function connectWebSocket(token) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsBase = `${protocol}//${window.location.host}/api/media/ws`; const wsBase = `${protocol}//${window.location.host}/api/media/ws`;
const wsUrl = token ? `${wsBase}?token=${encodeURIComponent(token)}` : wsBase;
const newWs = new WebSocket(wsUrl); // Prefer Sec-WebSocket-Protocol-based auth so the token never appears in
// the URL (which would otherwise land in browser history, server access
// logs, and Referer headers). Keep the ?token=... fallback for clients
// that pre-date this change and don't speak the subprotocol.
let newWs;
if (token) {
try {
newWs = new WebSocket(wsBase, [`media-server.token.${token}`]);
} catch (e) {
console.warn('Subprotocol WS handshake failed, falling back to ?token=', e);
newWs = new WebSocket(`${wsBase}?token=${encodeURIComponent(token)}`);
}
} else {
newWs = new WebSocket(wsBase);
}
activeSocket = newWs; activeSocket = newWs;
setWs(newWs); setWs(newWs);
+4 -2
View File
@@ -1,12 +1,14 @@
{ {
"id": "/",
"name": "Media Server", "name": "Media Server",
"short_name": "Media", "short_name": "Media",
"description": "Remote media player control and file browser", "description": "Remote media player control and file browser",
"start_url": "/", "start_url": "/",
"scope": "/",
"display": "standalone", "display": "standalone",
"orientation": "any", "orientation": "any",
"background_color": "#121212", "background_color": "#0E0D0B",
"theme_color": "#121212", "theme_color": "#0E0D0B",
"icons": [ "icons": [
{ {
"src": "/static/icons/icon.svg", "src": "/static/icons/icon.svg",
+121 -40
View File
@@ -3,6 +3,7 @@
import ctypes import ctypes
import io import io
import logging import logging
import sys
import webbrowser import webbrowser
from pathlib import Path from pathlib import Path
from typing import Callable from typing import Callable
@@ -30,62 +31,136 @@ _IDYES = 6
def _confirm(title: str, message: str) -> bool: def _confirm(title: str, message: str) -> bool:
"""Show a Yes/No dialog using native Windows MessageBox.""" """Show a Yes/No dialog before a destructive tray action.
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:
@@ -101,6 +176,9 @@ class TrayManager:
self._port = port self._port = port
self._on_exit = on_exit self._on_exit = on_exit
# Initialize so the property and any cross-thread reader cannot ever
# observe an uninitialized attribute. Set before _on_exit() fires.
self._restart_requested = False
menu = pystray.Menu( menu = pystray.Menu(
pystray.MenuItem("Show UI", self._show_ui, default=True), pystray.MenuItem("Show UI", self._show_ui, default=True),
@@ -123,13 +201,16 @@ class TrayManager:
if not _confirm("Media Server", "Restart the server?"): if not _confirm("Media Server", "Restart the server?"):
return return
logger.info("Restart requested from tray") logger.info("Restart requested from tray")
# Set the flag BEFORE signalling exit so the main thread observes it
# when it wakes from server_thread.join() — order matters across the
# tray/uvicorn handoff.
self._restart_requested = True self._restart_requested = True
self._on_exit() self._on_exit()
self._icon.stop() self._icon.stop()
@property @property
def restart_requested(self) -> bool: def restart_requested(self) -> bool:
return getattr(self, "_restart_requested", False) return self._restart_requested
def _shutdown(self, icon: "pystray.Icon", item: "pystray.MenuItem") -> None: def _shutdown(self, icon: "pystray.Icon", item: "pystray.MenuItem") -> None:
if not _confirm("Media Server", "Shut down the server?"): if not _confirm("Media Server", "Shut down the server?"):
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "media-server-frontend", "name": "media-server-frontend",
"version": "0.2.7", "version": "0.4.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "media-server-frontend", "name": "media-server-frontend",
"version": "0.2.7", "version": "0.4.0",
"devDependencies": { "devDependencies": {
"esbuild": "^0.27.4" "esbuild": "^0.27.4"
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "media-server-frontend", "name": "media-server-frontend",
"version": "0.2.7", "version": "0.4.0",
"private": true, "private": true,
"description": "Frontend build tooling for media server WebUI", "description": "Frontend build tooling for media server WebUI",
"scripts": { "scripts": {
+27 -7
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "media-server" name = "media-server"
version = "0.2.7" version = "0.4.0"
description = "REST API server for controlling system-wide media playback" description = "REST API server for controlling system-wide media playback"
readme = "README.md" readme = "README.md"
license = { text = "MIT" } license = { text = "MIT" }
@@ -34,24 +34,44 @@ dependencies = [
"pillow>=10.0.0", "pillow>=10.0.0",
"soundcard>=0.4.0", "soundcard>=0.4.0",
"numpy>=1.24.0,<2.0", "numpy>=1.24.0,<2.0",
# screen-brightness-control + monitorcontrol are cross-platform
# (Windows / Linux i2c-dev). Kept in base deps so a plain
# `pip install media-server` yields a working /api/display on any
# platform that has the underlying hardware support.
"screen-brightness-control>=0.20.0",
"monitorcontrol>=3.0.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
windows = [ windows = [
"winsdk>=1.0.0b10", "winsdk>=1.0.0b10",
"pywin32>=306", "pywin32>=306; sys_platform == 'win32'",
"comtypes>=1.2.0", "comtypes>=1.2.0; sys_platform == 'win32'",
"pycaw>=20230407", "pycaw>=20230407; sys_platform == 'win32'",
"screen-brightness-control>=0.20.0", "wmi>=1.5.1; sys_platform == 'win32'",
"wmi>=1.5.1",
"monitorcontrol>=3.0.0",
"pystray>=0.19.0", "pystray>=0.19.0",
] ]
linux = [
# MPRIS / D-Bus media control. Requires libdbus-1-dev, libglib2.0-dev,
# and pkg-config installed on the host before pip install.
"dbus-python>=1.3.2; sys_platform == 'linux'",
"PyGObject>=3.46; sys_platform == 'linux'",
# X11 foreground-window probe (Wayland sessions degrade gracefully).
"python-xlib>=0.33; sys_platform == 'linux'",
]
macos = [
# PyObjC for the foreground-window + active-app probes.
"pyobjc-framework-Cocoa>=10.0; sys_platform == 'darwin'",
"pyobjc-framework-Quartz>=10.0; sys_platform == 'darwin'",
]
dev = [ dev = [
"pytest>=7.0", "pytest>=7.0",
"pytest-asyncio>=0.21", "pytest-asyncio>=0.21",
"httpx>=0.24", "httpx>=0.24",
"ruff>=0.4.0", "ruff>=0.4.0",
# SVG -> PNG rasterizer used by scripts/generate-icon.py to (re)build
# media_server/static/icons/icon.ico from icon.svg. Build-time only.
"resvg-py>=0.3.2",
] ]
[project.urls] [project.urls]
+113
View File
@@ -0,0 +1,113 @@
"""Generate the Media Server application icon.
The SVG in ``media_server/static/icons/icon.svg`` is the single source of
truth. This script rasterizes it at every Windows ICO size via ``resvg-py``
(Rust-backed, identical math to Firefox's SVG renderer) and packs them all
into a multi-resolution ``icon.ico``.
This replaces the original 16x16-only ICO that Windows was upscaling into
mush for the installer chrome, Start Menu, desktop shortcuts, and Alt+Tab.
Usage:
python scripts/generate-icon.py
Dependencies:
pip install resvg-py Pillow
"""
from __future__ import annotations
import io
from pathlib import Path
import resvg_py
from PIL import Image
# Sizes packed into the ICO. Windows picks the closest match per surface;
# more sizes = sharper rendering everywhere (taskbar, installer header,
# Alt+Tab, jump lists, Start tile, desktop, file explorer details/tiles).
ICO_SIZES: tuple[int, ...] = (16, 20, 24, 32, 40, 48, 64, 96, 128, 256)
# The SVG source. Squircle + diagonal teal gradient + warm parchment play
# triangle with a drop shadow + ghosted echo-chevrons hinting at broadcast.
SVG_SOURCE = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Media Server">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0B3D3B"/>
<stop offset="100%" stop-color="#1A6B5E"/>
</linearGradient>
<linearGradient id="sheen" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.18"/>
<stop offset="55%" stop-color="#FFFFFF" stop-opacity="0"/>
</linearGradient>
<filter id="triShadow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3"/>
<feOffset dx="3" dy="5"/>
<feComponentTransfer><feFuncA type="linear" slope="0.45"/></feComponentTransfer>
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<clipPath id="clip"><rect x="0" y="0" width="256" height="256" rx="58" ry="58"/></clipPath>
</defs>
<g clip-path="url(#clip)">
<rect width="256" height="256" fill="url(#bg)"/>
<rect width="256" height="256" fill="url(#sheen)"/>
<g stroke="#F5F1E8" stroke-width="4.6" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.18">
<polyline points="66,72 105,128 66,184"/>
<polyline points="85,82 124,128 85,174" opacity="0.6"/>
</g>
<path d="M88.3 55 L88.3 201 L193.3 128 Z"
fill="#F5F1E8"
stroke="#F5F1E8" stroke-width="9" stroke-linejoin="round"
filter="url(#triShadow)"/>
</g>
<rect x="0.5" y="0.5" width="255" height="255" rx="58" ry="58"
fill="none" stroke="#000000" stroke-opacity="0.18" stroke-width="1"/>
</svg>
"""
def _render_png(size: int) -> Image.Image:
"""Rasterize the SVG to a PNG at ``size x size`` via resvg."""
data = resvg_py.svg_to_bytes(
svg_string=SVG_SOURCE,
width=size,
height=size,
shape_rendering="geometric_precision",
)
return Image.open(io.BytesIO(bytes(data))).convert("RGBA")
def main() -> None:
root = Path(__file__).resolve().parent.parent
out_dir = root / "media_server" / "static" / "icons"
out_dir.mkdir(parents=True, exist_ok=True)
# SVG — canonical source, also used as the Web UI favicon.
svg_path = out_dir / "icon.svg"
svg_path.write_text(SVG_SOURCE, encoding="utf-8")
print(f"wrote {svg_path}")
# Rasterize every ICO size via resvg.
frames = [_render_png(size) for size in ICO_SIZES]
# Pack into a multi-resolution ICO. The "primary" image must be the
# largest; the rest go via append_images. Pillow's ICO writer then
# serializes one frame per size.
primary = frames[-1]
ico_path = out_dir / "icon.ico"
primary.save(
ico_path,
format="ICO",
sizes=[(s, s) for s in ICO_SIZES],
append_images=frames[:-1],
)
print(f"wrote {ico_path} ({ico_path.stat().st_size:,} bytes, sizes={list(ICO_SIZES)})")
# Largest PNG for documentation / non-Windows surfaces.
png_path = out_dir / "icon-256.png"
primary.save(png_path, format="PNG", optimize=True)
print(f"wrote {png_path}")
if __name__ == "__main__":
main()
+58
View File
@@ -0,0 +1,58 @@
"""Tests for token scope hierarchy + back-compat with legacy bare-string tokens."""
from __future__ import annotations
import pytest
from media_server.config import Settings, TokenSpec
def test_bare_string_token_promotes_to_admin_scope():
"""Legacy `label: <token>` form must still work and grant admin."""
s = Settings(api_tokens={"legacy": "deadbeef-deadbeef-deadbeef-deadbeef"})
spec = s.api_tokens["legacy"]
assert isinstance(spec, TokenSpec)
assert spec.token == "deadbeef-deadbeef-deadbeef-deadbeef"
assert spec.scopes == ["admin"]
assert spec.grants("admin")
assert spec.grants("control")
assert spec.grants("read")
def test_dict_token_with_explicit_scopes():
s = Settings(api_tokens={
"ha": {"token": "aaaaaaaaaaaaaaaa", "scopes": ["read", "control"]},
})
spec = s.api_tokens["ha"]
assert spec.grants("control")
assert spec.grants("read")
assert not spec.grants("admin")
def test_read_only_scope_grants_only_read():
spec = TokenSpec(token="xxxxxxxxxxxxxxxx", scopes=["read"])
assert spec.grants("read")
assert not spec.grants("control")
assert not spec.grants("admin")
def test_admin_scope_implies_control_and_read():
spec = TokenSpec(token="xxxxxxxxxxxxxxxx", scopes=["admin"])
assert spec.grants("read")
assert spec.grants("control")
assert spec.grants("admin")
def test_unknown_scope_rejected():
with pytest.raises(ValueError, match="unknown scopes"):
TokenSpec(token="xxxxxxxxxxxxxxxx", scopes=["root"])
def test_empty_scopes_rejected():
with pytest.raises(ValueError, match="at least one"):
TokenSpec(token="xxxxxxxxxxxxxxxx", scopes=[])
def test_short_token_rejected():
with pytest.raises(ValueError):
TokenSpec(token="short", scopes=["read"])
+84
View File
@@ -0,0 +1,84 @@
"""Path traversal defence for BrowserService.validate_path.
The browser endpoint is the single most security-critical filesystem entry
point in the app: it serves file contents and folder listings to the WebUI.
A bypass here = arbitrary read of any file the server process can see.
The current implementation signals rejection by *raising* (ValueError for
traversal/NUL/unknown folder, FileNotFoundError for non-existent absolute
paths). Either rejection mode is acceptable — these tests assert that the
adversarial input never returns a path *inside* the configured base.
"""
from __future__ import annotations
import tempfile
from pathlib import Path
from unittest.mock import patch
import pytest
from media_server.services.browser_service import BrowserService
@pytest.fixture
def tmp_media_folder():
"""A real temp dir registered as a media folder for the test duration."""
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp).resolve()
(base / "ok.mp3").write_bytes(b"id3")
(base / "sub").mkdir()
(base / "sub" / "nested.mp3").write_bytes(b"id3")
from media_server.config import MediaFolderConfig
folders = {"test": MediaFolderConfig(path=str(base), label="Test", enabled=True)}
with patch("media_server.services.browser_service.settings.media_folders", folders):
yield base
def _is_rejected(folder_id: str, rel: str) -> bool:
"""Helper: True iff validate_path either raises or returns None."""
try:
result = BrowserService.validate_path(folder_id, rel)
except (ValueError, FileNotFoundError, OSError):
return True
return result is None
def test_validate_path_accepts_a_real_file(tmp_media_folder: Path):
p = BrowserService.validate_path("test", "ok.mp3")
assert p is not None
assert p.is_file()
# Defence-in-depth: resolved path must live inside the base.
assert tmp_media_folder in p.resolve().parents or p.resolve().parent == tmp_media_folder
def test_validate_path_accepts_nested(tmp_media_folder: Path):
p = BrowserService.validate_path("test", "sub/nested.mp3")
assert p is not None
def test_unknown_folder_rejected(tmp_media_folder: Path):
assert _is_rejected("ghost", "ok.mp3")
def test_dotdot_traversal_rejected(tmp_media_folder: Path):
assert _is_rejected("test", "../etc/passwd")
assert _is_rejected("test", "..\\..\\Windows\\System32")
assert _is_rejected("test", "sub/../../etc/passwd")
def test_absolute_path_rejected(tmp_media_folder: Path):
assert _is_rejected("test", "/etc/passwd")
assert _is_rejected("test", "C:\\Windows\\System32")
assert _is_rejected("test", "C:/Windows")
def test_unc_path_rejected(tmp_media_folder: Path):
assert _is_rejected("test", "\\\\server\\share")
assert _is_rejected("test", "//server/share")
def test_null_byte_rejected(tmp_media_folder: Path):
assert _is_rejected("test", "ok.mp3\x00.png")
assert _is_rejected("test", "sub\x00/nested.mp3")
+46
View File
@@ -0,0 +1,46 @@
"""Atomic config writes + POSIX permission hardening."""
from __future__ import annotations
import os
import stat
import sys
import tempfile
from pathlib import Path
import pytest
from media_server.config import _restrict_config_perms, _write_yaml_atomic
def test_atomic_write_round_trip():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "config.yaml"
_write_yaml_atomic(path, {"port": 8765, "host": "127.0.0.1"})
assert path.exists()
# Tmp file from the rename should be gone.
assert not path.with_suffix(path.suffix + ".tmp").exists()
# Contents are valid YAML and round-trip.
import yaml
data = yaml.safe_load(path.read_text())
assert data == {"port": 8765, "host": "127.0.0.1"}
def test_atomic_write_replaces_existing():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "config.yaml"
path.write_text("old: 1\n")
_write_yaml_atomic(path, {"new": 2})
import yaml
assert yaml.safe_load(path.read_text()) == {"new": 2}
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only permission check")
def test_restrict_config_perms_posix():
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "config.yaml"
path.write_text("token: secret\n")
_restrict_config_perms(path)
mode = stat.S_IMODE(os.stat(path).st_mode)
# Owner read+write only.
assert mode == 0o600, f"got {oct(mode)}"
-4
View File
@@ -8,10 +8,6 @@ Windows/Linux/macOS probes themselves are exercised through manual runs.
from __future__ import annotations from __future__ import annotations
import time
import pytest
from media_server.services import foreground_service as fg from media_server.services import foreground_service as fg
+45
View File
@@ -0,0 +1,45 @@
"""Tag-name validation in the Gitea release provider.
Whitelist regex protects the URL we broadcast to clients from any path
traversal or character-set abuse in a hostile (or MITM'd) upstream response.
"""
from __future__ import annotations
from media_server.services.gitea_release_provider import _TAG_RE
def test_accepts_plain_semver():
assert _TAG_RE.match("1.0.0")
assert _TAG_RE.match("v1.0.0")
assert _TAG_RE.match("0.3.7")
def test_accepts_pre_release_suffix():
assert _TAG_RE.match("v1.0.0-alpha.1")
assert _TAG_RE.match("v2.3.4-rc.10")
assert _TAG_RE.match("v0.2.7+build.42")
def test_rejects_path_traversal():
assert not _TAG_RE.match("../etc/passwd")
assert not _TAG_RE.match("v1.0.0/../../evil")
assert not _TAG_RE.match("v1.0.0/secret")
def test_rejects_url_injection():
assert not _TAG_RE.match("v1.0.0?evil=1")
assert not _TAG_RE.match("v1.0.0#frag")
assert not _TAG_RE.match("v1.0.0 OR 1=1")
assert not _TAG_RE.match("https://evil.example/")
def test_rejects_empty_and_garbage():
assert not _TAG_RE.match("")
assert not _TAG_RE.match("not-a-version")
assert not _TAG_RE.match("v")
def test_rejects_excessively_long_suffix():
long_suffix = "x" * 40
assert not _TAG_RE.match(f"v1.0.0-{long_suffix}")
+68
View File
@@ -0,0 +1,68 @@
"""Token-bucket rate limiter behaviour."""
from __future__ import annotations
import time
import pytest
from media_server.services import rate_limit
@pytest.fixture(autouse=True)
def _reset_state():
rate_limit._state.clear()
rate_limit._LAST_CLEANUP = 0.0
yield
rate_limit._state.clear()
def test_allows_up_to_capacity_then_blocks(monkeypatch):
"""Default execute bucket = 10/min."""
peer = "10.0.0.1"
for i in range(10):
ok, retry = rate_limit.check("execute", peer)
assert ok, f"expected allow on attempt {i + 1}, got block (retry={retry})"
ok, retry = rate_limit.check("execute", peer)
assert ok is False
assert retry is not None and retry > 0
def test_different_peers_independent():
for _ in range(10):
assert rate_limit.check("execute", "10.0.0.1")[0]
# Different peer should still be allowed.
assert rate_limit.check("execute", "10.0.0.2")[0]
def test_unknown_bucket_uses_default():
peer = "10.0.0.3"
# default = 60/min — first call always allowed.
allowed, _ = rate_limit.check("nonexistent-bucket", peer)
assert allowed
def test_auth_bucket_is_strict():
"""auth bucket = 5/min."""
peer = "10.0.0.4"
for _ in range(5):
assert rate_limit.check("auth", peer)[0]
blocked, retry = rate_limit.check("auth", peer)
assert not blocked
assert retry is not None
def test_refill_eventually_unblocks(monkeypatch):
"""Verify the bucket refills — exhaust then wait one refill period."""
peer = "10.0.0.5"
# Replace BUCKETS with a fast-refilling one for the test only.
monkeypatch.setitem(
rate_limit.BUCKETS,
"fast-test",
rate_limit.BucketConfig(capacity=2, refill_per_sec=10.0),
)
assert rate_limit.check("fast-test", peer)[0]
assert rate_limit.check("fast-test", peer)[0]
assert not rate_limit.check("fast-test", peer)[0]
time.sleep(0.15) # 0.15 * 10 = 1.5 tokens
assert rate_limit.check("fast-test", peer)[0]
+77
View File
@@ -0,0 +1,77 @@
"""Validation rules for script parameters (type coercion, regex pattern)."""
from __future__ import annotations
import pytest
from fastapi import HTTPException
from media_server.config import ScriptParameterConfig
from media_server.routes.scripts import _validate_params
def _defs(**kwargs) -> dict[str, ScriptParameterConfig]:
return {name: ScriptParameterConfig(**spec) for name, spec in kwargs.items()}
def test_unknown_param_rejected():
with pytest.raises(HTTPException) as ei:
_validate_params({"x": "1"}, _defs())
assert ei.value.status_code == 400
assert "Unknown" in ei.value.detail
def test_missing_required_rejected():
defs = _defs(name={"type": "string", "required": True})
with pytest.raises(HTTPException, match="missing"):
_validate_params({}, defs)
def test_integer_coercion_and_bounds():
defs = _defs(volume={"type": "integer", "min": 0, "max": 100})
out = _validate_params({"volume": "42"}, defs)
assert out == {"SCRIPT_PARAM_VOLUME": "42"}
with pytest.raises(HTTPException, match="<="):
_validate_params({"volume": 200}, defs)
with pytest.raises(HTTPException, match=">="):
_validate_params({"volume": -1}, defs)
with pytest.raises(HTTPException, match="integer"):
_validate_params({"volume": "not-a-number"}, defs)
def test_boolean_coercion():
defs = _defs(flag={"type": "boolean"})
assert _validate_params({"flag": "true"}, defs) == {"SCRIPT_PARAM_FLAG": "True"}
assert _validate_params({"flag": "no"}, defs) == {"SCRIPT_PARAM_FLAG": "False"}
with pytest.raises(HTTPException, match="boolean"):
_validate_params({"flag": "maybe"}, defs)
def test_select_rejects_non_option():
defs = _defs(mode={"type": "select", "options": ["a", "b", "c"]})
assert _validate_params({"mode": "a"}, defs) == {"SCRIPT_PARAM_MODE": "a"}
with pytest.raises(HTTPException, match="must be one of"):
_validate_params({"mode": "z"}, defs)
def test_pattern_enforced_on_string():
"""Regex pattern is the defence against shell metachars in shell=true scripts."""
defs = _defs(host={"type": "string", "pattern": r"^[a-z0-9.\-]+$"})
assert _validate_params({"host": "example.com"}, defs) == {"SCRIPT_PARAM_HOST": "example.com"}
with pytest.raises(HTTPException, match="pattern"):
_validate_params({"host": "evil & calc.exe"}, defs)
with pytest.raises(HTTPException, match="pattern"):
_validate_params({"host": "$(rm -rf /)"}, defs)
def test_pattern_can_disallow_empty():
defs = _defs(host={"type": "string", "pattern": r"^[a-z]+$"})
with pytest.raises(HTTPException, match="pattern"):
_validate_params({"host": ""}, defs)
def test_invalid_pattern_in_config_fails_closed():
defs = _defs(host={"type": "string", "pattern": r"["}) # unmatched bracket
with pytest.raises(HTTPException) as ei:
_validate_params({"host": "x"}, defs)
assert ei.value.status_code == 500