Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ddbb93537 | |||
| b7e50455ad | |||
| 0006620eb5 | |||
| e7a3f62a9a | |||
| d798fedf55 | |||
| ddf4a6cb29 | |||
| 82710c6457 | |||
| 9b9a2b5c9f | |||
| b023d72165 | |||
| d131ba461c | |||
| 450f9fe1ee | |||
| e1c8474271 | |||
| fe82836f4d | |||
| eeab9b2a26 | |||
| 61cdce9b60 | |||
| 0cf49deac0 | |||
| 527f3d0aa4 | |||
| 982dda42ac | |||
| eaeebb64cd | |||
| bcc6d40ed7 | |||
| 770bba7e60 | |||
| d1f621f0b4 | |||
| 6120625fa9 | |||
| 57fdeb70fb | |||
| 0d07f7f1f4 | |||
| 372e4eb11f | |||
| d27484a46d | |||
| 261a14c575 | |||
| e7372b0ccb | |||
| ec5178142e | |||
| 46af2bb8cc | |||
| 25a492d5dd | |||
| f4be2bdb89 | |||
| 51ec1503f4 | |||
| 08c3c80df4 | |||
| 62eeca1b9e | |||
| 4c93bfb8c1 | |||
| 59840a1190 | |||
| 2a474ea52c | |||
| f85ce77f14 | |||
| b09569f390 | |||
| f2c82164e8 | |||
| 588a303c44 | |||
| 2049850180 | |||
| 9b84fdd0e5 | |||
| 3de2b4496e | |||
| d7f488ac70 | |||
| 968eb156bc | |||
| a0f74dfc39 | |||
| 6066b4a2c5 | |||
| 153424eff8 | |||
| 336d596b66 | |||
| d937c1590c | |||
| d157388a94 | |||
| e9e4165927 | |||
| 77b39e5684 | |||
| d9d4672ca3 | |||
| 265b001b99 | |||
| 14e9f2294e | |||
| 8110c152b0 | |||
| 21adeb1070 | |||
| 68614c982d | |||
| a2a258e898 | |||
| 456eb3a881 | |||
| c586b1b518 | |||
| ee5184920d | |||
| af556e0bff | |||
| 26b4672a99 |
@@ -0,0 +1,16 @@
|
||||
# Normalise text files to LF in the repo so Windows checkouts stop
|
||||
# nagging "LF will be replaced by CRLF" on every git status.
|
||||
* text=auto eol=lf
|
||||
|
||||
# Binary assets — never touch.
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.svg text
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.exe binary
|
||||
*.dll binary
|
||||
*.zip binary
|
||||
@@ -60,6 +60,16 @@ jobs:
|
||||
with:
|
||||
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
|
||||
run: |
|
||||
chmod +x build-dist-linux.sh
|
||||
|
||||
@@ -61,6 +61,10 @@ jobs:
|
||||
| Windows (portable) | \`MediaServer-{tag}-win-x64.zip\` |
|
||||
| Linux | \`MediaServer-{tag}-linux-x64.tar.gz\` |
|
||||
''').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)))
|
||||
")
|
||||
@@ -187,6 +191,16 @@ jobs:
|
||||
with:
|
||||
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
|
||||
run: |
|
||||
chmod +x build-dist-linux.sh
|
||||
@@ -226,3 +240,68 @@ jobs:
|
||||
-H "Authorization: token $DEPLOY_TOKEN" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$FILE"
|
||||
|
||||
# --- Build macOS tarball (best-effort; requires a macos runner) ---
|
||||
# PyObjC wheels are macOS-only, so this job must run on a real Mac.
|
||||
#
|
||||
# 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"
|
||||
|
||||
@@ -8,6 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: "!startsWith(github.event.head_commit.message, 'chore: release')"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -33,3 +34,73 @@ jobs:
|
||||
|
||||
- name: Test
|
||||
run: pytest --tb=short -q || test $? -eq 5
|
||||
|
||||
# Linux smoke test: install the linux extra in the same way build-dist-linux.sh
|
||||
# does, then boot the server and hit /api/health. Catches dependency-resolution
|
||||
# and import-time regressions for the Linux distribution path.
|
||||
linux-smoke:
|
||||
if: "!startsWith(github.event.head_commit.message, 'chore: release')"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Build frontend
|
||||
run: npm ci && npm run build
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install Linux system deps for dbus-python + PyGObject
|
||||
run: |
|
||||
# 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
|
||||
'
|
||||
|
||||
@@ -53,3 +53,5 @@ Thumbs.db
|
||||
# Node.js / esbuild
|
||||
node_modules/
|
||||
media_server/static/dist/
|
||||
# Added by code-review-graph
|
||||
.code-review-graph/
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"code-review-graph": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"code-review-graph",
|
||||
"serve"
|
||||
],
|
||||
"type": "stdio"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,3 +196,42 @@ pytest --tb=short -q
|
||||
|
||||
- **ALWAYS ask for user approval before committing and pushing changes.**
|
||||
- When pushing, always push to all remotes: `git push origin master && git push github master`
|
||||
|
||||
<!-- code-review-graph MCP tools -->
|
||||
## MCP Tools: code-review-graph
|
||||
|
||||
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
|
||||
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
|
||||
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
|
||||
you structural context (callers, dependents, test coverage) that file
|
||||
scanning cannot.
|
||||
|
||||
### When to use graph tools FIRST
|
||||
|
||||
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
|
||||
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
|
||||
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
|
||||
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
|
||||
- **Architecture questions**: `get_architecture_overview` + `list_communities`
|
||||
|
||||
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
|
||||
|
||||
### Key Tools
|
||||
|
||||
| Tool | Use when |
|
||||
|------|----------|
|
||||
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
|
||||
| `get_review_context` | Need source snippets for review — token-efficient |
|
||||
| `get_impact_radius` | Understanding blast radius of a change |
|
||||
| `get_affected_flows` | Finding which execution paths are impacted |
|
||||
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
|
||||
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
|
||||
| `get_architecture_overview` | Understanding high-level codebase structure |
|
||||
| `refactor_tool` | Planning renames, finding dead code |
|
||||
|
||||
### Workflow
|
||||
|
||||
1. The graph auto-updates on file changes (via hooks).
|
||||
2. Use `detect_changes` for code review.
|
||||
3. Use `get_affected_flows` to understand impact.
|
||||
4. Use `query_graph` pattern="tests_for" to check coverage.
|
||||
|
||||
@@ -285,37 +285,44 @@ All connected WebSocket clients receive a `links_changed` notification when link
|
||||
|
||||
## Installation
|
||||
|
||||
Dependencies are declared in `pyproject.toml`. Pick the extra that matches
|
||||
your OS — the Python deps differ enough between Windows / Linux / macOS
|
||||
that there's no single `pip install` line.
|
||||
|
||||
### Installing on Windows
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
pip install ".[windows]"
|
||||
```
|
||||
|
||||
Required packages: `winsdk`, `pywin32`, `pycaw`, `comtypes`
|
||||
Pulls in `winsdk`, `pywin32`, `pycaw`, `comtypes`, `pystray`, etc.
|
||||
|
||||
### Installing on Linux
|
||||
|
||||
```bash
|
||||
# Install system dependencies
|
||||
sudo apt-get install python3-dbus python3-gi libdbus-1-dev libglib2.0-dev
|
||||
# System packages required to build dbus-python + PyGObject from sdist.
|
||||
sudo apt-get install -y python3-pip python3-venv \
|
||||
libdbus-1-dev libglib2.0-dev pkg-config
|
||||
|
||||
pip install -r requirements.txt
|
||||
pip install ".[linux]"
|
||||
```
|
||||
|
||||
### Installing on macOS
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
pip install ".[macos]"
|
||||
```
|
||||
|
||||
No additional dependencies - uses built-in `osascript`.
|
||||
Pulls in `pyobjc-framework-Cocoa` + `pyobjc-framework-Quartz` for the
|
||||
foreground-window probe; AppleScript-based media control uses the
|
||||
built-in `osascript`.
|
||||
|
||||
### Installing on Android (Termux)
|
||||
|
||||
```bash
|
||||
# In Termux
|
||||
pkg install python termux-api
|
||||
pip install -r requirements.txt
|
||||
pip install "."
|
||||
```
|
||||
|
||||
Requires Termux and Termux:API apps from F-Droid.
|
||||
@@ -835,11 +842,19 @@ Install:
|
||||
sudo ./service/install_linux.sh install
|
||||
```
|
||||
|
||||
Enable and start for your user:
|
||||
**Enable user lingering** — required so `/run/user/$UID/bus` (the D-Bus
|
||||
session socket needed for MPRIS) exists even when no graphical session
|
||||
is active. Without this the server boots but every `/api/media/*` call
|
||||
silently returns idle.
|
||||
|
||||
```bash
|
||||
sudo systemctl enable media-server@$USER
|
||||
sudo systemctl start media-server@$USER
|
||||
sudo loginctl enable-linger $USER
|
||||
```
|
||||
|
||||
Enable and start the templated unit for your user:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now media-server@$USER
|
||||
```
|
||||
|
||||
View logs:
|
||||
@@ -848,6 +863,39 @@ View logs:
|
||||
journalctl -u media-server@$USER -f
|
||||
```
|
||||
|
||||
**Troubleshooting:**
|
||||
|
||||
- *"`/api/media/status` always returns `idle`"* — check the service log for
|
||||
`D-Bus session bus not available`. Most commonly: lingering isn't
|
||||
enabled, or the unit is using the wrong `XDG_RUNTIME_DIR` (`%U` must
|
||||
expand to the user's numeric UID).
|
||||
- *"Visualizer permanently unavailable"* — PulseAudio/PipeWire must
|
||||
expose monitor sources. `pactl list sources short | grep monitor`
|
||||
should list at least one entry; if not, install `pipewire-pulse` and
|
||||
restart your session.
|
||||
- *"Volume control silently fails"* — `pactl` must be on `PATH` and the
|
||||
user's PulseAudio/PipeWire server must be reachable
|
||||
(`PULSE_RUNTIME_PATH=/run/user/$UID/pulse`).
|
||||
- *"Foreground window is always `null`"* — expected under Wayland; the
|
||||
compositor hides window info from unprivileged clients. X11 sessions
|
||||
work normally.
|
||||
|
||||
### macOS (LaunchAgent)
|
||||
|
||||
The distribution tarball ships an installer:
|
||||
|
||||
```bash
|
||||
./install-launchagent.sh
|
||||
```
|
||||
|
||||
This drops `~/Library/LaunchAgents/com.dolgolyov.media-server.plist`,
|
||||
starts the service immediately, and re-launches it at every login. Logs
|
||||
go to `~/Library/Logs/media-server/{stdout,stderr}.log`. To stop:
|
||||
|
||||
```bash
|
||||
./uninstall-launchagent.sh
|
||||
```
|
||||
|
||||
## Command Line Options
|
||||
|
||||
```text
|
||||
|
||||
+39
-5
@@ -1,8 +1,42 @@
|
||||
## v0.1.5 (2026-04-11)
|
||||
## 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
|
||||
- Make WebSocket token query parameter optional to prevent connection failures for clients that authenticate via other means ([34eb7c7](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/34eb7c7))
|
||||
- Fetch playback status eagerly on new WebSocket connection instead of waiting for the next poll cycle ([d09a0b9](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d09a0b9))
|
||||
|
||||
- **`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,7 +45,7 @@
|
||||
|
||||
| Hash | Message | Author |
|
||||
|------|---------|--------|
|
||||
| [34eb7c7](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/34eb7c7) | fix(ws): make WebSocket token parameter optional | alexei.dolgolyov |
|
||||
| [d09a0b9](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d09a0b9) | fix(ws): fetch status eagerly on new WebSocket connection | 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>
|
||||
|
||||
+5
-2
@@ -91,8 +91,11 @@ cleanup_site_packages() {
|
||||
find "$sp_dir" -name "*.pyi" -delete 2>/dev/null || true
|
||||
rm -rf "$sp_dir"/{pip,setuptools,pkg_resources,_distutils_hack}* 2>/dev/null || true
|
||||
|
||||
# Trim numpy if present
|
||||
for mod in polynomial linalg ma lib distutils f2py typing _pyinstaller; do
|
||||
# Trim numpy if present.
|
||||
# Keep only modules that numpy/__init__.py does NOT import unconditionally —
|
||||
# lib, linalg, ma, polynomial, fft, ctypeslib, matrixlib are all required for
|
||||
# `import numpy` to succeed, so they MUST stay.
|
||||
for mod in distutils f2py typing _pyinstaller; do
|
||||
rm -rf "$sp_dir/numpy/$mod" 2>/dev/null || true
|
||||
done
|
||||
|
||||
|
||||
+54
-1
@@ -16,12 +16,38 @@ BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-linux-x64"
|
||||
clean_dist "${DIST_DIR}" build
|
||||
verify_frontend
|
||||
|
||||
# --- Sanity check: native deps required for dbus-python + PyGObject ---
|
||||
# These compile from sdist on Linux. We don't try to install them via the
|
||||
# distro package manager here (the CI image must already have them); fail
|
||||
# with a clear message instead of cryptic gcc/pkg-config errors deep
|
||||
# inside pip.
|
||||
need_pkg() {
|
||||
local pkg="$1"
|
||||
if ! command -v pkg-config >/dev/null 2>&1; then
|
||||
echo "ERROR: pkg-config is required (apt: pkg-config / dnf: pkgconf-pkg-config)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! pkg-config --exists "$pkg" 2>/dev/null; then
|
||||
echo "ERROR: $pkg headers missing — install libdbus-1-dev libglib2.0-dev (apt)" >&2
|
||||
echo " or dbus-devel glib2-devel (dnf) before running this script." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
need_pkg dbus-1
|
||||
need_pkg glib-2.0
|
||||
|
||||
# --- Create self-contained virtualenv ---
|
||||
echo "Creating virtualenv..."
|
||||
python3 -m venv "${DIST_DIR}/venv"
|
||||
source "${DIST_DIR}/venv/bin/activate"
|
||||
pip install --quiet --upgrade pip
|
||||
pip install --quiet ".[visualizer]"
|
||||
# Install with the linux extra so dbus-python / PyGObject / python-xlib land
|
||||
# in the venv. Falls back to base deps if the extra is unsupported (shouldn't
|
||||
# happen post-0.3.2 but keep the fallback for older tags re-built here).
|
||||
if ! pip install --quiet ".[linux]"; then
|
||||
echo "WARN: '.[linux]' extra unavailable; installing base deps only" >&2
|
||||
pip install --quiet "."
|
||||
fi
|
||||
|
||||
# Remove the installed package (app source is on PYTHONPATH via launcher)
|
||||
rm -rf "${DIST_DIR}"/venv/lib/python*/site-packages/media_server*
|
||||
@@ -47,6 +73,11 @@ LAUNCHER
|
||||
chmod +x "${DIST_DIR}/media-server.sh"
|
||||
|
||||
# --- Create systemd service installer ---
|
||||
# Emits a non-templated unit that runs as the invoking user with the env
|
||||
# bits MPRIS / PulseAudio need: DBUS_SESSION_BUS_ADDRESS and XDG_RUNTIME_DIR
|
||||
# must be set or DBus.SessionBus() either fails or talks to the wrong bus.
|
||||
# We also relax ProtectHome to read-write for ~/.config/media-server +
|
||||
# ~/.cache/media-server so audit.log and thumbnail cache writes succeed.
|
||||
cat > "${DIST_DIR}/install-service.sh" << 'SERVICE'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
@@ -61,6 +92,9 @@ if [ "$EUID" -ne 0 ]; then
|
||||
fi
|
||||
|
||||
REAL_USER="${SUDO_USER:-$USER}"
|
||||
USER_UID="$(id -u "$REAL_USER")"
|
||||
USER_HOME="$(getent passwd "$REAL_USER" | cut -d: -f6)"
|
||||
RUNTIME_DIR="/run/user/${USER_UID}"
|
||||
|
||||
cat > "$SERVICE_FILE" << EOF
|
||||
[Unit]
|
||||
@@ -74,17 +108,36 @@ WorkingDirectory=${SCRIPT_DIR}
|
||||
ExecStart=${SCRIPT_DIR}/media-server.sh
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# Required by MPRIS (dbus.SessionBus) and PulseAudio/PipeWire loopback.
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=HOME=${USER_HOME}
|
||||
Environment=XDG_RUNTIME_DIR=${RUNTIME_DIR}
|
||||
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=${RUNTIME_DIR}/bus
|
||||
|
||||
# Light sandboxing — keep ~/.config/media-server + ~/.cache/media-server
|
||||
# writable so audit.log / thumbnails / config CRUD continue to work.
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=${USER_HOME}/.config/media-server ${USER_HOME}/.cache/media-server
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# Pre-create state dirs so ReadWritePaths actually has something to grant.
|
||||
sudo -u "$REAL_USER" mkdir -p \
|
||||
"${USER_HOME}/.config/media-server" \
|
||||
"${USER_HOME}/.cache/media-server"
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable "${SERVICE_NAME}"
|
||||
systemctl start "${SERVICE_NAME}"
|
||||
echo "Service '${SERVICE_NAME}' installed and started."
|
||||
echo "Check status: systemctl status ${SERVICE_NAME}"
|
||||
echo "Tail logs: journalctl -u ${SERVICE_NAME} -f"
|
||||
SERVICE
|
||||
chmod +x "${DIST_DIR}/install-service.sh"
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -60,12 +60,18 @@ CORE_DEPS=(
|
||||
)
|
||||
|
||||
# Windows-specific dependencies
|
||||
# NOTE: wmi is a transitive dep of screen-brightness-control gated on
|
||||
# `platform_system == "Windows"`. pip evaluates env markers against the HOST
|
||||
# (Linux in CI), so it gets skipped during cross-build. Listed explicitly here
|
||||
# so the wheel actually lands in the Windows bundle. Same gotcha as the
|
||||
# uvicorn[standard]/uvloop case documented above.
|
||||
WIN_DEPS=(
|
||||
"winsdk>=1.0.0b10"
|
||||
"pywin32>=306"
|
||||
"comtypes>=1.2.0"
|
||||
"pycaw>=20230407"
|
||||
"screen-brightness-control>=0.20.0"
|
||||
"wmi>=1.5.1"
|
||||
"monitorcontrol>=3.0.0"
|
||||
)
|
||||
|
||||
@@ -113,6 +119,22 @@ for whl in "$WHEEL_DIR"/*.whl; do
|
||||
unzip -qo "$whl" -d "$SITE_PACKAGES"
|
||||
done
|
||||
|
||||
# numpy wheels from PyPI don't include _distributor_init_local.py unless
|
||||
# patched by delvewheel. In embedded Python, os.add_dll_directory() is never
|
||||
# called, so libopenblas can't be found and numpy fails to import.
|
||||
# Generate the missing loader here instead.
|
||||
if [ -d "${SITE_PACKAGES}/numpy" ]; then
|
||||
cat > "${SITE_PACKAGES}/numpy/_distributor_init_local.py" << 'EOF'
|
||||
import os
|
||||
import sys
|
||||
if sys.platform == 'win32':
|
||||
_libs = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'numpy.libs'))
|
||||
if os.path.isdir(_libs):
|
||||
os.add_dll_directory(_libs)
|
||||
EOF
|
||||
echo "Generated numpy/_distributor_init_local.py"
|
||||
fi
|
||||
|
||||
cleanup_site_packages "$SITE_PACKAGES" "pyd" "dll"
|
||||
verify_frontend
|
||||
copy_app_files "$DIST_DIR"
|
||||
|
||||
+96
-58
@@ -1,7 +1,13 @@
|
||||
# Media Server Configuration
|
||||
# Copy this file to config.yaml and customize as needed.
|
||||
# By default, authentication is DISABLED (no tokens = open access).
|
||||
# To enable auth, uncomment and configure the api_tokens section below.
|
||||
#
|
||||
# Secure-by-default: the server binds to loopback (127.0.0.1) only and refuses
|
||||
# to bind a non-loopback address with no tokens configured.
|
||||
#
|
||||
# To expose on the LAN you must do ONE of:
|
||||
# 1. Configure api_tokens below AND change host to "0.0.0.0", OR
|
||||
# 2. Set `allow_lan_without_auth: true` (LAN-open, no auth — insecure on
|
||||
# hostile networks, only acceptable on a trusted home LAN).
|
||||
|
||||
# API Tokens - Multiple tokens with friendly labels
|
||||
# This allows you to identify which client is making requests in the logs
|
||||
@@ -11,112 +17,144 @@
|
||||
# web_ui: "your-web-ui-token-here"
|
||||
|
||||
# Server settings
|
||||
host: "0.0.0.0"
|
||||
host: "127.0.0.1"
|
||||
port: 8765
|
||||
# allow_lan_without_auth: true # uncomment + change host to 0.0.0.0 for LAN-open mode
|
||||
|
||||
# ─── Custom scripts ─────────────────────────────────────────────────────
|
||||
#
|
||||
# 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.
|
||||
|
||||
# Custom scripts
|
||||
scripts:
|
||||
lock_screen:
|
||||
command: "rundll32.exe user32.dll,LockWorkStation"
|
||||
label: "Lock Screen"
|
||||
description: "Lock the workstation"
|
||||
icon: "mdi:lock"
|
||||
timeout: 5
|
||||
shell: true
|
||||
# ── Windows ─────────────────────────────────────────────────────────
|
||||
# lock_screen:
|
||||
# command: "rundll32.exe user32.dll,LockWorkStation"
|
||||
# label: "Lock Screen"
|
||||
# description: "Lock the workstation"
|
||||
# icon: "mdi:lock"
|
||||
# timeout: 5
|
||||
# shell: true
|
||||
# sleep:
|
||||
# command: "rundll32.exe powrprof.dll,SetSuspendState 0,1,0"
|
||||
# label: "Sleep"
|
||||
# icon: "mdi:sleep"
|
||||
# timeout: 10
|
||||
# shell: true
|
||||
# shutdown:
|
||||
# command: "shutdown /s /t 0"
|
||||
# label: "Shutdown"
|
||||
# icon: "mdi:power"
|
||||
# timeout: 10
|
||||
# shell: true
|
||||
|
||||
hibernate:
|
||||
command: "shutdown /h"
|
||||
label: "Hibernate"
|
||||
description: "Hibernate the PC"
|
||||
icon: "mdi:power-sleep"
|
||||
# ── Linux (systemd / xdg) ───────────────────────────────────────────
|
||||
# lock_screen:
|
||||
# command: "loginctl lock-session" # or: xdg-screensaver lock
|
||||
# label: "Lock Screen"
|
||||
# icon: "mdi:lock"
|
||||
# timeout: 5
|
||||
# shell: true
|
||||
# sleep:
|
||||
# command: "systemctl suspend"
|
||||
# label: "Sleep"
|
||||
# icon: "mdi:sleep"
|
||||
# timeout: 10
|
||||
# shell: true
|
||||
# shutdown:
|
||||
# command: "systemctl poweroff"
|
||||
# label: "Shutdown"
|
||||
# icon: "mdi:power"
|
||||
# timeout: 10
|
||||
# shell: true
|
||||
|
||||
# ── macOS ───────────────────────────────────────────────────────────
|
||||
# lock_screen:
|
||||
# command: "pmset displaysleepnow"
|
||||
# label: "Lock Screen"
|
||||
# icon: "mdi:lock"
|
||||
# timeout: 5
|
||||
# shell: true
|
||||
# sleep:
|
||||
# command: "pmset sleepnow"
|
||||
# label: "Sleep"
|
||||
# icon: "mdi:sleep"
|
||||
# timeout: 5
|
||||
# shell: true
|
||||
# shutdown:
|
||||
# command: "osascript -e 'tell application \"System Events\" to shut down'"
|
||||
# label: "Shutdown"
|
||||
# icon: "mdi:power"
|
||||
# timeout: 10
|
||||
# shell: true
|
||||
|
||||
# Cross-platform smoke test (always defined so first-run users see the UI populate).
|
||||
example_script:
|
||||
command: "echo Hello from Media Server!"
|
||||
description: "Example script - echoes a message"
|
||||
timeout: 10
|
||||
shell: true
|
||||
|
||||
sleep:
|
||||
command: "rundll32.exe powrprof.dll,SetSuspendState 0,1,0"
|
||||
label: "Sleep"
|
||||
description: "Put PC to sleep"
|
||||
icon: "mdi:sleep"
|
||||
timeout: 10
|
||||
shell: true
|
||||
|
||||
shutdown:
|
||||
command: "shutdown /s /t 0"
|
||||
label: "Shutdown"
|
||||
description: "Shutdown the PC immediately"
|
||||
icon: "mdi:power"
|
||||
timeout: 10
|
||||
shell: true
|
||||
|
||||
restart:
|
||||
command: "shutdown /r /t 0"
|
||||
label: "Restart"
|
||||
description: "Restart the PC immediately"
|
||||
icon: "mdi:restart"
|
||||
timeout: 10
|
||||
shell: true
|
||||
|
||||
# Media folder management from Web UI (default: true)
|
||||
# Media folder management from Web UI (default: false)
|
||||
# When enabled, media folders can be added, edited, and deleted from the Settings tab.
|
||||
# Set to false to disable folder management from the UI.
|
||||
# media_folders_management: false
|
||||
# media_folders_management: true
|
||||
|
||||
# ─── Callback scripts (executed by integration events) ──────────────────
|
||||
#
|
||||
# Callbacks are OS-agnostic — if you want side effects, fill in commands
|
||||
# below. The defaults are no-op echos so first-run logs don't error out.
|
||||
|
||||
# Callback scripts (executed after media actions)
|
||||
# All callbacks are optional - if not defined, the action runs without callback
|
||||
callbacks:
|
||||
# Media control callbacks (run after successful action)
|
||||
on_play:
|
||||
command: "echo Play triggered"
|
||||
timeout: 10
|
||||
shell: true
|
||||
|
||||
on_pause:
|
||||
command: "echo Pause triggered"
|
||||
timeout: 10
|
||||
shell: true
|
||||
|
||||
on_stop:
|
||||
command: "echo Stop triggered"
|
||||
timeout: 10
|
||||
shell: true
|
||||
|
||||
on_next:
|
||||
command: "echo Next track"
|
||||
timeout: 10
|
||||
shell: true
|
||||
|
||||
on_previous:
|
||||
command: "echo Previous track"
|
||||
timeout: 10
|
||||
shell: true
|
||||
|
||||
on_volume:
|
||||
command: "echo Volume changed"
|
||||
timeout: 10
|
||||
shell: true
|
||||
|
||||
on_mute:
|
||||
command: "echo Mute toggled"
|
||||
timeout: 10
|
||||
shell: true
|
||||
|
||||
on_seek:
|
||||
command: "echo Seek triggered"
|
||||
timeout: 10
|
||||
shell: true
|
||||
|
||||
# Turn on/off/toggle (callback-only actions, no default behavior)
|
||||
# Turn on/off/toggle (callback-only actions, no default behavior).
|
||||
# The Windows-only example used to ship as the default for `on_turn_off`,
|
||||
# which silently failed on Linux/macOS. Defaults are now no-ops — pick a
|
||||
# platform-appropriate command below.
|
||||
on_turn_on:
|
||||
command: "echo Turn on callback"
|
||||
timeout: 10
|
||||
shell: true
|
||||
|
||||
on_turn_off:
|
||||
command: "rundll32.exe user32.dll,LockWorkStation"
|
||||
# Windows: "rundll32.exe user32.dll,LockWorkStation"
|
||||
# Linux: "loginctl lock-session"
|
||||
# macOS: "pmset displaysleepnow"
|
||||
command: "echo Turn off callback"
|
||||
timeout: 5
|
||||
shell: true
|
||||
|
||||
on_toggle:
|
||||
command: "echo Toggle callback"
|
||||
timeout: 10
|
||||
shell: true
|
||||
shell: true
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
"""Media Server - REST API for controlling system media playback."""
|
||||
|
||||
import re
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from pathlib import Path
|
||||
|
||||
_VERSION_RE = re.compile(r'^version\s*=\s*"([^"]+)"', re.MULTILINE)
|
||||
|
||||
|
||||
def _detect_version() -> str:
|
||||
# 1. Package metadata (works when pip-installed in dev)
|
||||
# 1. Live pyproject.toml — only present in dev checkouts. Prefer this
|
||||
# over installed package metadata so `pip install -e .` users don't
|
||||
# see stale versions after editing pyproject.toml without reinstalling.
|
||||
pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
||||
if pyproject.is_file():
|
||||
try:
|
||||
match = _VERSION_RE.search(pyproject.read_text(encoding="utf-8"))
|
||||
if match:
|
||||
return match.group(1)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# 2. Package metadata (works for any pip-installed copy).
|
||||
try:
|
||||
return version("media-server")
|
||||
except PackageNotFoundError:
|
||||
pass
|
||||
|
||||
# 2. VERSION file written by build scripts (production builds)
|
||||
# Located at install root, two levels up from this package
|
||||
# 3. VERSION file written by build scripts (production builds).
|
||||
version_file = Path(__file__).resolve().parent.parent.parent / "VERSION"
|
||||
if version_file.is_file():
|
||||
return version_file.read_text().strip()
|
||||
|
||||
+34
-2
@@ -13,6 +13,8 @@ security = HTTPBearer(auto_error=False)
|
||||
|
||||
# Context variable to store current request's token label
|
||||
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:
|
||||
@@ -29,12 +31,42 @@ def get_token_label(token: str) -> Optional[str]:
|
||||
Returns:
|
||||
The label for the token, or None if invalid
|
||||
"""
|
||||
for label, stored_token in settings.api_tokens.items():
|
||||
if secrets.compare_digest(stored_token, token):
|
||||
for label, spec in settings.api_tokens.items():
|
||||
if secrets.compare_digest(spec.token, token):
|
||||
return label
|
||||
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(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
|
||||
+223
-30
@@ -1,13 +1,54 @@
|
||||
"""Configuration management for the media server."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
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):
|
||||
"""Configuration for a media folder."""
|
||||
@@ -44,6 +85,13 @@ class ScriptParameterConfig(BaseModel):
|
||||
options: Optional[list[str]] = Field(
|
||||
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):
|
||||
@@ -81,14 +129,106 @@ class Settings(BaseSettings):
|
||||
)
|
||||
|
||||
# Server settings
|
||||
host: str = Field(default="0.0.0.0", description="Server bind address")
|
||||
port: int = Field(default=8765, description="Server port")
|
||||
|
||||
# Authentication (empty = auth disabled, anyone can access the API)
|
||||
api_tokens: dict[str, str] = Field(
|
||||
default_factory=dict,
|
||||
description="Named API tokens for access control (label: token pairs). Empty = no auth.",
|
||||
host: str = Field(
|
||||
default="127.0.0.1",
|
||||
description=(
|
||||
"Server bind address. Use 127.0.0.1 for loopback-only (default, safest),"
|
||||
" or 0.0.0.0 to expose on the LAN (requires api_tokens to be set)."
|
||||
),
|
||||
)
|
||||
port: int = Field(default=8765, description="Server port")
|
||||
allow_lan_without_auth: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Allow binding to a non-loopback address with no api_tokens configured."
|
||||
" Off by default to prevent unauthenticated LAN exposure."
|
||||
),
|
||||
)
|
||||
cors_origins: list[str] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"Allowed CORS origins. Empty (default) means only same-origin requests"
|
||||
" from http://localhost:<port> and http://127.0.0.1:<port>."
|
||||
),
|
||||
)
|
||||
|
||||
# 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).
|
||||
# When True the same token used for read/play can also persist arbitrary shell
|
||||
# commands. Default False so a single leaked token cannot escalate to RCE; opt
|
||||
# in explicitly to manage scripts/callbacks/links via the Web UI.
|
||||
scripts_management: bool = Field(default=False, description="Allow scripts 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).
|
||||
#
|
||||
# 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,
|
||||
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
|
||||
poll_interval: float = Field(
|
||||
@@ -125,7 +265,7 @@ class Settings(BaseSettings):
|
||||
description="Media folders available for browsing in the media browser",
|
||||
)
|
||||
media_folders_management: bool = Field(
|
||||
default=True,
|
||||
default=False,
|
||||
description="Allow adding, editing, and deleting media folders from the Web UI",
|
||||
)
|
||||
|
||||
@@ -218,21 +358,28 @@ def get_config_dir() -> Path:
|
||||
|
||||
|
||||
def generate_default_config(path: Optional[Path] = None) -> Path:
|
||||
"""Generate a default configuration file with a new API token."""
|
||||
"""Generate a default configuration file with a freshly generated API token.
|
||||
|
||||
The token is written into ``api_tokens.default`` and printed to the logger
|
||||
so first-run users can copy it. Subsequent runs preserve whatever the user
|
||||
has set.
|
||||
"""
|
||||
if path is None:
|
||||
path = get_config_dir() / "config.yaml"
|
||||
|
||||
default_token = secrets.token_urlsafe(32)
|
||||
|
||||
config = {
|
||||
"host": "0.0.0.0",
|
||||
"host": "127.0.0.1",
|
||||
"port": 8765,
|
||||
# "api_tokens": {
|
||||
# "default": "your-secret-token-here",
|
||||
# },
|
||||
# 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": {
|
||||
"default": {"token": default_token, "scopes": ["admin"]},
|
||||
},
|
||||
"poll_interval": 1.0,
|
||||
"log_level": "INFO",
|
||||
# Audio device to control (use GET /api/audio/devices to list available devices)
|
||||
# Set to null or remove to use default device
|
||||
# "audio_device": "Speakers (Realtek",
|
||||
"scripts": {
|
||||
"example_script": {
|
||||
"command": "echo Hello from Media Server!",
|
||||
@@ -240,26 +387,72 @@ def generate_default_config(path: Optional[Path] = None) -> Path:
|
||||
"timeout": 10,
|
||||
"shell": True,
|
||||
},
|
||||
# Add your custom scripts here:
|
||||
# "shutdown": {
|
||||
# "command": "shutdown /s /t 60",
|
||||
# "description": "Shutdown computer in 60 seconds",
|
||||
# "timeout": 5,
|
||||
# },
|
||||
# "lock_screen": {
|
||||
# "command": "rundll32.exe user32.dll,LockWorkStation",
|
||||
# "description": "Lock the workstation",
|
||||
# "timeout": 5,
|
||||
# },
|
||||
},
|
||||
}
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
||||
_write_yaml_atomic(path, config)
|
||||
_restrict_config_perms(path)
|
||||
|
||||
logger.info("Generated default config at %s", path)
|
||||
logger.info("API token (label=default): %s", default_token)
|
||||
|
||||
return path
|
||||
|
||||
|
||||
def _write_yaml_atomic(path: Path, data: dict) -> None:
|
||||
"""Write YAML to disk atomically via tmp file + rename, with restricted perms."""
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
_restrict_config_perms(tmp)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def _restrict_config_perms(path: Path) -> None:
|
||||
"""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":
|
||||
_restrict_config_perms_windows(path)
|
||||
return
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
os.chmod(path.parent, 0o700)
|
||||
except OSError:
|
||||
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
|
||||
settings = Settings.load_from_yaml()
|
||||
|
||||
+147
-402
@@ -1,52 +1,50 @@
|
||||
"""Thread-safe configuration file manager for runtime script updates."""
|
||||
"""Thread-safe configuration file manager for runtime updates."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from .config import CallbackConfig, LinkConfig, MediaFolderConfig, ScriptConfig, settings
|
||||
from .config import (
|
||||
CallbackConfig,
|
||||
LinkConfig,
|
||||
MediaFolderConfig,
|
||||
ScriptConfig,
|
||||
_restrict_config_perms,
|
||||
_write_yaml_atomic,
|
||||
settings,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
"""Thread-safe configuration file manager."""
|
||||
"""Thread-safe configuration file manager.
|
||||
|
||||
All writes go through ``_save()`` which writes to ``config.yaml.tmp`` and
|
||||
then ``os.replace()``s it into place so a crash mid-write cannot corrupt
|
||||
the only persistent user data. On POSIX the file is also chmodded to 0600
|
||||
so co-tenant users cannot read the API token.
|
||||
"""
|
||||
|
||||
def __init__(self, config_path: Optional[Path] = None):
|
||||
"""Initialize the config manager.
|
||||
|
||||
Args:
|
||||
config_path: Path to config file. If None, will search standard locations.
|
||||
"""
|
||||
self._lock = threading.Lock()
|
||||
self._config_path = config_path or self._find_config_path()
|
||||
logger.info(f"ConfigManager initialized with path: {self._config_path}")
|
||||
|
||||
def _find_config_path(self) -> Path:
|
||||
"""Find the active config file path.
|
||||
@staticmethod
|
||||
def _find_config_path() -> Path:
|
||||
"""Find the active config file path (or the default if none exists yet)."""
|
||||
search_paths = [Path("config.yaml"), Path("config.yml")]
|
||||
|
||||
Returns:
|
||||
Path to the config file.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If no config file is found.
|
||||
"""
|
||||
# Same search logic as Settings.load_from_yaml()
|
||||
search_paths = [
|
||||
Path("config.yaml"),
|
||||
Path("config.yml"),
|
||||
]
|
||||
|
||||
# Add platform-specific config directory
|
||||
if os.name == "nt": # Windows
|
||||
if os.name == "nt":
|
||||
appdata = os.environ.get("APPDATA", "")
|
||||
if appdata:
|
||||
search_paths.append(Path(appdata) / "media-server" / "config.yaml")
|
||||
else: # Linux/Unix/macOS
|
||||
else:
|
||||
search_paths.append(Path.home() / ".config" / "media-server" / "config.yaml")
|
||||
search_paths.append(Path("/etc/media-server/config.yaml"))
|
||||
|
||||
@@ -54,7 +52,6 @@ class ConfigManager:
|
||||
if search_path.exists():
|
||||
return search_path
|
||||
|
||||
# If not found, use the default location
|
||||
if os.name == "nt":
|
||||
default_path = Path(os.environ.get("APPDATA", "")) / "media-server" / "config.yaml"
|
||||
else:
|
||||
@@ -63,422 +60,170 @@ class ConfigManager:
|
||||
logger.warning(f"No config file found, using default path: {default_path}")
|
||||
return default_path
|
||||
|
||||
def add_script(self, name: str, config: ScriptConfig) -> None:
|
||||
"""Add a new script to config.
|
||||
def _load(self) -> dict[str, Any]:
|
||||
"""Read the config YAML, returning an empty dict if the file is missing."""
|
||||
if not self._config_path.exists():
|
||||
return {}
|
||||
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
|
||||
Args:
|
||||
name: Script name (must be unique).
|
||||
config: Script configuration.
|
||||
def _save(self, data: dict[str, Any]) -> None:
|
||||
"""Atomically write the config YAML and lock down its permissions."""
|
||||
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
_write_yaml_atomic(self._config_path, data)
|
||||
_restrict_config_perms(self._config_path)
|
||||
|
||||
Raises:
|
||||
ValueError: If script already exists.
|
||||
IOError: If config file cannot be written.
|
||||
"""
|
||||
# --- Generic per-section CRUD --------------------------------------
|
||||
|
||||
def _upsert(
|
||||
self,
|
||||
section: str,
|
||||
key: str,
|
||||
value: Any,
|
||||
*,
|
||||
require_absent: bool = False,
|
||||
require_present: bool = False,
|
||||
in_memory_target: dict[str, Any] | None = None,
|
||||
verb: str = "set",
|
||||
) -> None:
|
||||
with self._lock:
|
||||
# Read YAML
|
||||
if not self._config_path.exists():
|
||||
data = {}
|
||||
else:
|
||||
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
data = self._load()
|
||||
existing = data.get(section, {})
|
||||
if require_absent and key in existing:
|
||||
raise ValueError(f"{section[:-1].title()} '{key}' already exists")
|
||||
if require_present and (not isinstance(existing, dict) or key not in existing):
|
||||
raise ValueError(f"{section[:-1].title()} '{key}' does not exist")
|
||||
|
||||
# Check if script already exists
|
||||
if "scripts" in data and name in data["scripts"]:
|
||||
raise ValueError(f"Script '{name}' already exists")
|
||||
if not isinstance(existing, dict):
|
||||
existing = {}
|
||||
existing[key] = value.model_dump(exclude_none=True)
|
||||
data[section] = existing
|
||||
|
||||
# Add script
|
||||
if "scripts" not in data:
|
||||
data["scripts"] = {}
|
||||
data["scripts"][name] = config.model_dump(exclude_none=True)
|
||||
self._save(data)
|
||||
|
||||
# Write YAML
|
||||
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
if in_memory_target is not None:
|
||||
in_memory_target[key] = value
|
||||
logger.info(f"{section[:-1].title()} '{key}' {verb} in config")
|
||||
|
||||
# Update in-memory settings
|
||||
settings.scripts[name] = config
|
||||
def _delete(
|
||||
self,
|
||||
section: str,
|
||||
key: str,
|
||||
*,
|
||||
in_memory_target: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
data = self._load()
|
||||
existing = data.get(section, {})
|
||||
if not isinstance(existing, dict) or key not in existing:
|
||||
raise ValueError(f"{section[:-1].title()} '{key}' does not exist")
|
||||
del existing[key]
|
||||
data[section] = existing
|
||||
|
||||
logger.info(f"Script '{name}' added to config")
|
||||
self._save(data)
|
||||
|
||||
if in_memory_target is not None and key in in_memory_target:
|
||||
del in_memory_target[key]
|
||||
logger.info(f"{section[:-1].title()} '{key}' deleted from config")
|
||||
|
||||
# --- Scripts -------------------------------------------------------
|
||||
|
||||
def add_script(self, name: str, config: ScriptConfig) -> None:
|
||||
self._upsert(
|
||||
"scripts", name, config,
|
||||
require_absent=True,
|
||||
in_memory_target=settings.scripts,
|
||||
verb="added",
|
||||
)
|
||||
|
||||
def update_script(self, name: str, config: ScriptConfig) -> None:
|
||||
"""Update an existing script.
|
||||
|
||||
Args:
|
||||
name: Script name.
|
||||
config: New script configuration.
|
||||
|
||||
Raises:
|
||||
ValueError: If script does not exist.
|
||||
IOError: If config file cannot be written.
|
||||
"""
|
||||
with self._lock:
|
||||
# Read YAML
|
||||
if not self._config_path.exists():
|
||||
raise ValueError(f"Config file not found: {self._config_path}")
|
||||
|
||||
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
# Check if script exists
|
||||
if "scripts" not in data or name not in data["scripts"]:
|
||||
raise ValueError(f"Script '{name}' does not exist")
|
||||
|
||||
# Update script
|
||||
data["scripts"][name] = config.model_dump(exclude_none=True)
|
||||
|
||||
# Write YAML
|
||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
# Update in-memory settings
|
||||
settings.scripts[name] = config
|
||||
|
||||
logger.info(f"Script '{name}' updated in config")
|
||||
self._upsert(
|
||||
"scripts", name, config,
|
||||
require_present=True,
|
||||
in_memory_target=settings.scripts,
|
||||
verb="updated",
|
||||
)
|
||||
|
||||
def delete_script(self, name: str) -> None:
|
||||
"""Delete a script from config.
|
||||
self._delete("scripts", name, in_memory_target=settings.scripts)
|
||||
|
||||
Args:
|
||||
name: Script name.
|
||||
|
||||
Raises:
|
||||
ValueError: If script does not exist.
|
||||
IOError: If config file cannot be written.
|
||||
"""
|
||||
with self._lock:
|
||||
# Read YAML
|
||||
if not self._config_path.exists():
|
||||
raise ValueError(f"Config file not found: {self._config_path}")
|
||||
|
||||
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
# Check if script exists
|
||||
if "scripts" not in data or name not in data["scripts"]:
|
||||
raise ValueError(f"Script '{name}' does not exist")
|
||||
|
||||
# Delete script
|
||||
del data["scripts"][name]
|
||||
|
||||
# Write YAML
|
||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
# Update in-memory settings
|
||||
if name in settings.scripts:
|
||||
del settings.scripts[name]
|
||||
|
||||
logger.info(f"Script '{name}' deleted from config")
|
||||
# --- Callbacks -----------------------------------------------------
|
||||
|
||||
def add_callback(self, name: str, config: CallbackConfig) -> None:
|
||||
"""Add a new callback to config.
|
||||
|
||||
Args:
|
||||
name: Callback name (must be unique).
|
||||
config: Callback configuration.
|
||||
|
||||
Raises:
|
||||
ValueError: If callback already exists.
|
||||
IOError: If config file cannot be written.
|
||||
"""
|
||||
with self._lock:
|
||||
# Read YAML
|
||||
if not self._config_path.exists():
|
||||
data = {}
|
||||
else:
|
||||
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
# Check if callback already exists
|
||||
if "callbacks" in data and name in data["callbacks"]:
|
||||
raise ValueError(f"Callback '{name}' already exists")
|
||||
|
||||
# Add callback
|
||||
if "callbacks" not in data:
|
||||
data["callbacks"] = {}
|
||||
data["callbacks"][name] = config.model_dump(exclude_none=True)
|
||||
|
||||
# Write YAML
|
||||
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
# Update in-memory settings
|
||||
settings.callbacks[name] = config
|
||||
|
||||
logger.info(f"Callback '{name}' added to config")
|
||||
self._upsert(
|
||||
"callbacks", name, config,
|
||||
require_absent=True,
|
||||
in_memory_target=settings.callbacks,
|
||||
verb="added",
|
||||
)
|
||||
|
||||
def update_callback(self, name: str, config: CallbackConfig) -> None:
|
||||
"""Update an existing callback.
|
||||
|
||||
Args:
|
||||
name: Callback name.
|
||||
config: New callback configuration.
|
||||
|
||||
Raises:
|
||||
ValueError: If callback does not exist.
|
||||
IOError: If config file cannot be written.
|
||||
"""
|
||||
with self._lock:
|
||||
# Read YAML
|
||||
if not self._config_path.exists():
|
||||
raise ValueError(f"Config file not found: {self._config_path}")
|
||||
|
||||
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
# Check if callback exists
|
||||
if "callbacks" not in data or name not in data["callbacks"]:
|
||||
raise ValueError(f"Callback '{name}' does not exist")
|
||||
|
||||
# Update callback
|
||||
data["callbacks"][name] = config.model_dump(exclude_none=True)
|
||||
|
||||
# Write YAML
|
||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
# Update in-memory settings
|
||||
settings.callbacks[name] = config
|
||||
|
||||
logger.info(f"Callback '{name}' updated in config")
|
||||
self._upsert(
|
||||
"callbacks", name, config,
|
||||
require_present=True,
|
||||
in_memory_target=settings.callbacks,
|
||||
verb="updated",
|
||||
)
|
||||
|
||||
def delete_callback(self, name: str) -> None:
|
||||
"""Delete a callback from config.
|
||||
self._delete("callbacks", name, in_memory_target=settings.callbacks)
|
||||
|
||||
Args:
|
||||
name: Callback name.
|
||||
|
||||
Raises:
|
||||
ValueError: If callback does not exist.
|
||||
IOError: If config file cannot be written.
|
||||
"""
|
||||
with self._lock:
|
||||
# Read YAML
|
||||
if not self._config_path.exists():
|
||||
raise ValueError(f"Config file not found: {self._config_path}")
|
||||
|
||||
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
# Check if callback exists
|
||||
if "callbacks" not in data or name not in data["callbacks"]:
|
||||
raise ValueError(f"Callback '{name}' does not exist")
|
||||
|
||||
# Delete callback
|
||||
del data["callbacks"][name]
|
||||
|
||||
# Write YAML
|
||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
# Update in-memory settings
|
||||
if name in settings.callbacks:
|
||||
del settings.callbacks[name]
|
||||
|
||||
logger.info(f"Callback '{name}' deleted from config")
|
||||
# --- Media folders -------------------------------------------------
|
||||
|
||||
def add_media_folder(self, folder_id: str, config: MediaFolderConfig) -> None:
|
||||
"""Add a new media folder to config.
|
||||
|
||||
Args:
|
||||
folder_id: Folder ID (must be unique).
|
||||
config: Media folder configuration.
|
||||
|
||||
Raises:
|
||||
ValueError: If folder already exists.
|
||||
IOError: If config file cannot be written.
|
||||
"""
|
||||
with self._lock:
|
||||
# Read YAML
|
||||
if not self._config_path.exists():
|
||||
data = {}
|
||||
else:
|
||||
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
# Check if folder already exists
|
||||
if "media_folders" in data and folder_id in data["media_folders"]:
|
||||
raise ValueError(f"Media folder '{folder_id}' already exists")
|
||||
|
||||
# Add folder
|
||||
if "media_folders" not in data:
|
||||
data["media_folders"] = {}
|
||||
data["media_folders"][folder_id] = config.model_dump(exclude_none=True)
|
||||
|
||||
# Write YAML
|
||||
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
# Update in-memory settings
|
||||
settings.media_folders[folder_id] = config
|
||||
|
||||
logger.info(f"Media folder '{folder_id}' added to config")
|
||||
self._upsert(
|
||||
"media_folders", folder_id, config,
|
||||
require_absent=True,
|
||||
in_memory_target=settings.media_folders,
|
||||
verb="added",
|
||||
)
|
||||
|
||||
def update_media_folder(self, folder_id: str, config: MediaFolderConfig) -> None:
|
||||
"""Update an existing media folder.
|
||||
|
||||
Args:
|
||||
folder_id: Folder ID.
|
||||
config: New media folder configuration.
|
||||
|
||||
Raises:
|
||||
ValueError: If folder does not exist.
|
||||
IOError: If config file cannot be written.
|
||||
"""
|
||||
with self._lock:
|
||||
# Read YAML
|
||||
if not self._config_path.exists():
|
||||
raise ValueError(f"Config file not found: {self._config_path}")
|
||||
|
||||
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
# Check if folder exists
|
||||
if "media_folders" not in data or folder_id not in data["media_folders"]:
|
||||
raise ValueError(f"Media folder '{folder_id}' does not exist")
|
||||
|
||||
# Update folder
|
||||
data["media_folders"][folder_id] = config.model_dump(exclude_none=True)
|
||||
|
||||
# Write YAML
|
||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
# Update in-memory settings
|
||||
settings.media_folders[folder_id] = config
|
||||
|
||||
logger.info(f"Media folder '{folder_id}' updated in config")
|
||||
self._upsert(
|
||||
"media_folders", folder_id, config,
|
||||
require_present=True,
|
||||
in_memory_target=settings.media_folders,
|
||||
verb="updated",
|
||||
)
|
||||
|
||||
def delete_media_folder(self, folder_id: str) -> None:
|
||||
"""Delete a media folder from config.
|
||||
self._delete("media_folders", folder_id, in_memory_target=settings.media_folders)
|
||||
|
||||
Args:
|
||||
folder_id: Folder ID.
|
||||
|
||||
Raises:
|
||||
ValueError: If folder does not exist.
|
||||
IOError: If config file cannot be written.
|
||||
"""
|
||||
with self._lock:
|
||||
# Read YAML
|
||||
if not self._config_path.exists():
|
||||
raise ValueError(f"Config file not found: {self._config_path}")
|
||||
|
||||
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
# Check if folder exists
|
||||
if "media_folders" not in data or folder_id not in data["media_folders"]:
|
||||
raise ValueError(f"Media folder '{folder_id}' does not exist")
|
||||
|
||||
# Delete folder
|
||||
del data["media_folders"][folder_id]
|
||||
|
||||
# Write YAML
|
||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
# Update in-memory settings
|
||||
if folder_id in settings.media_folders:
|
||||
del settings.media_folders[folder_id]
|
||||
|
||||
logger.info(f"Media folder '{folder_id}' deleted from config")
|
||||
# --- Links ---------------------------------------------------------
|
||||
|
||||
def add_link(self, name: str, config: LinkConfig) -> None:
|
||||
"""Add a new link to config."""
|
||||
with self._lock:
|
||||
if not self._config_path.exists():
|
||||
data = {}
|
||||
else:
|
||||
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
if "links" in data and name in data["links"]:
|
||||
raise ValueError(f"Link '{name}' already exists")
|
||||
|
||||
if "links" not in data:
|
||||
data["links"] = {}
|
||||
data["links"][name] = config.model_dump(exclude_none=True)
|
||||
|
||||
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
settings.links[name] = config
|
||||
logger.info(f"Link '{name}' added to config")
|
||||
self._upsert(
|
||||
"links", name, config,
|
||||
require_absent=True,
|
||||
in_memory_target=settings.links,
|
||||
verb="added",
|
||||
)
|
||||
|
||||
def update_link(self, name: str, config: LinkConfig) -> None:
|
||||
"""Update an existing link."""
|
||||
with self._lock:
|
||||
if not self._config_path.exists():
|
||||
raise ValueError(f"Config file not found: {self._config_path}")
|
||||
|
||||
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
if "links" not in data or name not in data["links"]:
|
||||
raise ValueError(f"Link '{name}' does not exist")
|
||||
|
||||
data["links"][name] = config.model_dump(exclude_none=True)
|
||||
|
||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
settings.links[name] = config
|
||||
logger.info(f"Link '{name}' updated in config")
|
||||
self._upsert(
|
||||
"links", name, config,
|
||||
require_present=True,
|
||||
in_memory_target=settings.links,
|
||||
verb="updated",
|
||||
)
|
||||
|
||||
def delete_link(self, name: str) -> None:
|
||||
"""Delete a link from config."""
|
||||
self._delete("links", name, in_memory_target=settings.links)
|
||||
|
||||
# --- Top-level settings --------------------------------------------
|
||||
|
||||
def set_setting(self, key: str, value: Any) -> None:
|
||||
"""Set a top-level config setting and persist to YAML."""
|
||||
with self._lock:
|
||||
if not self._config_path.exists():
|
||||
raise ValueError(f"Config file not found: {self._config_path}")
|
||||
|
||||
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
if "links" not in data or name not in data["links"]:
|
||||
raise ValueError(f"Link '{name}' does not exist")
|
||||
|
||||
del data["links"][name]
|
||||
|
||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
if name in settings.links:
|
||||
del settings.links[name]
|
||||
logger.info(f"Link '{name}' deleted from config")
|
||||
|
||||
def set_setting(self, key: str, value) -> None:
|
||||
"""Set a top-level config setting and persist to YAML.
|
||||
|
||||
Args:
|
||||
key: Setting name (e.g., "visualizer_device").
|
||||
value: Setting value (None removes the key).
|
||||
"""
|
||||
with self._lock:
|
||||
if not self._config_path.exists():
|
||||
data = {}
|
||||
else:
|
||||
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
data = self._load()
|
||||
if value is None:
|
||||
data.pop(key, None)
|
||||
else:
|
||||
data[key] = value
|
||||
|
||||
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
|
||||
# Update in-memory settings
|
||||
self._save(data)
|
||||
if hasattr(settings, key):
|
||||
setattr(settings, key, value)
|
||||
logger.info("Setting '%s' updated to: %s", key, value)
|
||||
|
||||
|
||||
# Global config manager instance
|
||||
config_manager = ConfigManager()
|
||||
|
||||
+356
-78
@@ -15,13 +15,14 @@ from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
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 .routes import (
|
||||
audio_router,
|
||||
browser_router,
|
||||
callbacks_router,
|
||||
display_router,
|
||||
foreground_router,
|
||||
health_router,
|
||||
links_router,
|
||||
media_router,
|
||||
@@ -32,10 +33,34 @@ from .services.websocket_manager import ws_manager
|
||||
|
||||
|
||||
class TokenLabelFilter(logging.Filter):
|
||||
"""Add token label to log records."""
|
||||
"""Add token label + request_id to log records."""
|
||||
|
||||
def filter(self, record):
|
||||
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
|
||||
|
||||
|
||||
@@ -48,82 +73,188 @@ def setup_logging():
|
||||
|
||||
logging.basicConfig(
|
||||
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],
|
||||
)
|
||||
|
||||
# Suppress noisy third-party loggers
|
||||
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
|
||||
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()
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Media Server starting on {settings.host}:{settings.port}")
|
||||
|
||||
# Log authentication status
|
||||
# Log authentication status — never log full or partial token material.
|
||||
if settings.api_tokens:
|
||||
for label, token in settings.api_tokens.items():
|
||||
logger.info(f"API Token [{label}]: {token[:8]}...")
|
||||
labels = ", ".join(settings.api_tokens.keys())
|
||||
logger.info(f"Authentication enabled. Tokens configured: [{labels}]")
|
||||
else:
|
||||
logger.warning("No API tokens configured — authentication is DISABLED")
|
||||
|
||||
# Start WebSocket status monitor
|
||||
controller = get_media_controller()
|
||||
await ws_manager.start_status_monitor(controller.get_status)
|
||||
logger.info("WebSocket status monitor started")
|
||||
|
||||
# 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
|
||||
|
||||
# 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()
|
||||
|
||||
# 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()
|
||||
|
||||
# Clean up platform-specific resources
|
||||
# Linux preflight: most MPRIS / PulseAudio failures are environmental
|
||||
# (no DBUS_SESSION_BUS_ADDRESS, missing XDG_RUNTIME_DIR, systemd service
|
||||
# started before logind). Surface that early so the failure mode is a
|
||||
# warning at boot instead of silent "/api/media/status returns idle".
|
||||
import os
|
||||
import platform as _platform
|
||||
if _platform.system() == "Windows":
|
||||
from .services.windows_media import shutdown_executor
|
||||
shutdown_executor()
|
||||
if _platform.system() == "Linux":
|
||||
missing = [
|
||||
v for v in ("DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR")
|
||||
if not os.environ.get(v)
|
||||
]
|
||||
if missing:
|
||||
logger.warning(
|
||||
"Linux preflight: %s not set — MPRIS / PulseAudio may be unavailable."
|
||||
" Under systemd, run `loginctl enable-linger <user>` and ensure the"
|
||||
" service unit sets DBUS_SESSION_BUS_ADDRESS + XDG_RUNTIME_DIR.",
|
||||
", ".join(missing),
|
||||
)
|
||||
if os.environ.get("WAYLAND_DISPLAY"):
|
||||
logger.info(
|
||||
"Wayland session detected — foreground-window probe is intentionally"
|
||||
" disabled (Wayland hides window info from unprivileged clients)."
|
||||
)
|
||||
|
||||
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:
|
||||
@@ -138,42 +269,128 @@ def create_app() -> FastAPI:
|
||||
# Compress responses > 1KB
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
|
||||
# Add CORS middleware for cross-origin requests
|
||||
# Token auth is via Authorization header, not cookies, so credentials are not needed
|
||||
# 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
|
||||
# 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 [
|
||||
f"http://localhost:{settings.port}",
|
||||
f"http://127.0.0.1:{settings.port}",
|
||||
]
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_origins=cors_origins,
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Authorization", "Content-Type"],
|
||||
)
|
||||
|
||||
# Add token logging middleware
|
||||
# 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.
|
||||
@app.middleware("http")
|
||||
async def security_headers_middleware(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers.setdefault(
|
||||
"Content-Security-Policy",
|
||||
(
|
||||
"default-src 'self'; "
|
||||
"img-src 'self' data: blob: https://api.iconify.design; "
|
||||
"connect-src 'self' https://api.iconify.design ws: wss:; "
|
||||
"script-src 'self'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"font-src 'self' data:; "
|
||||
"frame-ancestors 'none'; "
|
||||
"form-action 'self'; "
|
||||
"worker-src 'self'; "
|
||||
"manifest-src 'self'; "
|
||||
"base-uri 'self'"
|
||||
),
|
||||
)
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
||||
return response
|
||||
|
||||
# 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")
|
||||
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:
|
||||
token_label_var.set("anonymous")
|
||||
else:
|
||||
token_label = "unknown"
|
||||
token_present = False
|
||||
token_valid = False
|
||||
|
||||
# Try Authorization header
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token_present = True
|
||||
token = auth_header[7:]
|
||||
label = get_token_label(token)
|
||||
if label:
|
||||
token_label = label
|
||||
token_valid = True
|
||||
|
||||
# Try query parameter (for artwork endpoint)
|
||||
elif "token" in request.query_params:
|
||||
token_present = True
|
||||
token = request.query_params["token"]
|
||||
label = get_token_label(token)
|
||||
if label:
|
||||
token_label = label
|
||||
token_valid = True
|
||||
|
||||
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)
|
||||
return response
|
||||
|
||||
@@ -182,6 +399,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(browser_router)
|
||||
app.include_router(callbacks_router)
|
||||
app.include_router(display_router)
|
||||
app.include_router(foreground_router)
|
||||
app.include_router(health_router)
|
||||
app.include_router(links_router)
|
||||
app.include_router(media_router)
|
||||
@@ -205,6 +423,11 @@ def create_app() -> FastAPI:
|
||||
async def serve_ui():
|
||||
"""Serve the Web UI."""
|
||||
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
|
||||
|
||||
@@ -247,36 +470,94 @@ def main():
|
||||
if args.generate_config:
|
||||
config_path = generate_default_config()
|
||||
print(f"Configuration file generated at: {config_path}")
|
||||
print("Authentication is disabled by default. Add api_tokens to enable it.")
|
||||
print("A random API token was generated under api_tokens.default.")
|
||||
print("Run `python -m media_server.main --show-token` to view it.")
|
||||
return
|
||||
|
||||
if args.show_token:
|
||||
print(f"Config directory: {get_config_dir()}")
|
||||
if settings.api_tokens:
|
||||
print("\nAPI Tokens:")
|
||||
for label, token in settings.api_tokens.items():
|
||||
print(f" {label:20} {token}")
|
||||
for label, spec in settings.api_tokens.items():
|
||||
scope_str = ",".join(spec.scopes)
|
||||
print(f" {label:20} {spec.token} [scopes: {scope_str}]")
|
||||
else:
|
||||
print("\nAuthentication is DISABLED (no tokens configured)")
|
||||
return
|
||||
|
||||
# Stderr is invisible when launched via wscript / pythonw (Start Menu shortcut,
|
||||
# autostart). Mirror pre-uvicorn failures to a file in the config dir so the
|
||||
# next silent boot failure is diagnosable.
|
||||
def _fatal(msg: str, exit_code: int = 1) -> None:
|
||||
print(msg, file=sys.stderr)
|
||||
try:
|
||||
log_path = get_config_dir() / "startup-errors.log"
|
||||
from datetime import datetime
|
||||
with open(log_path, "a", encoding="utf-8") as f:
|
||||
f.write(f"[{datetime.now().isoformat(timespec='seconds')}] {msg}\n")
|
||||
except OSError:
|
||||
pass
|
||||
sys.exit(exit_code)
|
||||
|
||||
# First-run bootstrap: if no config has ever been written, generate one
|
||||
# with a random token instead of starting in the insecure "no-auth" mode.
|
||||
config_path = get_config_dir() / "config.yaml"
|
||||
if not config_path.exists() and not settings.api_tokens:
|
||||
try:
|
||||
generate_default_config(config_path)
|
||||
_fatal(
|
||||
f"\nFirst run: generated default config at {config_path}.\n"
|
||||
"Run --show-token to retrieve the API token, then restart.",
|
||||
exit_code=0,
|
||||
)
|
||||
except OSError as e:
|
||||
print(f"WARNING: could not bootstrap config: {e}", file=sys.stderr)
|
||||
|
||||
# Refuse to bind a non-loopback address with no tokens, unless explicitly opted in.
|
||||
non_loopback = args.host not in ("127.0.0.1", "localhost", "::1")
|
||||
if non_loopback and not settings.api_tokens and not settings.allow_lan_without_auth:
|
||||
_fatal(
|
||||
"ERROR: refusing to bind a non-loopback address with no api_tokens configured.\n"
|
||||
"Either set api_tokens in config.yaml, bind to 127.0.0.1,"
|
||||
" or set allow_lan_without_auth: true in config.yaml to override."
|
||||
)
|
||||
|
||||
# Check if port is available before starting
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
try:
|
||||
sock.bind((args.host if args.host != "0.0.0.0" else "127.0.0.1", args.port))
|
||||
except OSError:
|
||||
print(
|
||||
_fatal(
|
||||
f"ERROR: Port {args.port} is already in use. "
|
||||
f"Another instance of Media Server may be running.\n"
|
||||
f"Stop the other process or use --port to pick a different port.",
|
||||
file=sys.stderr,
|
||||
f"Stop the other process or use --port to pick a different port."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
from .tray import PYSTRAY_AVAILABLE, TrayManager
|
||||
|
||||
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:
|
||||
import asyncio
|
||||
import threading
|
||||
@@ -284,9 +565,7 @@ def main():
|
||||
# Run uvicorn in a background thread so tray owns the main thread message loop
|
||||
uv_config = uvicorn.Config(
|
||||
"media_server.main:app",
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
log_level=settings.log_level.lower(),
|
||||
**_uvicorn_kwargs(),
|
||||
)
|
||||
server = uvicorn.Server(uv_config)
|
||||
|
||||
@@ -324,9 +603,8 @@ def main():
|
||||
else:
|
||||
uvicorn.run(
|
||||
"media_server.main:app",
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
reload=False,
|
||||
**_uvicorn_kwargs(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from .audio import router as audio_router
|
||||
from .browser import router as browser_router
|
||||
from .callbacks import router as callbacks_router
|
||||
from .display import router as display_router
|
||||
from .foreground import router as foreground_router
|
||||
from .health import router as health_router
|
||||
from .links import router as links_router
|
||||
from .media import router as media_router
|
||||
@@ -14,6 +15,7 @@ __all__ = [
|
||||
"browser_router",
|
||||
"callbacks_router",
|
||||
"display_router",
|
||||
"foreground_router",
|
||||
"health_router",
|
||||
"links_router",
|
||||
"media_router",
|
||||
|
||||
+109
-81
@@ -23,14 +23,33 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/browser", tags=["browser"])
|
||||
|
||||
# Strong refs to background tasks so they don't get garbage-collected mid-flight.
|
||||
_background_tasks: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
def _spawn_background(coro) -> asyncio.Task:
|
||||
"""Schedule a background coroutine and keep a strong ref to its Task."""
|
||||
task = asyncio.create_task(coro)
|
||||
_background_tasks.add(task)
|
||||
task.add_done_callback(_background_tasks.discard)
|
||||
return task
|
||||
|
||||
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
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:
|
||||
@@ -38,16 +57,23 @@ async def _broadcast_after_open(controller, label: str, max_wait: float = 2.0) -
|
||||
|
||||
Fires as a background task so the HTTP response returns immediately.
|
||||
"""
|
||||
status = None
|
||||
try:
|
||||
interval = 0.3
|
||||
elapsed = 0.0
|
||||
while elapsed < max_wait:
|
||||
await asyncio.sleep(interval)
|
||||
elapsed += interval
|
||||
status = await controller.get_status()
|
||||
try:
|
||||
status = await controller.get_status()
|
||||
except Exception as poll_err: # noqa: BLE001 — broadcast is best-effort
|
||||
logger.debug("get_status during broadcast poll failed: %s", poll_err)
|
||||
continue
|
||||
if status.state in ("playing", "paused"):
|
||||
break
|
||||
|
||||
if status is None:
|
||||
return
|
||||
status_dict = status.model_dump()
|
||||
await ws_manager.broadcast({"type": "status", "data": status_dict})
|
||||
logger.info(f"Broadcasted status update after opening: {label}")
|
||||
@@ -74,9 +100,14 @@ class FolderUpdateRequest(BaseModel):
|
||||
|
||||
|
||||
class PlayRequest(BaseModel):
|
||||
"""Request model for playing a media file."""
|
||||
"""Request model for playing a media file.
|
||||
|
||||
path: str = Field(..., description="Full path to the media file")
|
||||
Both ``folder_id`` and ``path`` are required so the server can validate
|
||||
the file lives inside a configured media folder.
|
||||
"""
|
||||
|
||||
folder_id: str = Field(..., description="Media folder ID")
|
||||
path: str = Field(..., description="Path relative to folder root")
|
||||
|
||||
|
||||
class PlayFolderRequest(BaseModel):
|
||||
@@ -128,8 +159,10 @@ async def create_folder(
|
||||
"""
|
||||
_require_folder_management()
|
||||
try:
|
||||
# Validate folder_id format (alphanumeric and underscore only)
|
||||
if not request.folder_id.replace("_", "").isalnum():
|
||||
# Validate folder_id format (alphanumeric and underscore only).
|
||||
# Same constraint is enforced when validating paths so traversal can't
|
||||
# be smuggled through the ID itself.
|
||||
if not request.folder_id or not request.folder_id.replace("_", "").isalnum():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Folder ID must contain only alphanumeric characters and underscores",
|
||||
@@ -277,13 +310,15 @@ async def browse(
|
||||
# URL decode the path
|
||||
decoded_path = unquote(path)
|
||||
|
||||
# Browse directory
|
||||
result = BrowserService.browse_directory(
|
||||
folder_id=folder_id,
|
||||
path=decoded_path,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
nocache=nocache,
|
||||
# Browse directory in a thread — iterdir() + stat() can block on
|
||||
# network shares for many seconds; never run on the event loop.
|
||||
result = await asyncio.to_thread(
|
||||
BrowserService.browse_directory,
|
||||
folder_id,
|
||||
decoded_path,
|
||||
offset,
|
||||
limit,
|
||||
nocache,
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -307,41 +342,40 @@ async def browse(
|
||||
# Metadata Endpoint
|
||||
@router.get("/metadata")
|
||||
async def get_metadata(
|
||||
path: str = Query(..., description="Full path to media file (URL-encoded)"),
|
||||
folder_id: str = Query(..., description="Media folder ID"),
|
||||
path: str = Query(..., description="Path relative to folder root (URL-encoded)"),
|
||||
_: str = Depends(verify_token),
|
||||
):
|
||||
"""Get metadata for a media file.
|
||||
"""Get metadata for a media file inside a configured media folder.
|
||||
|
||||
Args:
|
||||
path: Full path to the media file (URL-encoded).
|
||||
folder_id: ID of the media folder.
|
||||
path: Path relative to folder root (URL-encoded).
|
||||
|
||||
Returns:
|
||||
Media file metadata.
|
||||
|
||||
Raises:
|
||||
HTTPException: If file not found or metadata extraction fails.
|
||||
"""
|
||||
try:
|
||||
# URL decode the path
|
||||
decoded_path = unquote(path)
|
||||
file_path = Path(decoded_path)
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
file_path = BrowserService.validate_path(folder_id, decoded_path)
|
||||
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=400, detail="Path is not a file")
|
||||
if not BrowserService.is_media_file(file_path):
|
||||
raise HTTPException(status_code=400, detail="File is not a media file")
|
||||
|
||||
# Extract metadata in executor (blocking operation)
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
metadata = await loop.run_in_executor(
|
||||
None,
|
||||
MetadataService.extract_metadata,
|
||||
file_path,
|
||||
)
|
||||
|
||||
return metadata
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -352,59 +386,47 @@ async def get_metadata(
|
||||
# Thumbnail Endpoint
|
||||
@router.get("/thumbnail")
|
||||
async def get_thumbnail(
|
||||
path: str = Query(..., description="Full path to media file (URL-encoded)"),
|
||||
folder_id: str = Query(..., description="Media folder ID"),
|
||||
path: str = Query(..., description="Path relative to folder root (URL-encoded)"),
|
||||
size: str = Query(default="medium", description='Thumbnail size: "small" or "medium"'),
|
||||
_: str = Depends(verify_token),
|
||||
):
|
||||
"""Get thumbnail for a media file.
|
||||
|
||||
Args:
|
||||
path: Full path to the media file (URL-encoded).
|
||||
size: Thumbnail size ("small" or "medium").
|
||||
|
||||
Returns:
|
||||
JPEG image bytes.
|
||||
|
||||
Raises:
|
||||
HTTPException: If file not found or thumbnail generation fails.
|
||||
"""
|
||||
"""Get thumbnail for a media file inside a configured media folder."""
|
||||
try:
|
||||
# URL decode the path
|
||||
decoded_path = unquote(path)
|
||||
file_path = Path(decoded_path)
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
file_path = BrowserService.validate_path(folder_id, decoded_path)
|
||||
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=400, detail="Path is not a file")
|
||||
if not BrowserService.is_media_file(file_path):
|
||||
raise HTTPException(status_code=400, detail="File is not a media file")
|
||||
|
||||
# Validate size
|
||||
if size not in ("small", "medium"):
|
||||
size = "medium"
|
||||
|
||||
# Get thumbnail
|
||||
thumbnail_data = await ThumbnailService.get_thumbnail(file_path, size)
|
||||
|
||||
if thumbnail_data is None:
|
||||
return Response(status_code=204)
|
||||
|
||||
# Calculate ETag (hash of path + mtime)
|
||||
import hashlib
|
||||
stat = file_path.stat()
|
||||
etag_data = f"{file_path}:{stat.st_mtime}:{size}".encode()
|
||||
etag = hashlib.md5(etag_data).hexdigest()
|
||||
|
||||
# Return image with caching headers
|
||||
return Response(
|
||||
content=thumbnail_data,
|
||||
media_type="image/jpeg",
|
||||
headers={
|
||||
"ETag": f'"{etag}"',
|
||||
"Cache-Control": "public, max-age=86400", # 24 hours
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
},
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -420,44 +442,37 @@ async def play_file(
|
||||
):
|
||||
"""Open a media file with the default system player.
|
||||
|
||||
Args:
|
||||
request: Play request with file path.
|
||||
|
||||
Returns:
|
||||
Success message.
|
||||
|
||||
Raises:
|
||||
HTTPException: If file not found or playback fails.
|
||||
Requires both ``folder_id`` and a folder-relative ``path``; the resolved
|
||||
file must live inside the configured media folder and be a recognized
|
||||
media file. This prevents arbitrary OS-handler invocation (e.g.,
|
||||
``os.startfile`` on Windows ``.lnk``/UNC paths).
|
||||
"""
|
||||
try:
|
||||
file_path = Path(request.path)
|
||||
|
||||
# Validate file exists
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
decoded_path = unquote(request.path)
|
||||
file_path = BrowserService.validate_path(request.folder_id, decoded_path)
|
||||
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=400, detail="Path is not a file")
|
||||
|
||||
# Validate file is a media file
|
||||
if not BrowserService.is_media_file(file_path):
|
||||
raise HTTPException(status_code=400, detail="File is not a media file")
|
||||
|
||||
# Get media controller and open file
|
||||
controller = get_media_controller()
|
||||
success = await controller.open_file(str(file_path))
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to open file")
|
||||
|
||||
# Poll until player registers with media session API (up to 2s)
|
||||
asyncio.create_task(_broadcast_after_open(controller, file_path.name))
|
||||
_spawn_background(_broadcast_after_open(controller, file_path.name))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Playing {file_path.name}",
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -489,26 +504,38 @@ async def play_folder(
|
||||
if not full_path.is_dir():
|
||||
raise HTTPException(status_code=400, detail="Path is not a directory")
|
||||
|
||||
# Collect all media files sorted by name
|
||||
media_files = sorted(
|
||||
[f for f in full_path.iterdir() if f.is_file() and BrowserService.is_media_file(f)],
|
||||
key=lambda f: f.name.lower(),
|
||||
)
|
||||
def _scan(directory: Path) -> list[Path]:
|
||||
return sorted(
|
||||
(
|
||||
f for f in directory.iterdir()
|
||||
if f.is_file() and BrowserService.is_media_file(f)
|
||||
),
|
||||
key=lambda f: f.name.lower(),
|
||||
)
|
||||
|
||||
media_files = await asyncio.to_thread(_scan, full_path)
|
||||
|
||||
if not media_files:
|
||||
raise HTTPException(status_code=404, detail="No media files found in this folder")
|
||||
|
||||
# Generate M3U playlist with absolute paths and EXTINF entries
|
||||
# Written to local temp dir to avoid extra SMB file handle on network shares
|
||||
# Uses utf-8-sig (BOM) so players detect encoding properly
|
||||
# Generate M3U playlist with absolute paths and EXTINF entries.
|
||||
# Use NamedTemporaryFile to get a fresh per-call path — prevents
|
||||
# symlink-clobber races between concurrent /play-folder requests
|
||||
# and any local user pre-creating a fixed temp filename.
|
||||
lines = ["#EXTM3U"]
|
||||
for f in media_files:
|
||||
lines.append(f"#EXTINF:-1,{f.stem}")
|
||||
lines.append(str(f))
|
||||
m3u_content = "\r\n".join(lines) + "\r\n"
|
||||
m3u_content = ("\r\n".join(lines) + "\r\n").encode("utf-8-sig")
|
||||
|
||||
playlist_path = Path(tempfile.gettempdir()) / ".media_server_playlist.m3u"
|
||||
playlist_path.write_text(m3u_content, encoding="utf-8-sig")
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="wb",
|
||||
prefix=".media_server_playlist_",
|
||||
suffix=".m3u",
|
||||
delete=False,
|
||||
) as f:
|
||||
f.write(m3u_content)
|
||||
playlist_path = Path(f.name)
|
||||
|
||||
# Open playlist with default player
|
||||
controller = get_media_controller()
|
||||
@@ -517,8 +544,9 @@ async def play_folder(
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to open playlist")
|
||||
|
||||
# Poll until player registers with media session API (up to 2s)
|
||||
asyncio.create_task(_broadcast_after_open(controller, f"playlist ({len(media_files)} files)"))
|
||||
_spawn_background(
|
||||
_broadcast_after_open(controller, f"playlist ({len(media_files)} files)")
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
|
||||
@@ -3,16 +3,19 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
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 ..auth import verify_token
|
||||
from ..config import CallbackConfig, settings
|
||||
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"])
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -21,6 +24,31 @@ logger = logging.getLogger(__name__)
|
||||
_callback_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="callback")
|
||||
|
||||
|
||||
def shutdown_callback_executor() -> None:
|
||||
"""Shut down the callback executor cleanly on application teardown."""
|
||||
_callback_executor.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
|
||||
def _require_callbacks_management() -> None:
|
||||
"""Authorise a callbacks-CRUD operation. Operator flag + per-token admin scope."""
|
||||
if not settings.callbacks_management:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=(
|
||||
"Callbacks management is disabled. Set callbacks_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 CallbackInfo(BaseModel):
|
||||
"""Information about a configured callback."""
|
||||
|
||||
@@ -105,6 +133,7 @@ async def list_callbacks(_: str = Depends(verify_token)) -> list[CallbackInfo]:
|
||||
@router.post("/execute/{callback_name}")
|
||||
async def execute_callback(
|
||||
callback_name: str,
|
||||
http_request: Request,
|
||||
_: str = Depends(verify_token),
|
||||
) -> CallbackExecuteResponse:
|
||||
"""Execute a callback for debugging purposes.
|
||||
@@ -115,6 +144,16 @@ async def execute_callback(
|
||||
Returns:
|
||||
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(callback_name)
|
||||
|
||||
@@ -129,9 +168,11 @@ async def execute_callback(
|
||||
|
||||
logger.info(f"Executing callback for debugging: {callback_name}")
|
||||
|
||||
from ..services.audit_log import record_script_execution
|
||||
|
||||
try:
|
||||
# Execute in dedicated thread pool to not block the default executor
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
result = await loop.run_in_executor(
|
||||
_callback_executor,
|
||||
lambda: _run_callback(
|
||||
@@ -142,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(
|
||||
success=result["exit_code"] == 0,
|
||||
callback=callback_name,
|
||||
@@ -153,6 +203,13 @@ async def execute_callback(
|
||||
|
||||
except Exception as 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(
|
||||
success=False,
|
||||
callback=callback_name,
|
||||
@@ -178,6 +235,11 @@ def _run_callback(
|
||||
Dict with exit_code, stdout, stderr, execution_time
|
||||
"""
|
||||
start_time = time.time()
|
||||
popen_kwargs: dict[str, Any] = {}
|
||||
if sys.platform == "win32":
|
||||
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
else:
|
||||
popen_kwargs["start_new_session"] = True
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
@@ -186,6 +248,7 @@ def _run_callback(
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
**popen_kwargs,
|
||||
)
|
||||
execution_time = time.time() - start_time
|
||||
return {
|
||||
@@ -230,7 +293,7 @@ async def create_callback(
|
||||
Raises:
|
||||
HTTPException: If callback already exists or name is invalid.
|
||||
"""
|
||||
# Validate name
|
||||
_require_callbacks_management()
|
||||
_validate_callback_name(callback_name)
|
||||
|
||||
# Check if callback already exists
|
||||
@@ -278,7 +341,7 @@ async def update_callback(
|
||||
Raises:
|
||||
HTTPException: If callback does not exist.
|
||||
"""
|
||||
# Validate name
|
||||
_require_callbacks_management()
|
||||
_validate_callback_name(callback_name)
|
||||
|
||||
# Check if callback exists
|
||||
@@ -324,7 +387,7 @@ async def delete_callback(
|
||||
Raises:
|
||||
HTTPException: If callback does not exist.
|
||||
"""
|
||||
# Validate name
|
||||
_require_callbacks_management()
|
||||
_validate_callback_name(callback_name)
|
||||
|
||||
# Check if callback exists
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Display brightness and power control API endpoints."""
|
||||
"""Display brightness, power, contrast, input-source, color-preset and picture-mode API."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
@@ -9,6 +10,10 @@ from ..auth import verify_token
|
||||
from ..services.display_service import (
|
||||
list_monitors,
|
||||
set_brightness,
|
||||
set_color_preset,
|
||||
set_contrast,
|
||||
set_input_source,
|
||||
set_picture_mode,
|
||||
set_power,
|
||||
)
|
||||
|
||||
@@ -25,12 +30,37 @@ class PowerRequest(BaseModel):
|
||||
on: bool
|
||||
|
||||
|
||||
class ContrastRequest(BaseModel):
|
||||
contrast: int = Field(ge=0, le=100)
|
||||
|
||||
|
||||
class InputSourceRequest(BaseModel):
|
||||
source: str
|
||||
|
||||
|
||||
class ColorPresetRequest(BaseModel):
|
||||
preset: str
|
||||
|
||||
|
||||
class PictureModeRequest(BaseModel):
|
||||
code: int = Field(ge=0, le=255)
|
||||
|
||||
|
||||
# DDC/CI hardware writes open a per-monitor handle and can take seconds —
|
||||
# every public endpoint dispatches into a worker thread so the event loop
|
||||
# stays responsive.
|
||||
|
||||
|
||||
@router.get("/monitors")
|
||||
async def get_monitors(
|
||||
refresh: bool = False, _: str = Depends(verify_token)
|
||||
refresh: bool = False,
|
||||
rediscover: bool = False,
|
||||
_: str = Depends(verify_token),
|
||||
) -> list[dict]:
|
||||
"""List all connected monitors with brightness and power info."""
|
||||
monitors = list_monitors(force_refresh=refresh)
|
||||
"""List all connected monitors with their reported DDC/CI capabilities."""
|
||||
monitors = await asyncio.to_thread(
|
||||
list_monitors, force_refresh=refresh, rediscover=rediscover
|
||||
)
|
||||
logger.debug("Found %d monitors", len(monitors))
|
||||
return [m.to_dict() for m in monitors]
|
||||
|
||||
@@ -40,7 +70,7 @@ async def set_monitor_brightness(
|
||||
monitor_id: int, request: BrightnessRequest, _: str = Depends(verify_token)
|
||||
) -> dict:
|
||||
"""Set brightness for a specific monitor."""
|
||||
success = set_brightness(monitor_id, request.brightness)
|
||||
success = await asyncio.to_thread(set_brightness, monitor_id, request.brightness)
|
||||
if success:
|
||||
logger.info("Set monitor %d brightness to %d", monitor_id, request.brightness)
|
||||
return {"success": success}
|
||||
@@ -52,7 +82,51 @@ async def set_monitor_power(
|
||||
) -> dict:
|
||||
"""Turn a monitor on or off."""
|
||||
action = "on" if request.on else "off"
|
||||
success = set_power(monitor_id, request.on)
|
||||
success = await asyncio.to_thread(set_power, monitor_id, request.on)
|
||||
if success:
|
||||
logger.info("Set monitor %d power %s", monitor_id, action)
|
||||
return {"success": success}
|
||||
|
||||
|
||||
@router.post("/contrast/{monitor_id}")
|
||||
async def set_monitor_contrast(
|
||||
monitor_id: int, request: ContrastRequest, _: str = Depends(verify_token)
|
||||
) -> dict:
|
||||
"""Set DDC/CI contrast for a specific monitor."""
|
||||
success = await asyncio.to_thread(set_contrast, monitor_id, request.contrast)
|
||||
if success:
|
||||
logger.info("Set monitor %d contrast to %d", monitor_id, request.contrast)
|
||||
return {"success": success}
|
||||
|
||||
|
||||
@router.post("/input_source/{monitor_id}")
|
||||
async def set_monitor_input_source(
|
||||
monitor_id: int, request: InputSourceRequest, _: str = Depends(verify_token)
|
||||
) -> dict:
|
||||
"""Switch a monitor's DDC/CI input source (e.g. HDMI1, DP1)."""
|
||||
success = await asyncio.to_thread(set_input_source, monitor_id, request.source)
|
||||
if success:
|
||||
logger.info("Set monitor %d input source to %s", monitor_id, request.source)
|
||||
return {"success": success}
|
||||
|
||||
|
||||
@router.post("/color_preset/{monitor_id}")
|
||||
async def set_monitor_color_preset(
|
||||
monitor_id: int, request: ColorPresetRequest, _: str = Depends(verify_token)
|
||||
) -> dict:
|
||||
"""Apply a DDC/CI color preset (color temperature) to the monitor."""
|
||||
success = await asyncio.to_thread(set_color_preset, monitor_id, request.preset)
|
||||
if success:
|
||||
logger.info("Set monitor %d color preset to %s", monitor_id, request.preset)
|
||||
return {"success": success}
|
||||
|
||||
|
||||
@router.post("/picture_mode/{monitor_id}")
|
||||
async def set_monitor_picture_mode(
|
||||
monitor_id: int, request: PictureModeRequest, _: str = Depends(verify_token)
|
||||
) -> dict:
|
||||
"""Apply a DDC/CI picture/scene mode (VCP 0xDC) by raw code."""
|
||||
success = await asyncio.to_thread(set_picture_mode, monitor_id, request.code)
|
||||
if success:
|
||||
logger.info("Set monitor %d picture mode to code %d", monitor_id, request.code)
|
||||
return {"success": success}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Foreground (topmost) window/process API."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from ..auth import verify_token
|
||||
from ..services.foreground_service import get_foreground_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/foreground", tags=["foreground"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_foreground(
|
||||
refresh: bool = False, _: str = Depends(verify_token)
|
||||
) -> dict:
|
||||
"""Return metadata about the foreground window and owning process.
|
||||
|
||||
The probe is cached for ~500ms server-side; pass ``?refresh=1`` to bypass
|
||||
the cache for one-shot queries.
|
||||
"""
|
||||
info = await asyncio.to_thread(get_foreground_info, refresh)
|
||||
return info.to_dict()
|
||||
@@ -3,9 +3,10 @@
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from ..auth import verify_token
|
||||
from ..config import LinkConfig, settings
|
||||
@@ -15,6 +16,44 @@ from ..services.websocket_manager import ws_manager
|
||||
router = APIRouter(prefix="/api/links", tags=["links"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Only allow MDI iconify slugs and safe `http(s)`-ish URLs through the API.
|
||||
_MDI_ICON_RE = re.compile(r"^mdi:[a-z0-9][a-z0-9-]{0,63}$")
|
||||
_ALLOWED_URL_SCHEMES = {"http", "https"}
|
||||
|
||||
|
||||
def _validate_url(url: str) -> str:
|
||||
"""Ensure the URL is well-formed http(s) — no ``javascript:`` etc."""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme.lower() not in _ALLOWED_URL_SCHEMES:
|
||||
raise ValueError("URL must start with http:// or https://")
|
||||
if not parsed.netloc:
|
||||
raise ValueError("URL must include a host")
|
||||
return url
|
||||
|
||||
|
||||
def _validate_icon(icon: str) -> str:
|
||||
"""Restrict icon names to safe Material Design Icons slugs."""
|
||||
if not _MDI_ICON_RE.match(icon):
|
||||
raise ValueError("Icon must be of the form 'mdi:<lowercase-slug>'")
|
||||
return icon
|
||||
|
||||
|
||||
def _require_links_management() -> None:
|
||||
"""Authorise a links-CRUD operation. Operator flag + per-token admin scope."""
|
||||
if not settings.links_management:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
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):
|
||||
"""Information about a configured link."""
|
||||
@@ -29,22 +68,25 @@ class LinkInfo(BaseModel):
|
||||
class LinkCreateRequest(BaseModel):
|
||||
"""Request model for creating or updating a link."""
|
||||
|
||||
url: str = Field(..., description="URL to open", min_length=1)
|
||||
url: str = Field(..., description="URL to open", min_length=1, max_length=2048)
|
||||
icon: str = Field(default="mdi:link", description="MDI icon name (e.g., 'mdi:led-strip-variant')")
|
||||
label: str = Field(default="", description="Tooltip text")
|
||||
description: str = Field(default="", description="Optional description")
|
||||
label: str = Field(default="", description="Tooltip text", max_length=128)
|
||||
description: str = Field(default="", description="Optional description", max_length=512)
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def _check_url(cls, v: str) -> str:
|
||||
return _validate_url(v)
|
||||
|
||||
@field_validator("icon")
|
||||
@classmethod
|
||||
def _check_icon(cls, v: str) -> str:
|
||||
return _validate_icon(v)
|
||||
|
||||
|
||||
def _validate_link_name(name: str) -> None:
|
||||
"""Validate link name.
|
||||
|
||||
Args:
|
||||
name: Link name to validate.
|
||||
|
||||
Raises:
|
||||
HTTPException: If name is invalid.
|
||||
"""
|
||||
if not re.match(r'^[a-zA-Z0-9_]+$', name):
|
||||
"""Validate link name."""
|
||||
if not re.match(r"^[a-zA-Z0-9_]+$", name):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Link name must contain only letters, numbers, and underscores",
|
||||
@@ -90,6 +132,7 @@ async def create_link(
|
||||
Returns:
|
||||
Success response with link name.
|
||||
"""
|
||||
_require_links_management()
|
||||
_validate_link_name(link_name)
|
||||
|
||||
if link_name in settings.links:
|
||||
@@ -129,6 +172,7 @@ async def update_link(
|
||||
Returns:
|
||||
Success response with link name.
|
||||
"""
|
||||
_require_links_management()
|
||||
_validate_link_name(link_name)
|
||||
|
||||
if link_name not in settings.links:
|
||||
@@ -166,6 +210,7 @@ async def delete_link(
|
||||
Returns:
|
||||
Success response with link name.
|
||||
"""
|
||||
_require_links_management()
|
||||
_validate_link_name(link_name)
|
||||
|
||||
if link_name not in settings.links:
|
||||
|
||||
+178
-30
@@ -3,13 +3,22 @@
|
||||
import asyncio
|
||||
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 ..auth import verify_token, verify_token_or_query
|
||||
from ..config import settings
|
||||
from ..models import MediaStatus, SeekRequest, VolumeRequest
|
||||
from ..services import get_current_album_art, get_media_controller
|
||||
from ..services import get_current_album_art_async, get_media_controller
|
||||
from ..services.websocket_manager import ws_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -17,19 +26,28 @@ logger = logging.getLogger(__name__)
|
||||
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:
|
||||
"""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:
|
||||
return
|
||||
|
||||
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
|
||||
|
||||
try:
|
||||
callback = settings.callbacks[callback_name]
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
_callback_executor,
|
||||
lambda: _run_script(
|
||||
command=callback.command,
|
||||
timeout=callback.timeout,
|
||||
@@ -37,6 +55,14 @@ def _run_callback(callback_name: str) -> None:
|
||||
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:
|
||||
logger.warning(
|
||||
"Callback %s failed with exit code %s: %s",
|
||||
@@ -46,8 +72,18 @@ def _run_callback(callback_name: str) -> None:
|
||||
)
|
||||
except Exception as 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)
|
||||
@@ -242,41 +278,91 @@ async def toggle(_: str = Depends(verify_token)) -> dict:
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
Returns:
|
||||
The album art image as PNG/JPEG
|
||||
Returns the bytes with a content-derived ETag so the browser can serve a
|
||||
304 when the same track is re-requested.
|
||||
"""
|
||||
art_bytes = get_current_album_art()
|
||||
art_bytes = await get_current_album_art_async()
|
||||
if art_bytes is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No album artwork available",
|
||||
)
|
||||
|
||||
# Try to detect image type from magic bytes
|
||||
content_type = "image/png" # Default
|
||||
# Detect image type from magic bytes
|
||||
if art_bytes[:3] == b"\xff\xd8\xff":
|
||||
content_type = "image/jpeg"
|
||||
elif art_bytes[:8] == b"\x89PNG\r\n\x1a\n":
|
||||
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"
|
||||
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")
|
||||
async def visualizer_status(_: str = Depends(verify_token)) -> dict:
|
||||
"""Check if audio visualizer is available and running."""
|
||||
"""Check if audio visualizer is available and running.
|
||||
|
||||
``available`` is True only when both numpy + soundcard import.
|
||||
``unavailable_reason`` carries a short human-readable hint when False
|
||||
or when a usable loopback device was not found — useful on Linux where
|
||||
PulseAudio/PipeWire may not expose monitor sources to a headless
|
||||
systemd-as-user service.
|
||||
"""
|
||||
import platform as _platform
|
||||
|
||||
from ..services.audio_analyzer import get_audio_analyzer
|
||||
|
||||
analyzer = get_audio_analyzer()
|
||||
reason: str | None = None
|
||||
if not analyzer.available:
|
||||
reason = "soundcard or numpy is not installed"
|
||||
elif getattr(analyzer, "_unavailable", False):
|
||||
if _platform.system() == "Linux":
|
||||
reason = (
|
||||
"No loopback audio device found. On Linux this requires a "
|
||||
"running PulseAudio/PipeWire session with monitor sources "
|
||||
"(systemd user service: ensure DBUS_SESSION_BUS_ADDRESS + "
|
||||
"XDG_RUNTIME_DIR are set)."
|
||||
)
|
||||
elif _platform.system() == "Darwin":
|
||||
reason = (
|
||||
"No loopback audio device found. macOS does not expose system "
|
||||
"loopback by default — install BlackHole or Soundflower."
|
||||
)
|
||||
else:
|
||||
reason = "No loopback audio device found"
|
||||
|
||||
return {
|
||||
"available": analyzer.available,
|
||||
"running": analyzer.running,
|
||||
"current_device": analyzer.current_device,
|
||||
"unavailable_reason": reason,
|
||||
}
|
||||
|
||||
|
||||
@@ -285,7 +371,7 @@ async def visualizer_devices(_: str = Depends(verify_token)) -> list[dict[str, s
|
||||
"""List available loopback audio devices for the visualizer."""
|
||||
from ..services.audio_analyzer import AudioAnalyzer
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, AudioAnalyzer.list_loopback_devices)
|
||||
|
||||
|
||||
@@ -323,12 +409,17 @@ async def set_visualizer_device(
|
||||
@router.websocket("/ws")
|
||||
async def websocket_endpoint(
|
||||
websocket: WebSocket,
|
||||
token: str | None = Query(None, description="API authentication token"),
|
||||
token: str | None = Query(None, description="API authentication token (legacy)"),
|
||||
) -> None:
|
||||
"""WebSocket endpoint for real-time media status updates.
|
||||
|
||||
Authentication is done via query parameter since WebSocket
|
||||
doesn't support custom headers in the browser.
|
||||
Authentication is accepted from two sources, in priority order:
|
||||
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:
|
||||
- {"type": "status", "data": {...}} - Initial status on connect
|
||||
@@ -339,11 +430,54 @@ async def websocket_endpoint(
|
||||
- {"type": "ping"} - Keepalive, server responds with {"type": "pong"}
|
||||
- {"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
|
||||
from ..auth import auth_enabled, get_token_label, token_label_var
|
||||
|
||||
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:
|
||||
await websocket.close(code=4001, reason="Invalid authentication token")
|
||||
return
|
||||
@@ -351,16 +485,25 @@ async def websocket_endpoint(
|
||||
else:
|
||||
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:
|
||||
while True:
|
||||
# Wait for messages from client (for keepalive/ping)
|
||||
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"})
|
||||
elif data.get("type") == "get_status":
|
||||
elif msg_type == "get_status":
|
||||
# Allow manual status request
|
||||
controller = get_media_controller()
|
||||
status_data = await controller.get_status()
|
||||
@@ -368,15 +511,20 @@ async def websocket_endpoint(
|
||||
"type": "status",
|
||||
"data": status_data.model_dump(),
|
||||
})
|
||||
elif data.get("type") == "volume":
|
||||
# Low-latency volume control via WebSocket
|
||||
volume = data.get("volume")
|
||||
if volume is not None:
|
||||
controller = get_media_controller()
|
||||
await controller.set_volume(int(volume))
|
||||
elif data.get("type") == "enable_visualizer":
|
||||
elif msg_type == "volume":
|
||||
# Low-latency volume control via WebSocket. Coerce, clamp, and
|
||||
# never drop the socket on a single bad message — that would
|
||||
# turn the WS into a one-shot DoS for any holder of a token.
|
||||
try:
|
||||
volume = int(data.get("volume"))
|
||||
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)
|
||||
elif data.get("type") == "disable_visualizer":
|
||||
elif msg_type == "disable_visualizer":
|
||||
await ws_manager.unsubscribe_visualizer(websocket)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
|
||||
@@ -2,18 +2,22 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
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 ..auth import verify_token
|
||||
from ..config import ScriptConfig, ScriptParameterConfig, settings
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/api/scripts", tags=["scripts"])
|
||||
@@ -23,6 +27,36 @@ _script_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="script"
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def shutdown_script_executor() -> None:
|
||||
"""Shut down the dedicated executor cleanly on application teardown."""
|
||||
_script_executor.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=(
|
||||
"Scripts management is disabled. Set scripts_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 ScriptExecuteRequest(BaseModel):
|
||||
"""Request model for script execution with optional parameters."""
|
||||
|
||||
@@ -197,6 +231,28 @@ def _validate_params(
|
||||
# string — just convert to str
|
||||
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)
|
||||
|
||||
return env_vars
|
||||
@@ -205,6 +261,7 @@ def _validate_params(
|
||||
@router.post("/execute/{script_name}")
|
||||
async def execute_script(
|
||||
script_name: str,
|
||||
http_request: Request,
|
||||
request: ScriptExecuteRequest | None = None,
|
||||
_: str = Depends(verify_token),
|
||||
) -> ScriptExecuteResponse:
|
||||
@@ -217,6 +274,16 @@ async def execute_script(
|
||||
Returns:
|
||||
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
|
||||
if script_name not in settings.scripts:
|
||||
raise HTTPException(
|
||||
@@ -231,9 +298,11 @@ async def execute_script(
|
||||
|
||||
logger.info(f"Executing script: {script_name}")
|
||||
|
||||
from ..services.audit_log import record_script_execution
|
||||
|
||||
try:
|
||||
# Execute in dedicated thread pool to not block the default executor
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
result = await loop.run_in_executor(
|
||||
_script_executor,
|
||||
lambda: _run_script(
|
||||
@@ -245,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(
|
||||
success=result["exit_code"] == 0,
|
||||
script=script_name,
|
||||
@@ -256,6 +334,13 @@ async def execute_script(
|
||||
|
||||
except Exception as 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(
|
||||
success=False,
|
||||
script=script_name,
|
||||
@@ -285,17 +370,38 @@ def _run_script(
|
||||
start_time = time.time()
|
||||
env = None
|
||||
if extra_env:
|
||||
import os
|
||||
env = {**os.environ, **extra_env}
|
||||
|
||||
# Spawn the script in its own process group / job so a timeout kills the
|
||||
# whole tree, not just the shell (POSIX) and not just the parent (Windows).
|
||||
popen_kwargs: dict[str, Any] = {}
|
||||
if sys.platform == "win32":
|
||||
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
else:
|
||||
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:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
run_command,
|
||||
shell=shell,
|
||||
cwd=working_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
env=env,
|
||||
**popen_kwargs,
|
||||
)
|
||||
execution_time = time.time() - start_time
|
||||
return {
|
||||
@@ -455,7 +561,7 @@ async def create_script(
|
||||
Raises:
|
||||
HTTPException: If script already exists or name is invalid.
|
||||
"""
|
||||
# Validate name
|
||||
_require_scripts_management()
|
||||
_validate_script_name(script_name)
|
||||
|
||||
# Check if script already exists
|
||||
@@ -511,7 +617,7 @@ async def update_script(
|
||||
Raises:
|
||||
HTTPException: If script does not exist.
|
||||
"""
|
||||
# Validate name
|
||||
_require_scripts_management()
|
||||
_validate_script_name(script_name)
|
||||
|
||||
# Check if script exists
|
||||
@@ -565,7 +671,7 @@ async def delete_script(
|
||||
Raises:
|
||||
HTTPException: If script does not exist.
|
||||
"""
|
||||
# Validate name
|
||||
_require_scripts_management()
|
||||
_validate_script_name(script_name)
|
||||
|
||||
# Check if script exists
|
||||
|
||||
@@ -57,25 +57,38 @@ install_service() {
|
||||
# Create installation directory
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
|
||||
# Copy source files
|
||||
cp -r "$(dirname "$0")/../"* "$INSTALL_DIR/"
|
||||
# Resolve the source-tree root (two levels up from this script:
|
||||
# media_server/service/install_linux.sh → repo root).
|
||||
SOURCE_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
|
||||
# Copy the source tree (pyproject.toml + media_server/ package)
|
||||
cp -r "$SOURCE_ROOT/." "$INSTALL_DIR/"
|
||||
|
||||
# Create virtual environment
|
||||
echo_info "Creating Python virtual environment..."
|
||||
python3 -m venv "$INSTALL_DIR/venv"
|
||||
|
||||
# Install Python dependencies
|
||||
# Install Python dependencies from pyproject.toml (with linux extra).
|
||||
# cd into the install dir so pip resolves `.[linux]` against the local
|
||||
# pyproject — passing a path-with-extras (`$INSTALL_DIR[linux]`) trips
|
||||
# the requirement-spec parser on some pip versions.
|
||||
echo_info "Installing Python dependencies..."
|
||||
"$INSTALL_DIR/venv/bin/pip" install --upgrade pip
|
||||
"$INSTALL_DIR/venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt"
|
||||
(cd "$INSTALL_DIR" && "$INSTALL_DIR/venv/bin/pip" install ".[linux]")
|
||||
|
||||
# Install systemd service file
|
||||
# Install systemd service file (templated unit)
|
||||
echo_info "Installing systemd service..."
|
||||
cp "$INSTALL_DIR/service/media-server.service" "$SERVICE_FILE"
|
||||
cp "$INSTALL_DIR/media_server/service/media-server.service" "$SERVICE_FILE"
|
||||
|
||||
# Reload systemd
|
||||
systemctl daemon-reload
|
||||
|
||||
# Pre-create writable state dirs so the unit's ReadWritePaths grant
|
||||
# actually has something to grant.
|
||||
sudo -u "$SUDO_USER" mkdir -p \
|
||||
"/home/$SUDO_USER/.config/media-server" \
|
||||
"/home/$SUDO_USER/.cache/media-server"
|
||||
|
||||
# Generate config if not exists
|
||||
if [[ ! -f "/home/$SUDO_USER/.config/media-server/config.yaml" ]]; then
|
||||
echo_info "Generating configuration file..."
|
||||
|
||||
@@ -3,34 +3,38 @@ Description=Media Server - REST API for controlling system media playback
|
||||
After=network.target sound.target
|
||||
Wants=sound.target
|
||||
|
||||
# Templated unit. Enable as:
|
||||
# sudo systemctl enable --now media-server@$USER
|
||||
# %i is the user name supplied after the '@'; %U is the matching numeric UID.
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=%i
|
||||
Group=%i
|
||||
|
||||
# Environment variables (optional - can also use config file)
|
||||
# Environment=MEDIA_SERVER_HOST=0.0.0.0
|
||||
# Environment=MEDIA_SERVER_PORT=8765
|
||||
# Environment=MEDIA_SERVER_API_TOKEN=your-secret-token
|
||||
|
||||
# Working directory
|
||||
# Working directory (override via drop-in if you install elsewhere)
|
||||
WorkingDirectory=/opt/media-server
|
||||
|
||||
# Start command - adjust path to your Python environment
|
||||
ExecStart=/opt/media-server/venv/bin/python -m media_server.main
|
||||
# Start command — adjust to match where you installed the venv. --no-tray
|
||||
# avoids pulling pystray into a headless service environment.
|
||||
ExecStart=/opt/media-server/venv/bin/python -m media_server.main --no-tray
|
||||
|
||||
# Restart policy
|
||||
Restart=always
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
# Security hardening
|
||||
# Required for MPRIS (dbus.SessionBus) and PulseAudio/PipeWire loopback.
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=XDG_RUNTIME_DIR=/run/user/%U
|
||||
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%U/bus
|
||||
|
||||
# Light sandboxing. ProtectHome=read-only by itself blocks the app's own
|
||||
# audit.log / thumbnail cache writes — ReadWritePaths re-opens just the
|
||||
# two state dirs the server owns.
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
PrivateTmp=true
|
||||
|
||||
# Required for D-Bus access (MPRIS)
|
||||
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%U/bus
|
||||
ReadWritePaths=/home/%i/.config/media-server /home/%i/.cache/media-server
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -65,7 +65,12 @@ def get_media_controller() -> "MediaController":
|
||||
|
||||
|
||||
def get_current_album_art() -> bytes | None:
|
||||
"""Get the current album art bytes (Windows only for now)."""
|
||||
"""Get the current album art bytes (synchronous, Windows-cached path).
|
||||
|
||||
Windows pre-populates a module-level cache via the WinRT polling thread,
|
||||
so this stays sync. For Linux/macOS the controller fetches on demand —
|
||||
use ``get_current_album_art_async()`` from FastAPI handlers instead.
|
||||
"""
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
from .windows_media import get_current_album_art as _get_art
|
||||
@@ -73,6 +78,22 @@ def get_current_album_art() -> bytes | None:
|
||||
return None
|
||||
|
||||
|
||||
async def get_current_album_art_async() -> bytes | None:
|
||||
"""Cross-platform album art fetch. Awaits the controller's impl.
|
||||
|
||||
Falls back to the sync Windows cache when running on Windows so we don't
|
||||
pay an extra coroutine hop for the existing path.
|
||||
"""
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
return get_current_album_art()
|
||||
controller = get_media_controller()
|
||||
try:
|
||||
return await controller.get_album_art()
|
||||
except Exception: # noqa: BLE001 — art is best-effort; never break the route
|
||||
return None
|
||||
|
||||
|
||||
def get_audio_devices() -> list[dict[str, str]]:
|
||||
"""Get list of available audio output devices (Windows only for now)."""
|
||||
system = platform.system()
|
||||
@@ -82,4 +103,9 @@ def get_audio_devices() -> list[dict[str, str]]:
|
||||
return []
|
||||
|
||||
|
||||
__all__ = ["get_media_controller", "get_current_album_art", "get_audio_devices"]
|
||||
__all__ = [
|
||||
"get_media_controller",
|
||||
"get_current_album_art",
|
||||
"get_current_album_art_async",
|
||||
"get_audio_devices",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Audio spectrum analyzer service using system loopback capture."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import platform
|
||||
import threading
|
||||
import time
|
||||
@@ -15,10 +16,25 @@ def _load_numpy():
|
||||
global _np
|
||||
if _np is None:
|
||||
try:
|
||||
import os
|
||||
import sys
|
||||
if sys.platform == 'win32':
|
||||
# Embedded Python doesn't auto-load DLLs from numpy.libs;
|
||||
# add the directory explicitly so libopenblas can be found.
|
||||
try:
|
||||
import importlib.util
|
||||
spec = importlib.util.find_spec('numpy')
|
||||
if spec and spec.submodule_search_locations:
|
||||
numpy_dir = list(spec.submodule_search_locations)[0]
|
||||
libs_dir = os.path.join(os.path.dirname(numpy_dir), 'numpy.libs')
|
||||
if os.path.isdir(libs_dir):
|
||||
os.add_dll_directory(libs_dir)
|
||||
except Exception:
|
||||
pass
|
||||
import numpy as np
|
||||
_np = np
|
||||
except ImportError:
|
||||
logger.info("numpy not installed - audio visualizer unavailable")
|
||||
except Exception as e:
|
||||
logger.warning("numpy unavailable - audio visualizer disabled: %s", e)
|
||||
return _np
|
||||
|
||||
|
||||
@@ -28,8 +44,8 @@ def _load_soundcard():
|
||||
try:
|
||||
import soundcard as sc
|
||||
_sc = sc
|
||||
except ImportError:
|
||||
logger.info("soundcard not installed - audio visualizer unavailable")
|
||||
except Exception as e:
|
||||
logger.warning("soundcard unavailable - audio visualizer disabled: %s", e)
|
||||
return _sc
|
||||
|
||||
|
||||
@@ -56,6 +72,24 @@ class AudioAnalyzer:
|
||||
self._lifecycle_lock = threading.Lock()
|
||||
self._data: dict | None = None
|
||||
self._current_device_name: str | None = None
|
||||
# Sticky "no usable device" flag — flipped to True if a capture
|
||||
# attempt fails because no loopback device exists. Prevents the
|
||||
# WebSocket manager from looping on start()/stop()/start() forever
|
||||
# when there's nothing to capture. Cleared by set_device().
|
||||
self._unavailable = False
|
||||
# Generation counter — bumped each time _data is refreshed.
|
||||
# Lets the broadcast loop dedupe without comparing dict identity
|
||||
# (which is fragile because we always allocate a new dict).
|
||||
self._data_seq = 0
|
||||
# Threading.Event signaled when new frame data is available.
|
||||
# The broadcast loop awaits this instead of polling on a timer,
|
||||
# so it wakes up exactly once per produced frame.
|
||||
self._data_event = threading.Event()
|
||||
# Slow AGC envelope so the spectrum reflects real dynamics
|
||||
# instead of being renormalized to peak=1.0 every frame.
|
||||
# A loud transient (e.g. notification beep) lifts the reference
|
||||
# for a few seconds afterwards; this is the price of real loudness.
|
||||
self._spectrum_ref = 0.01
|
||||
|
||||
# Pre-compute logarithmic bin edges
|
||||
self._bin_edges = self._compute_bin_edges()
|
||||
@@ -94,6 +128,14 @@ class AudioAnalyzer:
|
||||
return True
|
||||
if not self.available:
|
||||
return False
|
||||
if self._unavailable:
|
||||
# We already tried and failed to acquire a device. Don't
|
||||
# spin a new capture thread for each new subscriber.
|
||||
return False
|
||||
|
||||
# Reset AGC envelope so a long silent gap between sessions
|
||||
# doesn't make the first new transients clip at the ceiling.
|
||||
self._spectrum_ref = 0.01
|
||||
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._capture_loop, daemon=True)
|
||||
@@ -104,17 +146,30 @@ class AudioAnalyzer:
|
||||
"""Stop audio capture and cleanup."""
|
||||
with self._lifecycle_lock:
|
||||
self._running = False
|
||||
# Wake any waiter so it can observe _running and exit cleanly.
|
||||
self._data_event.set()
|
||||
if self._thread:
|
||||
self._thread.join(timeout=3.0)
|
||||
self._thread = None
|
||||
with self._lock:
|
||||
self._data = None
|
||||
self._data_event.clear()
|
||||
|
||||
def get_frequency_data(self) -> dict | None:
|
||||
"""Return latest frequency data (thread-safe). None if not running."""
|
||||
with self._lock:
|
||||
return self._data
|
||||
|
||||
def get_frequency_data_versioned(self) -> tuple[dict | None, int]:
|
||||
"""Return (data, seq) so callers can dedupe without identity tricks."""
|
||||
with self._lock:
|
||||
return self._data, self._data_seq
|
||||
|
||||
@property
|
||||
def data_event(self) -> threading.Event:
|
||||
"""Event signaled when a fresh frame is ready. Caller must clear()."""
|
||||
return self._data_event
|
||||
|
||||
@staticmethod
|
||||
def list_loopback_devices() -> list[dict[str, str]]:
|
||||
"""List all available loopback audio devices."""
|
||||
@@ -189,6 +244,9 @@ class AudioAnalyzer:
|
||||
|
||||
self.device_name = device_name
|
||||
self._current_device_name = None
|
||||
# Clear the "no device" sticky flag — the user is asking for a
|
||||
# different device so it's worth attempting capture again.
|
||||
self._unavailable = False
|
||||
|
||||
if was_running:
|
||||
return self.start()
|
||||
@@ -223,15 +281,28 @@ class AudioAnalyzer:
|
||||
if device is None:
|
||||
logger.warning("No loopback audio device found - visualizer disabled")
|
||||
self._running = False
|
||||
self._unavailable = True
|
||||
return
|
||||
|
||||
interval = 1.0 / self.target_fps
|
||||
window = np.hanning(self.chunk_size)
|
||||
# Float32 window — matches soundcard's typical buffer dtype and
|
||||
# halves FFT memory traffic vs. the default float64.
|
||||
window = np.hanning(self.chunk_size).astype(np.float32)
|
||||
|
||||
# Pre-compute bin edge pairs for vectorized grouping
|
||||
edges = self._bin_edges
|
||||
bin_starts = np.array([edges[i] for i in range(self.num_bins)], dtype=np.intp)
|
||||
bin_ends = np.array([max(edges[i + 1], edges[i] + 1) for i in range(self.num_bins)], dtype=np.intp)
|
||||
# Counts are constant — compute once.
|
||||
bin_counts = (bin_ends - bin_starts).astype(np.float32)
|
||||
|
||||
# Pre-allocate working buffers so the per-frame allocator churn
|
||||
# on the capture thread (which runs at target_fps Hz, hours on
|
||||
# end) drops to zero copies for these arrays.
|
||||
fft_size = self.chunk_size // 2 + 1
|
||||
windowed = np.empty(self.chunk_size, dtype=np.float32)
|
||||
cumsum = np.empty(fft_size + 1, dtype=np.float32)
|
||||
cumsum[0] = 0.0
|
||||
|
||||
try:
|
||||
with device.recorder(
|
||||
@@ -260,29 +331,65 @@ class AudioAnalyzer:
|
||||
time.sleep(interval)
|
||||
continue
|
||||
|
||||
# Apply window and compute FFT
|
||||
windowed = mono[:self.chunk_size] * window
|
||||
# Apply window in-place into the pre-allocated buffer.
|
||||
np.multiply(mono[:self.chunk_size], window, out=windowed)
|
||||
fft_mag = np.abs(np.fft.rfft(windowed))
|
||||
|
||||
# Group into logarithmic bins (vectorized via cumsum)
|
||||
cumsum = np.concatenate(([0.0], np.cumsum(fft_mag)))
|
||||
counts = bin_ends - bin_starts
|
||||
bins = (cumsum[bin_ends] - cumsum[bin_starts]) / counts
|
||||
# Group into logarithmic bins (vectorized via cumsum).
|
||||
# Write into the pre-allocated [1:] slice so cumsum[0]
|
||||
# stays 0.0 and we never allocate a new array.
|
||||
np.cumsum(fft_mag, out=cumsum[1:])
|
||||
bins = (cumsum[bin_ends] - cumsum[bin_starts]) / bin_counts
|
||||
|
||||
# Normalize to 0-1
|
||||
max_val = bins.max()
|
||||
if max_val > 0:
|
||||
bins *= (1.0 / max_val)
|
||||
# True loudness from time-domain RMS via single BLAS
|
||||
# dot — avoids astype() and ** allocations.
|
||||
mono32 = mono if mono.dtype == np.float32 else mono.astype(np.float32, copy=False)
|
||||
energy = float(np.dot(mono32, mono32))
|
||||
if energy > 1e-12:
|
||||
rms = (energy / mono32.size) ** 0.5
|
||||
db = 20.0 * math.log10(rms)
|
||||
# Map -60 dB..-6 dB to 0..1 (typical music range)
|
||||
level = max(0.0, min(1.0, (db + 60.0) / 54.0))
|
||||
else:
|
||||
level = 0.0
|
||||
|
||||
# Slow auto-gain: envelope follower with fast attack,
|
||||
# slow release. Quiet music yields small bars; loud
|
||||
# passages reach the top; the reference adapts over
|
||||
# seconds instead of resetting every frame.
|
||||
current_peak = float(bins.max())
|
||||
if current_peak > self._spectrum_ref:
|
||||
self._spectrum_ref += (current_peak - self._spectrum_ref) * 0.05
|
||||
else:
|
||||
self._spectrum_ref += (current_peak - self._spectrum_ref) * 0.005
|
||||
ref = max(self._spectrum_ref, 1e-4)
|
||||
np.divide(bins, ref, out=bins)
|
||||
np.clip(bins, 0.0, 1.5, out=bins)
|
||||
|
||||
# Bass energy: average of first 4 bins (~20-200Hz)
|
||||
bass = float(bins[:4].mean()) if self.num_bins >= 4 else 0.0
|
||||
|
||||
# Round for compact JSON
|
||||
frequencies = np.round(bins, 3).tolist()
|
||||
bass = round(bass, 3)
|
||||
# Quantize to 0..1000 ints — same wire fidelity as
|
||||
# 3-decimal floats but smaller GC churn on both ends
|
||||
# (frontend smooths anyway, so quantization is
|
||||
# invisible). JSON encodes ints faster than floats.
|
||||
frequencies = (bins * 1000.0).astype(np.int16).tolist()
|
||||
bass_i = int(bass * 1000.0)
|
||||
level_i = int(level * 1000.0)
|
||||
|
||||
new_data = {
|
||||
"frequencies": frequencies,
|
||||
"bass": bass_i,
|
||||
"level": level_i,
|
||||
# Wire-format flag: clients that see this know
|
||||
# values are 0..1000 ints, not 0..1 floats.
|
||||
"scale": 1000,
|
||||
}
|
||||
with self._lock:
|
||||
self._data = {"frequencies": frequencies, "bass": bass}
|
||||
self._data = new_data
|
||||
self._data_seq += 1
|
||||
# Wake any broadcast loop waiting on fresh data.
|
||||
self._data_event.set()
|
||||
|
||||
# Throttle to target FPS
|
||||
elapsed = time.monotonic() - t0
|
||||
|
||||
@@ -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)
|
||||
@@ -63,14 +63,28 @@ class BrowserService:
|
||||
if not base_path.is_dir():
|
||||
raise ValueError(f"Media folder path is not a directory: {base_path}")
|
||||
|
||||
# Handle relative vs absolute paths
|
||||
if requested_path.startswith("/") or requested_path.startswith("\\"):
|
||||
# Relative to folder root (remove leading slash)
|
||||
requested_path = requested_path.lstrip("/\\")
|
||||
# Reject absolute paths, drive letters, UNC paths, and NUL bytes outright.
|
||||
# Only true folder-relative paths are accepted.
|
||||
if "\x00" in requested_path:
|
||||
raise ValueError("Path contains NUL byte")
|
||||
|
||||
# Strip a single leading "/" or "\\" (legacy callers send "/sub/dir") but
|
||||
# then refuse anything that still looks absolute.
|
||||
cleaned = requested_path.lstrip("/\\")
|
||||
# Detect Windows drive letter like "C:/..." after stripping.
|
||||
if len(cleaned) >= 2 and cleaned[1] == ":":
|
||||
raise ValueError("Absolute paths are not allowed")
|
||||
# Detect raw UNC ("\\\\server\\share") — the lstrip above strips at most
|
||||
# one leading slash, so a UNC original starts with another "\\" or "/".
|
||||
if cleaned.startswith("\\") or cleaned.startswith("/"):
|
||||
raise ValueError("Absolute paths are not allowed")
|
||||
candidate = Path(cleaned) if cleaned else None
|
||||
if candidate is not None and candidate.is_absolute():
|
||||
raise ValueError("Absolute paths are not allowed")
|
||||
|
||||
# Build and resolve full path
|
||||
if requested_path:
|
||||
full_path = (base_path / requested_path).resolve()
|
||||
if cleaned:
|
||||
full_path = (base_path / cleaned).resolve()
|
||||
else:
|
||||
full_path = base_path
|
||||
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Extract page-level metadata from a focused desktop web browser.
|
||||
|
||||
The browser's window title is the reliable signal — every major browser
|
||||
formats it as ``"<page title> - <Browser Name>"``, so stripping the suffix
|
||||
gives us the page title for free.
|
||||
|
||||
URL extraction was attempted via UI Automation (UIA), but Chromium-based
|
||||
browsers (Chrome/Edge/Brave/Vivaldi) keep their accessibility tree dormant
|
||||
unless a screen reader is active or ``--force-renderer-accessibility`` is
|
||||
set — neither is something we want to require from end users. The UIA
|
||||
machinery is still here behind a feature flag in case a future caller
|
||||
opts into the accessibility-flag path; by default we just return the
|
||||
page title and leave ``url=None``.
|
||||
|
||||
Other platforms (macOS via AppleScript, Linux via AT-SPI) are out of scope
|
||||
for this iteration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# UIA URL extraction is opt-in because Chromium browsers keep their
|
||||
# accessibility tree dormant unless the user starts the browser with
|
||||
# ``--force-renderer-accessibility`` (or a screen reader is running).
|
||||
# Without that, `FindAll` throws and we'd burn 5s per probe retrying.
|
||||
# Set MEDIA_SERVER_BROWSER_UIA=1 to enable; default off.
|
||||
_UIA_ENABLED = os.environ.get("MEDIA_SERVER_BROWSER_UIA", "").lower() in (
|
||||
"1", "true", "yes", "on"
|
||||
)
|
||||
|
||||
|
||||
# Known browser executables (lowercase, .exe-stripped). Used to decide
|
||||
# whether to spend the UIA query budget on this foreground process.
|
||||
BROWSER_PROCESS_HINTS: frozenset[str] = frozenset({
|
||||
"chrome",
|
||||
"msedge",
|
||||
"firefox",
|
||||
"brave",
|
||||
"opera",
|
||||
"vivaldi",
|
||||
"yandex",
|
||||
"browser", # Yandex Browser sometimes reports as browser.exe
|
||||
"arc",
|
||||
"thorium",
|
||||
})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BrowserPageInfo:
|
||||
url: str | None = None
|
||||
page_title: str | None = None
|
||||
|
||||
|
||||
_EMPTY = BrowserPageInfo()
|
||||
|
||||
|
||||
def is_browser_process(process_name: str | None) -> bool:
|
||||
"""Return True when ``process_name`` looks like a supported browser."""
|
||||
if not process_name:
|
||||
return False
|
||||
base = process_name.lower()
|
||||
if base.endswith(".exe"):
|
||||
base = base[:-4]
|
||||
return base in BROWSER_PROCESS_HINTS
|
||||
|
||||
|
||||
def _strip_browser_suffix(title: str | None, process_name: str | None) -> str | None:
|
||||
"""Pull the page title out of the browser's window title.
|
||||
|
||||
Most browsers format their window title as ``"<page> - <Browser Name>"``.
|
||||
We strip the trailing suffix so consumers get the page title alone. If
|
||||
the suffix can't be matched, return the raw title unchanged.
|
||||
"""
|
||||
if not title:
|
||||
return None
|
||||
suffixes = (
|
||||
" - Google Chrome",
|
||||
" — Google Chrome",
|
||||
" - Microsoft Edge",
|
||||
" - Microsoft Edge",
|
||||
" — Mozilla Firefox",
|
||||
" - Mozilla Firefox",
|
||||
" - Brave",
|
||||
" - Opera",
|
||||
" - Vivaldi",
|
||||
" - Yandex",
|
||||
)
|
||||
for s in suffixes:
|
||||
if title.endswith(s):
|
||||
return title[: -len(s)].strip() or None
|
||||
return title
|
||||
|
||||
|
||||
# ─── UIA lookup (Windows) ───────────────────────────────────────────
|
||||
|
||||
# UIA control type / property constants we need. Avoiding the full
|
||||
# UIAutomationClient typelib generation — those constants are stable.
|
||||
_UIA_EditControlTypeId = 50004
|
||||
_UIA_ControlTypePropertyId = 30003
|
||||
_UIA_ValueValuePropertyId = 30045
|
||||
_UIA_NamePropertyId = 30005
|
||||
_UIA_ValuePatternId = 10002
|
||||
_TreeScope_Descendants = 4
|
||||
_PropertyConditionFlags_IgnoreCase = 1
|
||||
|
||||
|
||||
# Lazy import + per-thread COM init.
|
||||
_uia_lock = threading.Lock()
|
||||
_uia_singleton = None
|
||||
_uia_load_error: str | None = None
|
||||
_uia_thread_local = threading.local()
|
||||
|
||||
|
||||
def _ensure_com() -> None:
|
||||
"""Initialise COM on the current thread (idempotent per thread)."""
|
||||
if getattr(_uia_thread_local, "initialised", False):
|
||||
return
|
||||
try:
|
||||
import comtypes # type: ignore
|
||||
|
||||
# COINIT_APARTMENTTHREADED is required by UIA; comtypes' default
|
||||
# CoInitializeEx already passes that flag.
|
||||
comtypes.CoInitialize()
|
||||
_uia_thread_local.initialised = True
|
||||
except Exception as e:
|
||||
logger.debug("CoInitialize failed: %s", e)
|
||||
|
||||
|
||||
def _get_uia():
|
||||
"""Return the IUIAutomation singleton, or None if unavailable."""
|
||||
global _uia_singleton, _uia_load_error
|
||||
if _uia_singleton is not None:
|
||||
return _uia_singleton
|
||||
if _uia_load_error is not None:
|
||||
return None
|
||||
with _uia_lock:
|
||||
if _uia_singleton is not None:
|
||||
return _uia_singleton
|
||||
try:
|
||||
import comtypes.client # type: ignore
|
||||
|
||||
# CLSID for CUIAutomation. Using GetActiveObject would fail,
|
||||
# so we cocreate. comtypes.client.CreateObject keeps the COM
|
||||
# plumbing tidy.
|
||||
_uia_singleton = comtypes.client.CreateObject(
|
||||
"{ff48dba4-60ef-4201-aa87-54103eef594e}",
|
||||
interface=comtypes.client.GetModule(
|
||||
"UIAutomationCore.dll"
|
||||
).IUIAutomation,
|
||||
)
|
||||
return _uia_singleton
|
||||
except Exception as e:
|
||||
_uia_load_error = str(e)
|
||||
logger.info("UIA unavailable; browser URL extraction disabled: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _find_address_bar_value(hwnd: int) -> str | None:
|
||||
"""Walk the UIA tree under ``hwnd`` looking for the URL Edit control.
|
||||
|
||||
Strategy: find every descendant Edit control, then pick the first one
|
||||
whose Name contains an address-bar hint, or — failing that — the first
|
||||
one whose value parses as a URL-ish string. Browsers expose extra Edit
|
||||
controls (search bars, find-in-page) so name matching is the reliable
|
||||
signal; the URL-ish fallback covers locale variants we haven't seen.
|
||||
"""
|
||||
_ensure_com()
|
||||
uia = _get_uia()
|
||||
if uia is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
element = uia.ElementFromHandle(hwnd)
|
||||
if not element:
|
||||
return None
|
||||
|
||||
# Build a condition matching ControlType=Edit, then enumerate.
|
||||
edit_condition = uia.CreatePropertyCondition(
|
||||
_UIA_ControlTypePropertyId, _UIA_EditControlTypeId
|
||||
)
|
||||
edits = element.FindAll(_TreeScope_Descendants, edit_condition)
|
||||
count = edits.Length if edits else 0
|
||||
if count == 0:
|
||||
return None
|
||||
|
||||
# Hints (lowercase) used to identify the address bar by its Name
|
||||
# property. Covers en-US plus a few common locales / browsers.
|
||||
name_hints = (
|
||||
"address", # Chrome/Edge: "Address and search bar"
|
||||
"адрес", # Chrome ru: "Адресная строка и строка поиска"
|
||||
"адресная",
|
||||
"search with", # Firefox: "Search with Google or enter address"
|
||||
"поиск или ввод", # Firefox ru
|
||||
"url",
|
||||
"location",
|
||||
)
|
||||
|
||||
# First pass: name-based match (high confidence).
|
||||
candidates: list[tuple[int, str]] = []
|
||||
for i in range(count):
|
||||
edit = edits.GetElement(i)
|
||||
try:
|
||||
name = (edit.CurrentName or "").lower()
|
||||
except Exception:
|
||||
name = ""
|
||||
try:
|
||||
value = edit.GetCurrentPropertyValue(_UIA_ValueValuePropertyId)
|
||||
except Exception:
|
||||
value = None
|
||||
if value is None:
|
||||
continue
|
||||
value_str = str(value)
|
||||
for h in name_hints:
|
||||
if h in name:
|
||||
return value_str
|
||||
candidates.append((i, value_str))
|
||||
|
||||
# Second pass: URL-ish fallback. Pick the first candidate that
|
||||
# looks like a URL; this catches browser/locale combos we haven't
|
||||
# listed above.
|
||||
for _i, v in candidates:
|
||||
lv = v.lower()
|
||||
if (
|
||||
lv.startswith("http://")
|
||||
or lv.startswith("https://")
|
||||
or lv.startswith("about:")
|
||||
or lv.startswith("chrome://")
|
||||
or lv.startswith("edge://")
|
||||
or lv.startswith("brave://")
|
||||
or lv.startswith("file://")
|
||||
or lv.startswith("ftp://")
|
||||
):
|
||||
return v
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug("UIA address-bar lookup failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
# ─── Per-(hwnd, title) cache ────────────────────────────────────────
|
||||
|
||||
_cache_lock = threading.Lock()
|
||||
_cache_key: tuple[int | None, str | None] = (None, None)
|
||||
_cache_value: BrowserPageInfo = _EMPTY
|
||||
|
||||
|
||||
def get_browser_page(
|
||||
*,
|
||||
hwnd: int | None,
|
||||
process_name: str | None,
|
||||
window_title: str | None,
|
||||
) -> BrowserPageInfo:
|
||||
"""Return the URL + page title for the foreground browser tab, if any.
|
||||
|
||||
Callers pass the already-resolved foreground HWND/title/process_name so
|
||||
this service doesn't re-walk Win32 to find them. Returns ``_EMPTY`` for
|
||||
non-browser processes or when UIA can't resolve the URL.
|
||||
"""
|
||||
if not is_browser_process(process_name):
|
||||
return _EMPTY
|
||||
if platform.system() != "Windows":
|
||||
# macOS/Linux paths not implemented in this iteration.
|
||||
return _EMPTY
|
||||
if not hwnd:
|
||||
return _EMPTY
|
||||
|
||||
global _cache_key, _cache_value
|
||||
key = (hwnd, window_title)
|
||||
with _cache_lock:
|
||||
if key == _cache_key and _cache_value is not _EMPTY:
|
||||
return _cache_value
|
||||
|
||||
url = _find_address_bar_value(hwnd) if _UIA_ENABLED else None
|
||||
page_title = _strip_browser_suffix(window_title, process_name)
|
||||
info = BrowserPageInfo(url=url, page_title=page_title)
|
||||
|
||||
with _cache_lock:
|
||||
_cache_key = key
|
||||
_cache_value = info
|
||||
return info
|
||||
|
||||
|
||||
def reset_cache() -> None:
|
||||
"""Reset the cache. Useful in tests."""
|
||||
global _cache_key, _cache_value
|
||||
with _cache_lock:
|
||||
_cache_key = (None, None)
|
||||
_cache_value = _EMPTY
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Display brightness and power control service."""
|
||||
"""Display brightness, power, contrast, input-source and color-preset control."""
|
||||
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
@@ -6,10 +6,33 @@ import logging
|
||||
import platform
|
||||
import struct
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# VCP 0xDC "Display Application" — picture / scene mode.
|
||||
# Vendors deviate from the MCCS spec, but these labels match the standard
|
||||
# meanings and cover what most monitors report through their capability
|
||||
# string. Unknown codes fall back to "Mode <n>".
|
||||
PICTURE_MODE_VCP = 0xDC
|
||||
PICTURE_MODE_LABELS: dict[int, str] = {
|
||||
0x00: "Default",
|
||||
0x01: "Standalone",
|
||||
0x02: "Mixed",
|
||||
0x03: "Productivity",
|
||||
0x04: "Movie",
|
||||
0x05: "Game",
|
||||
0x06: "Sports",
|
||||
0x07: "Professional",
|
||||
0x08: "Standard",
|
||||
0x09: "Default",
|
||||
0x0A: "Movie (Reduced Effects)",
|
||||
0x0B: "Movie (Enhanced)",
|
||||
0x0C: "User 1",
|
||||
0x0D: "User 2",
|
||||
0x0E: "User 3",
|
||||
}
|
||||
|
||||
_sbc = None
|
||||
_monitorcontrol = None
|
||||
|
||||
@@ -32,7 +55,7 @@ def _load_monitorcontrol():
|
||||
import monitorcontrol
|
||||
_monitorcontrol = monitorcontrol
|
||||
except ImportError:
|
||||
logger.warning("monitorcontrol not installed - display power control unavailable")
|
||||
logger.warning("monitorcontrol not installed - DDC/CI control unavailable")
|
||||
return _monitorcontrol
|
||||
|
||||
|
||||
@@ -64,6 +87,18 @@ class MonitorInfo:
|
||||
manufacturer: str = ""
|
||||
resolution: str | None = None
|
||||
is_primary: bool = False
|
||||
contrast: int | None = None
|
||||
contrast_supported: bool = False
|
||||
input_source: str | None = None
|
||||
available_input_sources: list[str] = field(default_factory=list)
|
||||
input_source_supported: bool = False
|
||||
color_preset: str | None = None
|
||||
available_color_presets: list[str] = field(default_factory=list)
|
||||
color_preset_supported: bool = False
|
||||
picture_mode: str | None = None
|
||||
picture_mode_code: int | None = None
|
||||
available_picture_modes: list[dict] = field(default_factory=list)
|
||||
picture_mode_supported: bool = False
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -76,6 +111,18 @@ class MonitorInfo:
|
||||
"manufacturer": self.manufacturer,
|
||||
"resolution": self.resolution,
|
||||
"is_primary": self.is_primary,
|
||||
"contrast": self.contrast,
|
||||
"contrast_supported": self.contrast_supported,
|
||||
"input_source": self.input_source,
|
||||
"available_input_sources": self.available_input_sources,
|
||||
"input_source_supported": self.input_source_supported,
|
||||
"color_preset": self.color_preset,
|
||||
"available_color_presets": self.available_color_presets,
|
||||
"color_preset_supported": self.color_preset_supported,
|
||||
"picture_mode": self.picture_mode,
|
||||
"picture_mode_code": self.picture_mode_code,
|
||||
"available_picture_modes": self.available_picture_modes,
|
||||
"picture_mode_supported": self.picture_mode_supported,
|
||||
}
|
||||
|
||||
|
||||
@@ -137,17 +184,184 @@ def _mark_primary(monitors: list[MonitorInfo]) -> None:
|
||||
monitors[0].is_primary = True
|
||||
|
||||
|
||||
# Cache for monitor list
|
||||
# Short TTL cache of the assembled monitor list (full response).
|
||||
_monitor_cache: list[MonitorInfo] | None = None
|
||||
_cache_time: float = 0
|
||||
_CACHE_TTL = 5.0 # seconds
|
||||
|
||||
# Per-monitor cache of static capabilities (option lists + support flags).
|
||||
# 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
|
||||
# it across refreshes. Keyed by a stable identity tuple
|
||||
# (manufacturer, model, edid_hash) so that hot-plug swaps where the new
|
||||
# topology has the same number of monitors but different devices still
|
||||
# refresh the cache for the new monitor instead of serving stale capabilities.
|
||||
_static_cache: dict[tuple, dict] = {}
|
||||
|
||||
def list_monitors(force_refresh: bool = False) -> list[MonitorInfo]:
|
||||
"""List all connected monitors with their current brightness."""
|
||||
|
||||
def _enum_name(value, enum_cls=None) -> str | None:
|
||||
"""Best-effort name for an enum or raw int returned by monitorcontrol.
|
||||
|
||||
monitorcontrol's getters sometimes hand back raw ints when the monitor
|
||||
reports a value the library wraps incompletely. Re-map those through the
|
||||
matching enum class so HA selects still receive symbolic option names.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
name = getattr(value, "name", None)
|
||||
if name:
|
||||
return name
|
||||
if enum_cls is not None:
|
||||
try:
|
||||
return enum_cls(value).name
|
||||
except (ValueError, KeyError):
|
||||
pass
|
||||
return str(value)
|
||||
|
||||
|
||||
def _probe_static_open(mon, mc, monitor_id: int) -> dict:
|
||||
"""Probe per-monitor static capabilities.
|
||||
|
||||
Must be called inside an open `with mon:` DDC/CI context. Tries each
|
||||
feature once to confirm the monitor responds, and enumerates option
|
||||
lists from the capability string. Heavy: this is what the cache is for.
|
||||
"""
|
||||
static = {
|
||||
"contrast_supported": False,
|
||||
"input_source_supported": False,
|
||||
"available_input_sources": [],
|
||||
"color_preset_supported": False,
|
||||
"available_color_presets": [],
|
||||
"picture_mode_supported": False,
|
||||
"available_picture_modes": [],
|
||||
}
|
||||
|
||||
try:
|
||||
caps = mon.get_vcp_capabilities() or {}
|
||||
except Exception as e:
|
||||
caps = {}
|
||||
logger.debug("Monitor %d: get_vcp_capabilities failed: %s", monitor_id, e)
|
||||
|
||||
try:
|
||||
mon.get_contrast()
|
||||
static["contrast_supported"] = True
|
||||
except Exception as e:
|
||||
logger.debug("Monitor %d: contrast unsupported: %s", monitor_id, e)
|
||||
|
||||
try:
|
||||
mon.get_input_source()
|
||||
static["input_source_supported"] = True
|
||||
except Exception as e:
|
||||
logger.debug("Monitor %d: input_source unsupported: %s", monitor_id, e)
|
||||
|
||||
inputs = caps.get("inputs") or []
|
||||
input_enum = mc.InputSource if mc else None
|
||||
static["available_input_sources"] = [
|
||||
n for n in (_enum_name(s, input_enum) for s in inputs) if n is not None
|
||||
]
|
||||
|
||||
try:
|
||||
mon.get_color_preset()
|
||||
static["color_preset_supported"] = True
|
||||
if mc is not None:
|
||||
static["available_color_presets"] = [p.name for p in mc.ColorPreset]
|
||||
except Exception as e:
|
||||
logger.debug("Monitor %d: color_preset unsupported: %s", monitor_id, e)
|
||||
|
||||
# Picture / scene mode (VCP 0xDC). Trickier than color preset because
|
||||
# many monitors (LG ultrawides included) respond to READS but silently
|
||||
# drop every WRITE - they implement the register but not the feature.
|
||||
# The capability string is the most reliable signal: a monitor that
|
||||
# really implements picture mode declares its supported codes under
|
||||
# cmds[0xDC]. If 0xDC isn't declared, treat the feature as unsupported
|
||||
# to avoid exposing a non-functional select.
|
||||
cmds = caps.get("cmds") or {}
|
||||
declared = cmds.get(PICTURE_MODE_VCP)
|
||||
if declared:
|
||||
try:
|
||||
mon.vcp.get_vcp_feature(PICTURE_MODE_VCP)
|
||||
static["picture_mode_supported"] = True
|
||||
static["available_picture_modes"] = [
|
||||
{"code": c, "label": PICTURE_MODE_LABELS.get(c, f"Mode {c}")}
|
||||
for c in sorted(declared)
|
||||
]
|
||||
except Exception as e:
|
||||
logger.debug("Monitor %d: picture_mode declared but unreadable: %s", monitor_id, e)
|
||||
else:
|
||||
logger.debug("Monitor %d: picture_mode (VCP 0xDC) not declared in capability string", monitor_id)
|
||||
|
||||
return static
|
||||
|
||||
|
||||
def _probe_dynamic_open(mon, mc, monitor_id: int, static: dict) -> dict:
|
||||
"""Read current values for features known to be supported.
|
||||
|
||||
Must be called inside an open `with mon:` context. Skips reads for
|
||||
unsupported features (saves one I2C roundtrip each), so the warm path
|
||||
only touches features the monitor actually responds to.
|
||||
"""
|
||||
dynamic = {
|
||||
"power_on": True,
|
||||
"contrast": None,
|
||||
"input_source": None,
|
||||
"color_preset": None,
|
||||
"picture_mode": None,
|
||||
"picture_mode_code": None,
|
||||
}
|
||||
|
||||
try:
|
||||
dynamic["power_on"] = mon.get_power_mode() == mc.PowerMode.on
|
||||
except Exception as e:
|
||||
logger.debug("Monitor %d: power readback failed: %s", monitor_id, e)
|
||||
|
||||
if static.get("contrast_supported"):
|
||||
try:
|
||||
dynamic["contrast"] = mon.get_contrast()
|
||||
except Exception as e:
|
||||
logger.debug("Monitor %d: contrast readback failed: %s", monitor_id, e)
|
||||
|
||||
if static.get("input_source_supported"):
|
||||
try:
|
||||
src = mon.get_input_source()
|
||||
dynamic["input_source"] = _enum_name(src, mc.InputSource if mc else None)
|
||||
except Exception as e:
|
||||
logger.debug("Monitor %d: input_source readback failed: %s", monitor_id, e)
|
||||
|
||||
if static.get("color_preset_supported"):
|
||||
try:
|
||||
preset = mon.get_color_preset()
|
||||
dynamic["color_preset"] = _enum_name(preset, mc.ColorPreset if mc else None)
|
||||
except Exception as e:
|
||||
logger.debug("Monitor %d: color_preset readback failed: %s", monitor_id, e)
|
||||
|
||||
if static.get("picture_mode_supported"):
|
||||
try:
|
||||
current, _maximum = mon.vcp.get_vcp_feature(PICTURE_MODE_VCP)
|
||||
dynamic["picture_mode_code"] = current
|
||||
dynamic["picture_mode"] = PICTURE_MODE_LABELS.get(current, f"Mode {current}")
|
||||
except Exception as e:
|
||||
logger.debug("Monitor %d: picture_mode readback failed: %s", monitor_id, e)
|
||||
|
||||
return dynamic
|
||||
|
||||
|
||||
def list_monitors(force_refresh: bool = False, rediscover: bool = False) -> list[MonitorInfo]:
|
||||
"""List all connected monitors with their current state.
|
||||
|
||||
Args:
|
||||
force_refresh: bypass the short TTL response cache.
|
||||
rediscover: also drop the per-monitor static capability cache, so the
|
||||
next probe re-runs DDC/CI capability discovery. Use after hot-plug
|
||||
or when a monitor's reported capabilities change.
|
||||
"""
|
||||
global _monitor_cache, _cache_time
|
||||
|
||||
if not force_refresh and _monitor_cache is not None and (time.time() - _cache_time) < _CACHE_TTL:
|
||||
if (
|
||||
not force_refresh
|
||||
and not rediscover
|
||||
and _monitor_cache is not None
|
||||
and (time.time() - _cache_time) < _CACHE_TTL
|
||||
):
|
||||
return _monitor_cache
|
||||
|
||||
sbc = _load_sbc()
|
||||
@@ -159,7 +373,12 @@ def list_monitors(force_refresh: bool = False) -> list[MonitorInfo]:
|
||||
info_list = sbc.list_monitors_info()
|
||||
brightnesses = sbc.get_brightness()
|
||||
|
||||
# Get DDC/CI monitors for power state
|
||||
# Explicit rediscover wipes the whole cache; otherwise rely on stable
|
||||
# per-monitor keys (manufacturer|model|edid_hash) so a hot-plug swap
|
||||
# invalidates the entry for the missing monitor automatically.
|
||||
if rediscover:
|
||||
_static_cache.clear()
|
||||
|
||||
mc = _load_monitorcontrol()
|
||||
ddc_monitors = []
|
||||
if mc:
|
||||
@@ -168,6 +387,9 @@ def list_monitors(force_refresh: bool = False) -> list[MonitorInfo]:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
import hashlib
|
||||
|
||||
seen_keys: set[tuple] = set()
|
||||
for i, info in enumerate(info_list):
|
||||
name = info.get("name", f"Monitor {i}")
|
||||
model = info.get("model", "")
|
||||
@@ -181,26 +403,66 @@ def list_monitors(force_refresh: bool = False) -> list[MonitorInfo]:
|
||||
edid = info.get("edid", "")
|
||||
resolution = _parse_edid_resolution(edid) if edid else None
|
||||
|
||||
# Read power state via DDC/CI
|
||||
power_on = True
|
||||
# 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 = {}
|
||||
dynamic: dict = {}
|
||||
|
||||
# Open the DDC handle ONCE; do static probe (if needed) + dynamic
|
||||
# readback inside the same context. Opening the handle is the
|
||||
# expensive part — keep both phases under one open.
|
||||
if power_supported and i < len(ddc_monitors):
|
||||
try:
|
||||
with ddc_monitors[i] as mon:
|
||||
power_mode = mon.get_power_mode()
|
||||
power_on = power_mode == mc.PowerMode.on
|
||||
except Exception:
|
||||
pass
|
||||
if cache_key not in _static_cache:
|
||||
_static_cache[cache_key] = _probe_static_open(mon, mc, i)
|
||||
static = _static_cache[cache_key]
|
||||
dynamic = _probe_dynamic_open(mon, mc, i, static)
|
||||
except Exception as e:
|
||||
logger.debug("Monitor %d: DDC/CI session failed: %s", i, e)
|
||||
static = _static_cache.get(cache_key, {})
|
||||
|
||||
monitors.append(MonitorInfo(
|
||||
id=i,
|
||||
name=name,
|
||||
brightness=brightness,
|
||||
power_supported=power_supported,
|
||||
power_on=power_on,
|
||||
power_on=dynamic.get("power_on", True),
|
||||
model=model,
|
||||
manufacturer=manufacturer,
|
||||
resolution=resolution,
|
||||
contrast=dynamic.get("contrast"),
|
||||
contrast_supported=static.get("contrast_supported", False),
|
||||
input_source=dynamic.get("input_source"),
|
||||
available_input_sources=static.get("available_input_sources", []),
|
||||
input_source_supported=static.get("input_source_supported", False),
|
||||
color_preset=dynamic.get("color_preset"),
|
||||
available_color_presets=static.get("available_color_presets", []),
|
||||
color_preset_supported=static.get("color_preset_supported", False),
|
||||
picture_mode=dynamic.get("picture_mode"),
|
||||
picture_mode_code=dynamic.get("picture_mode_code"),
|
||||
available_picture_modes=static.get("available_picture_modes", []),
|
||||
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:
|
||||
logger.error("Failed to enumerate monitors: %s", e)
|
||||
|
||||
@@ -234,9 +496,7 @@ def set_brightness(monitor_id: int, value: int) -> bool:
|
||||
value = max(0, min(100, value))
|
||||
try:
|
||||
sbc.set_brightness(value, display=monitor_id)
|
||||
# Invalidate cache
|
||||
global _monitor_cache
|
||||
_monitor_cache = None
|
||||
_invalidate_cache()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to set brightness for monitor %d: %s", monitor_id, e)
|
||||
@@ -262,10 +522,155 @@ def set_power(monitor_id: int, on: bool) -> bool:
|
||||
else:
|
||||
monitor.set_power_mode(mc.PowerMode.off_soft)
|
||||
|
||||
# Invalidate cache
|
||||
global _monitor_cache
|
||||
_monitor_cache = None
|
||||
_invalidate_cache()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to set power for monitor %d: %s", monitor_id, e)
|
||||
return False
|
||||
|
||||
|
||||
def _verify_after_set(getter, expected, *, retries: int = 3, delay: float = 0.1) -> bool:
|
||||
"""Poll a DDC/CI getter to confirm the monitor actually applied a write.
|
||||
|
||||
DDC/CI writes are fire-and-forget at the protocol level: a successful
|
||||
send does not mean the monitor honored the value. Many monitors silently
|
||||
drop writes for codes their firmware doesn't really implement (LG's
|
||||
ColorPreset / Picture Mode are common offenders). Without this check the
|
||||
API would report `success: true` while the monitor sat unchanged.
|
||||
|
||||
Compares both raw and `.value` forms so enum/int mismatches don't flag a
|
||||
spurious failure.
|
||||
"""
|
||||
expected_int = getattr(expected, "value", expected)
|
||||
for _ in range(retries):
|
||||
time.sleep(delay)
|
||||
try:
|
||||
actual = getter()
|
||||
except Exception:
|
||||
continue
|
||||
actual_int = getattr(actual, "value", actual)
|
||||
if actual == expected or actual_int == expected_int:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def set_contrast(monitor_id: int, value: int) -> bool:
|
||||
"""Set contrast for a specific monitor (0-100) via DDC/CI."""
|
||||
mc = _load_monitorcontrol()
|
||||
if mc is None:
|
||||
return False
|
||||
|
||||
value = max(0, min(100, value))
|
||||
try:
|
||||
ddc_monitors = mc.get_monitors()
|
||||
if monitor_id >= len(ddc_monitors):
|
||||
return False
|
||||
with ddc_monitors[monitor_id] as monitor:
|
||||
monitor.set_contrast(value)
|
||||
if not _verify_after_set(monitor.get_contrast, value):
|
||||
logger.warning("Monitor %d: contrast %d not applied", monitor_id, value)
|
||||
return False
|
||||
_invalidate_cache()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to set contrast for monitor %d: %s", monitor_id, e)
|
||||
return False
|
||||
|
||||
|
||||
def set_input_source(monitor_id: int, source: str) -> bool:
|
||||
"""Set the DDC/CI input source by enum name (e.g. 'HDMI1', 'DP1')."""
|
||||
mc = _load_monitorcontrol()
|
||||
if mc is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
target = mc.InputSource[source]
|
||||
except KeyError:
|
||||
logger.error("Unknown input source: %s", source)
|
||||
return False
|
||||
|
||||
try:
|
||||
ddc_monitors = mc.get_monitors()
|
||||
if monitor_id >= len(ddc_monitors):
|
||||
return False
|
||||
with ddc_monitors[monitor_id] as monitor:
|
||||
monitor.set_input_source(target)
|
||||
# Source switches can briefly disrupt the DDC/CI link; allow a
|
||||
# longer settle window before declaring failure.
|
||||
if not _verify_after_set(monitor.get_input_source, target, retries=5, delay=0.2):
|
||||
logger.warning("Monitor %d: input source %s not applied", monitor_id, source)
|
||||
return False
|
||||
_invalidate_cache()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to set input source for monitor %d: %s", monitor_id, e)
|
||||
return False
|
||||
|
||||
|
||||
def set_color_preset(monitor_id: int, preset: str) -> bool:
|
||||
"""Set the DDC/CI color preset by enum name (e.g. 'COLOR_TEMP_6500K')."""
|
||||
mc = _load_monitorcontrol()
|
||||
if mc is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
target = mc.ColorPreset[preset]
|
||||
except KeyError:
|
||||
logger.error("Unknown color preset: %s", preset)
|
||||
return False
|
||||
|
||||
try:
|
||||
ddc_monitors = mc.get_monitors()
|
||||
if monitor_id >= len(ddc_monitors):
|
||||
return False
|
||||
with ddc_monitors[monitor_id] as monitor:
|
||||
monitor.set_color_preset(target)
|
||||
if not _verify_after_set(monitor.get_color_preset, target):
|
||||
logger.warning(
|
||||
"Monitor %d: color preset %s not applied (monitor silently rejected)",
|
||||
monitor_id, preset,
|
||||
)
|
||||
return False
|
||||
_invalidate_cache()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to set color preset for monitor %d: %s", monitor_id, e)
|
||||
return False
|
||||
|
||||
|
||||
def set_picture_mode(monitor_id: int, code: int) -> bool:
|
||||
"""Set the DDC/CI picture/scene mode (VCP 0xDC) by raw code."""
|
||||
mc = _load_monitorcontrol()
|
||||
if mc is None:
|
||||
return False
|
||||
|
||||
if not 0 <= code <= 255:
|
||||
logger.error("Picture mode code %d out of range", code)
|
||||
return False
|
||||
|
||||
try:
|
||||
ddc_monitors = mc.get_monitors()
|
||||
if monitor_id >= len(ddc_monitors):
|
||||
return False
|
||||
with ddc_monitors[monitor_id] as monitor:
|
||||
monitor.vcp.set_vcp_feature(PICTURE_MODE_VCP, code)
|
||||
# Raw VCP read returns (current, maximum) — only compare current.
|
||||
def _read_picture_mode():
|
||||
current, _ = monitor.vcp.get_vcp_feature(PICTURE_MODE_VCP)
|
||||
return current
|
||||
if not _verify_after_set(_read_picture_mode, code):
|
||||
logger.warning(
|
||||
"Monitor %d: picture mode code %d not applied (monitor silently rejected)",
|
||||
monitor_id, code,
|
||||
)
|
||||
return False
|
||||
_invalidate_cache()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to set picture mode for monitor %d: %s", monitor_id, e)
|
||||
return False
|
||||
|
||||
|
||||
def _invalidate_cache() -> None:
|
||||
global _monitor_cache
|
||||
_monitor_cache = None
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
"""Foreground (topmost) window/process tracking.
|
||||
|
||||
Reports the process that currently owns the foreground window, plus useful
|
||||
metadata (window title, executable path, monitor index, whether the window
|
||||
covers a full monitor, process start time).
|
||||
|
||||
All probes happen behind a short TTL cache so the WebSocket status poll and
|
||||
per-entity HA polls don't pay the OS call cost on every tick.
|
||||
|
||||
Windows uses the Win32 API via ``ctypes`` (no extra dependency) and falls back
|
||||
gracefully when individual probes fail. Linux/macOS implementations are
|
||||
best-effort and return ``available=False`` when the required tooling is
|
||||
missing, so the rest of the stack keeps working.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import platform
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CACHE_TTL = 0.5 # seconds — fast enough for WebSocket broadcast loop
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ForegroundInfo:
|
||||
"""Snapshot of the foreground window/process."""
|
||||
|
||||
available: bool
|
||||
pid: int | None = None
|
||||
process_name: str | None = None
|
||||
executable_path: str | None = None
|
||||
window_title: str | None = None
|
||||
window_handle: int | None = None
|
||||
is_fullscreen: bool = False
|
||||
is_minimized: bool = False
|
||||
monitor_id: int | None = None
|
||||
monitor_geometry: dict[str, int] | None = None
|
||||
window_geometry: dict[str, int] | None = None
|
||||
started_at: float | None = None
|
||||
platform: str = field(default_factory=lambda: platform.system())
|
||||
error: str | None = None
|
||||
# Populated only when the foreground process is a recognised web
|
||||
# browser. ``browser_page_title`` is derived from the window title
|
||||
# (suffix stripped); ``browser_url`` requires UIA to succeed.
|
||||
is_browser: bool = False
|
||||
browser_url: str | None = None
|
||||
browser_page_title: str | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
_UNAVAILABLE = ForegroundInfo(available=False)
|
||||
|
||||
|
||||
class _Cache:
|
||||
"""Single-slot TTL cache shared across callers."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._value: ForegroundInfo | None = None
|
||||
self._fetched_at: float = 0.0
|
||||
|
||||
def get(self, ttl: float, fetch) -> ForegroundInfo:
|
||||
with self._lock:
|
||||
now = time.monotonic()
|
||||
if self._value is not None and (now - self._fetched_at) < ttl:
|
||||
return self._value
|
||||
# Fetch outside the lock — OS calls can take tens of ms.
|
||||
value = fetch()
|
||||
with self._lock:
|
||||
self._value = value
|
||||
self._fetched_at = time.monotonic()
|
||||
return value
|
||||
|
||||
def invalidate(self) -> None:
|
||||
with self._lock:
|
||||
self._value = None
|
||||
self._fetched_at = 0.0
|
||||
|
||||
|
||||
_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
|
||||
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
|
||||
user32 = ctypes.WinDLL("user32", use_last_error=True)
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
psapi = ctypes.WinDLL("psapi", use_last_error=True)
|
||||
|
||||
user32.GetForegroundWindow.restype = wt.HWND
|
||||
user32.GetWindowThreadProcessId.argtypes = [wt.HWND, ctypes.POINTER(wt.DWORD)]
|
||||
user32.GetWindowThreadProcessId.restype = wt.DWORD
|
||||
user32.GetWindowTextLengthW.argtypes = [wt.HWND]
|
||||
user32.GetWindowTextLengthW.restype = ctypes.c_int
|
||||
user32.GetWindowTextW.argtypes = [wt.HWND, wt.LPWSTR, ctypes.c_int]
|
||||
user32.GetWindowTextW.restype = ctypes.c_int
|
||||
user32.IsIconic.argtypes = [wt.HWND]
|
||||
user32.IsIconic.restype = wt.BOOL
|
||||
user32.GetWindowRect.argtypes = [wt.HWND, ctypes.POINTER(wt.RECT)]
|
||||
user32.GetWindowRect.restype = wt.BOOL
|
||||
user32.MonitorFromWindow.argtypes = [wt.HWND, wt.DWORD]
|
||||
user32.MonitorFromWindow.restype = wt.HMONITOR
|
||||
user32.GetMonitorInfoW.argtypes = [wt.HMONITOR, ctypes.c_void_p]
|
||||
user32.GetMonitorInfoW.restype = wt.BOOL
|
||||
|
||||
kernel32.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]
|
||||
kernel32.OpenProcess.restype = wt.HANDLE
|
||||
kernel32.CloseHandle.argtypes = [wt.HANDLE]
|
||||
kernel32.CloseHandle.restype = wt.BOOL
|
||||
kernel32.QueryFullProcessImageNameW.argtypes = [
|
||||
wt.HANDLE, wt.DWORD, wt.LPWSTR, ctypes.POINTER(wt.DWORD)
|
||||
]
|
||||
kernel32.QueryFullProcessImageNameW.restype = wt.BOOL
|
||||
kernel32.GetProcessTimes.argtypes = [
|
||||
wt.HANDLE,
|
||||
ctypes.POINTER(wt.FILETIME),
|
||||
ctypes.POINTER(wt.FILETIME),
|
||||
ctypes.POINTER(wt.FILETIME),
|
||||
ctypes.POINTER(wt.FILETIME),
|
||||
]
|
||||
kernel32.GetProcessTimes.restype = wt.BOOL
|
||||
|
||||
psapi.GetModuleFileNameExW.argtypes = [wt.HANDLE, wt.HMODULE, wt.LPWSTR, 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()
|
||||
if not hwnd:
|
||||
return ForegroundInfo(available=True, error="no foreground window")
|
||||
|
||||
# PID + window thread.
|
||||
pid = wt.DWORD(0)
|
||||
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
||||
pid_val = int(pid.value) if pid.value else None
|
||||
|
||||
# Window title — Unicode.
|
||||
length = user32.GetWindowTextLengthW(hwnd)
|
||||
title_buf = ctypes.create_unicode_buffer(length + 1)
|
||||
user32.GetWindowTextW(hwnd, title_buf, length + 1)
|
||||
window_title = title_buf.value or None
|
||||
|
||||
# Minimized flag.
|
||||
is_minimized = bool(user32.IsIconic(hwnd))
|
||||
|
||||
# Window rect (screen coords).
|
||||
rect = wt.RECT()
|
||||
window_geometry: dict[str, int] | None = None
|
||||
if user32.GetWindowRect(hwnd, ctypes.byref(rect)):
|
||||
window_geometry = {
|
||||
"left": int(rect.left),
|
||||
"top": int(rect.top),
|
||||
"right": int(rect.right),
|
||||
"bottom": int(rect.bottom),
|
||||
"width": int(rect.right - rect.left),
|
||||
"height": int(rect.bottom - rect.top),
|
||||
}
|
||||
|
||||
# Monitor under the window + its geometry.
|
||||
monitor_geometry: dict[str, int] | None = None
|
||||
monitor_id: int | None = None
|
||||
is_fullscreen = False
|
||||
try:
|
||||
MONITOR_DEFAULTTONEAREST = 2
|
||||
|
||||
class MONITORINFO(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("cbSize", wt.DWORD),
|
||||
("rcMonitor", wt.RECT),
|
||||
("rcWork", wt.RECT),
|
||||
("dwFlags", wt.DWORD),
|
||||
]
|
||||
|
||||
hmon = user32.MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST)
|
||||
if hmon:
|
||||
mi = MONITORINFO()
|
||||
mi.cbSize = ctypes.sizeof(mi)
|
||||
if user32.GetMonitorInfoW(hmon, ctypes.byref(mi)):
|
||||
monitor_geometry = {
|
||||
"left": int(mi.rcMonitor.left),
|
||||
"top": int(mi.rcMonitor.top),
|
||||
"right": int(mi.rcMonitor.right),
|
||||
"bottom": int(mi.rcMonitor.bottom),
|
||||
"width": int(mi.rcMonitor.right - mi.rcMonitor.left),
|
||||
"height": int(mi.rcMonitor.bottom - mi.rcMonitor.top),
|
||||
}
|
||||
# Fullscreen heuristic: window rect equals monitor rect AND
|
||||
# not minimized. Many media players (VLC, browser fullscreen)
|
||||
# set themselves to exactly the monitor bounds.
|
||||
if window_geometry and not is_minimized:
|
||||
is_fullscreen = (
|
||||
window_geometry["left"] == monitor_geometry["left"]
|
||||
and window_geometry["top"] == monitor_geometry["top"]
|
||||
and window_geometry["right"] == monitor_geometry["right"]
|
||||
and window_geometry["bottom"] == monitor_geometry["bottom"]
|
||||
)
|
||||
|
||||
# Resolve monitor index by enumerating displays in order. Coerce
|
||||
# both the foreground hmon and the per-enum hmon to int so the
|
||||
# equality compare uses 64-bit values consistently regardless of
|
||||
# how ctypes represents the handle internally.
|
||||
try:
|
||||
indexed: list[int] = []
|
||||
|
||||
def _cb(hm, _hdc, _rect, _data):
|
||||
indexed.append(int(hm) if hm else 0)
|
||||
return True
|
||||
|
||||
MONITORENUMPROC = ctypes.WINFUNCTYPE(
|
||||
ctypes.c_int,
|
||||
wt.HMONITOR,
|
||||
wt.HDC,
|
||||
ctypes.POINTER(wt.RECT),
|
||||
wt.LPARAM,
|
||||
)
|
||||
user32.EnumDisplayMonitors.argtypes = [
|
||||
wt.HDC, ctypes.POINTER(wt.RECT), MONITORENUMPROC, wt.LPARAM
|
||||
]
|
||||
user32.EnumDisplayMonitors.restype = wt.BOOL
|
||||
user32.EnumDisplayMonitors(None, None, MONITORENUMPROC(_cb), 0)
|
||||
target = int(hmon) if hmon else 0
|
||||
if target and target in indexed:
|
||||
monitor_id = indexed.index(target)
|
||||
except Exception as e:
|
||||
logger.debug("Monitor index resolution failed: %s", e)
|
||||
except Exception as e:
|
||||
logger.debug("Monitor info probe failed: %s", e)
|
||||
|
||||
# Process executable path + start time.
|
||||
executable_path: str | None = None
|
||||
process_name: str | None = None
|
||||
started_at: float | None = None
|
||||
if pid_val:
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
h_proc = kernel32.OpenProcess(
|
||||
PROCESS_QUERY_LIMITED_INFORMATION, False, pid_val
|
||||
)
|
||||
if h_proc:
|
||||
try:
|
||||
# Image filename — full path. QueryFullProcessImageNameW works
|
||||
# across 32/64-bit boundaries, unlike GetModuleFileNameExW.
|
||||
buf = ctypes.create_unicode_buffer(1024)
|
||||
size = wt.DWORD(len(buf))
|
||||
if kernel32.QueryFullProcessImageNameW(
|
||||
h_proc, 0, buf, ctypes.byref(size)
|
||||
):
|
||||
executable_path = buf.value or None
|
||||
else:
|
||||
# Fallback via psapi. Return value is the length copied
|
||||
# into the buffer (0 on failure); ignoring it would leave
|
||||
# `executable_path` as an empty string from the freshly
|
||||
# allocated buffer instead of None.
|
||||
written = psapi.GetModuleFileNameExW(h_proc, None, buf, len(buf))
|
||||
if written:
|
||||
executable_path = buf.value or None
|
||||
else:
|
||||
logger.debug(
|
||||
"QueryFullProcessImageNameW + psapi fallback both "
|
||||
"failed for pid=%s (err=%d)",
|
||||
pid_val,
|
||||
ctypes.get_last_error(),
|
||||
)
|
||||
|
||||
if executable_path:
|
||||
import os
|
||||
process_name = os.path.basename(executable_path)
|
||||
|
||||
# Process creation time (FILETIME, 100ns ticks since 1601).
|
||||
creation = wt.FILETIME()
|
||||
exit_t = wt.FILETIME()
|
||||
kernel_t = wt.FILETIME()
|
||||
user_t = wt.FILETIME()
|
||||
if kernel32.GetProcessTimes(
|
||||
h_proc,
|
||||
ctypes.byref(creation),
|
||||
ctypes.byref(exit_t),
|
||||
ctypes.byref(kernel_t),
|
||||
ctypes.byref(user_t),
|
||||
):
|
||||
ticks = (creation.dwHighDateTime << 32) | creation.dwLowDateTime
|
||||
# Convert to Unix epoch seconds (1601-01-01 → 1970-01-01).
|
||||
if ticks:
|
||||
started_at = (ticks - 116444736000000000) / 10_000_000
|
||||
finally:
|
||||
kernel32.CloseHandle(h_proc)
|
||||
|
||||
return ForegroundInfo(
|
||||
available=True,
|
||||
pid=pid_val,
|
||||
process_name=process_name,
|
||||
executable_path=executable_path,
|
||||
window_title=window_title,
|
||||
window_handle=int(hwnd) if hwnd else None,
|
||||
is_fullscreen=is_fullscreen,
|
||||
is_minimized=is_minimized,
|
||||
monitor_id=monitor_id,
|
||||
monitor_geometry=monitor_geometry,
|
||||
window_geometry=window_geometry,
|
||||
started_at=started_at,
|
||||
)
|
||||
|
||||
|
||||
def _probe_macos() -> ForegroundInfo:
|
||||
"""Best-effort probe on macOS via AppKit (PyObjC).
|
||||
|
||||
Returns ``available=False`` when PyObjC is not installed — we don't take
|
||||
a hard dependency on it because the typical macOS install path uses pip
|
||||
+ the standalone wheel.
|
||||
"""
|
||||
try:
|
||||
from AppKit import NSWorkspace # type: ignore
|
||||
from Quartz import ( # type: ignore
|
||||
CGWindowListCopyWindowInfo,
|
||||
kCGNullWindowID,
|
||||
kCGWindowListOptionOnScreenOnly,
|
||||
)
|
||||
except Exception:
|
||||
return ForegroundInfo(available=False, error="AppKit/Quartz not available")
|
||||
|
||||
try:
|
||||
ws = NSWorkspace.sharedWorkspace()
|
||||
app = ws.frontmostApplication()
|
||||
if app is None:
|
||||
return ForegroundInfo(available=True, error="no frontmost app")
|
||||
|
||||
pid = int(app.processIdentifier())
|
||||
process_name = str(app.localizedName() or "")
|
||||
bundle_url = app.bundleURL()
|
||||
executable_path = str(bundle_url.path()) if bundle_url else None
|
||||
started_at = None
|
||||
launch_date = app.launchDate()
|
||||
if launch_date is not None:
|
||||
started_at = float(launch_date.timeIntervalSince1970())
|
||||
|
||||
# Window title — frontmost on-screen window owned by this PID.
|
||||
window_title: str | None = None
|
||||
try:
|
||||
windows = CGWindowListCopyWindowInfo(
|
||||
kCGWindowListOptionOnScreenOnly, kCGNullWindowID
|
||||
)
|
||||
for w in windows or []:
|
||||
if int(w.get("kCGWindowOwnerPID", -1)) == pid:
|
||||
name = w.get("kCGWindowName")
|
||||
if name:
|
||||
window_title = str(name)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug("CGWindowListCopyWindowInfo failed: %s", e)
|
||||
|
||||
return ForegroundInfo(
|
||||
available=True,
|
||||
pid=pid,
|
||||
process_name=process_name,
|
||||
executable_path=executable_path,
|
||||
window_title=window_title,
|
||||
started_at=started_at,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("macOS foreground probe failed: %s", e)
|
||||
return ForegroundInfo(available=False, error=str(e))
|
||||
|
||||
|
||||
def _probe_linux() -> ForegroundInfo:
|
||||
"""Best-effort probe on Linux via Xlib (X11 only).
|
||||
|
||||
Wayland sessions intentionally hide window/process info from unprivileged
|
||||
clients, so this returns ``available=False`` on Wayland. The caller still
|
||||
gets a structured response and can render "unavailable" in the UI.
|
||||
"""
|
||||
import os
|
||||
|
||||
if os.environ.get("WAYLAND_DISPLAY"):
|
||||
return ForegroundInfo(
|
||||
available=False, error="Wayland session — foreground probe unavailable"
|
||||
)
|
||||
|
||||
try:
|
||||
from Xlib import X, display # type: ignore # noqa: F401
|
||||
except Exception:
|
||||
return ForegroundInfo(available=False, error="python-xlib not installed")
|
||||
|
||||
try:
|
||||
d = display.Display()
|
||||
root = d.screen().root
|
||||
NET_ACTIVE_WINDOW = d.intern_atom("_NET_ACTIVE_WINDOW")
|
||||
NET_WM_PID = d.intern_atom("_NET_WM_PID")
|
||||
NET_WM_NAME = d.intern_atom("_NET_WM_NAME")
|
||||
UTF8_STRING = d.intern_atom("UTF8_STRING")
|
||||
|
||||
active = root.get_full_property(NET_ACTIVE_WINDOW, X.AnyPropertyType)
|
||||
if not active or not active.value:
|
||||
return ForegroundInfo(available=True, error="no active window")
|
||||
win_id = int(active.value[0])
|
||||
win = d.create_resource_object("window", win_id)
|
||||
|
||||
pid_prop = win.get_full_property(NET_WM_PID, X.AnyPropertyType)
|
||||
pid_val = int(pid_prop.value[0]) if pid_prop and pid_prop.value else None
|
||||
|
||||
name_prop = win.get_full_property(NET_WM_NAME, UTF8_STRING)
|
||||
window_title = (
|
||||
name_prop.value.decode("utf-8", "replace") if name_prop and name_prop.value else None
|
||||
)
|
||||
|
||||
process_name: str | None = None
|
||||
executable_path: str | None = None
|
||||
started_at: float | None = None
|
||||
if pid_val:
|
||||
try:
|
||||
exe = os.readlink(f"/proc/{pid_val}/exe")
|
||||
executable_path = exe
|
||||
process_name = os.path.basename(exe)
|
||||
except OSError as e:
|
||||
logger.debug("readlink /proc/%d/exe failed: %s", pid_val, e)
|
||||
try:
|
||||
started_at = os.stat(f"/proc/{pid_val}").st_ctime
|
||||
except OSError as e:
|
||||
logger.debug("stat /proc/%d failed: %s", pid_val, e)
|
||||
|
||||
return ForegroundInfo(
|
||||
available=True,
|
||||
pid=pid_val,
|
||||
process_name=process_name,
|
||||
executable_path=executable_path,
|
||||
window_title=window_title,
|
||||
window_handle=win_id,
|
||||
started_at=started_at,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Linux foreground probe failed: %s", e)
|
||||
return ForegroundInfo(available=False, error=str(e))
|
||||
|
||||
|
||||
def _enrich_browser(info: ForegroundInfo) -> ForegroundInfo:
|
||||
"""If ``info`` describes a focused browser, attach URL + page title.
|
||||
|
||||
The UIA lookup is wrapped in its own try/except so a failure here can't
|
||||
take down the rest of the foreground probe.
|
||||
"""
|
||||
try:
|
||||
from . import browser_url_service as bus
|
||||
except Exception as e:
|
||||
logger.debug("browser_url_service unavailable: %s", e)
|
||||
return info
|
||||
|
||||
if not info.available or not bus.is_browser_process(info.process_name):
|
||||
return info
|
||||
|
||||
try:
|
||||
page = bus.get_browser_page(
|
||||
hwnd=info.window_handle,
|
||||
process_name=info.process_name,
|
||||
window_title=info.window_title,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Browser URL enrichment failed: %s", e)
|
||||
return info
|
||||
|
||||
# ``dataclasses.replace`` keeps the frozen-dataclass contract.
|
||||
from dataclasses import replace
|
||||
return replace(
|
||||
info,
|
||||
is_browser=True,
|
||||
browser_url=page.url,
|
||||
browser_page_title=page.page_title,
|
||||
)
|
||||
|
||||
|
||||
def _probe() -> ForegroundInfo:
|
||||
system = platform.system()
|
||||
try:
|
||||
if system == "Windows":
|
||||
info = _probe_windows()
|
||||
elif system == "Darwin":
|
||||
info = _probe_macos()
|
||||
elif system == "Linux":
|
||||
info = _probe_linux()
|
||||
else:
|
||||
return ForegroundInfo(
|
||||
available=False, error=f"unsupported platform: {system}"
|
||||
)
|
||||
return _enrich_browser(info)
|
||||
except Exception as e:
|
||||
logger.warning("Foreground probe crashed: %s", e)
|
||||
return ForegroundInfo(available=False, error=str(e))
|
||||
|
||||
|
||||
def get_foreground_info(force_refresh: bool = False) -> ForegroundInfo:
|
||||
"""Return the current foreground window/process snapshot.
|
||||
|
||||
Args:
|
||||
force_refresh: bypass the short TTL cache. WebSocket broadcast loop
|
||||
should leave this False; the REST endpoint accepts ?refresh=1
|
||||
for callers that want a fresh probe.
|
||||
"""
|
||||
if force_refresh:
|
||||
_cache.invalidate()
|
||||
return _cache.get(_CACHE_TTL, _probe)
|
||||
|
||||
|
||||
def reset_cache() -> None:
|
||||
"""Reset the cache. Useful in tests."""
|
||||
_cache.invalidate()
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Optional
|
||||
@@ -15,6 +16,11 @@ _DEFAULT_BASE_URL = "https://git.dolgolyov-family.by"
|
||||
_DEFAULT_OWNER = "alexei.dolgolyov"
|
||||
_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):
|
||||
"""Fetches the latest release from a Gitea repository."""
|
||||
@@ -53,6 +59,9 @@ class GiteaReleaseProvider(ReleaseProvider):
|
||||
continue
|
||||
|
||||
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")
|
||||
if not version:
|
||||
continue
|
||||
|
||||
@@ -2,14 +2,23 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from ..models import MediaState, MediaStatus
|
||||
from .media_controller import MediaController
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cap remote artwork downloads so a hostile / huge Spotify image can't
|
||||
# blow up RAM. Real album art rarely exceeds ~2 MB; 8 MB is a comfortable
|
||||
# upper bound that also covers loss-less PNGs.
|
||||
_MAX_ART_BYTES = 8 * 1024 * 1024
|
||||
_ART_FETCH_TIMEOUT = 5.0 # seconds
|
||||
|
||||
# Linux-specific imports
|
||||
try:
|
||||
import dbus
|
||||
@@ -35,13 +44,54 @@ class LinuxMediaController(MediaController):
|
||||
"Linux media control requires dbus-python package. "
|
||||
"Install with: sudo apt-get install python3-dbus"
|
||||
)
|
||||
DBusGMainLoop(set_as_default=True)
|
||||
self._bus = dbus.SessionBus()
|
||||
# The session-bus connection is deferred until first use. Connecting
|
||||
# in __init__ raised during app startup whenever the user's session
|
||||
# bus wasn't ready yet — common under systemd (service starts
|
||||
# before logind set up /run/user/<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]:
|
||||
"""Find an active MPRIS media player on the bus."""
|
||||
bus = self._get_bus()
|
||||
if bus is None:
|
||||
return None
|
||||
try:
|
||||
bus_names = self._bus.list_names()
|
||||
bus_names = bus.list_names()
|
||||
mpris_players = [
|
||||
name for name in bus_names if name.startswith(self.MPRIS_PREFIX)
|
||||
]
|
||||
@@ -151,22 +201,19 @@ class LinuxMediaController(MediaController):
|
||||
logger.error(f"Failed to toggle mute: {e}")
|
||||
return False
|
||||
|
||||
async def get_status(self) -> MediaStatus:
|
||||
"""Get current media playback status."""
|
||||
def _sync_get_status(self) -> MediaStatus:
|
||||
"""Synchronous status read (called from a worker thread)."""
|
||||
status = MediaStatus()
|
||||
|
||||
# Get system volume
|
||||
volume, muted = self._get_volume_pulseaudio()
|
||||
status.volume = volume
|
||||
status.muted = muted
|
||||
|
||||
# Get active player
|
||||
player_name = self._get_active_player()
|
||||
if player_name is None:
|
||||
status.state = MediaState.IDLE
|
||||
return status
|
||||
|
||||
# Get playback status
|
||||
playback_status = self._get_property(player_name, "PlaybackStatus")
|
||||
if playback_status == "Playing":
|
||||
status.state = MediaState.PLAYING
|
||||
@@ -177,114 +224,78 @@ class LinuxMediaController(MediaController):
|
||||
else:
|
||||
status.state = MediaState.IDLE
|
||||
|
||||
# Get metadata
|
||||
metadata = self._get_property(player_name, "Metadata")
|
||||
if metadata:
|
||||
status.title = str(metadata.get("xesam:title", "")) or None
|
||||
|
||||
artists = metadata.get("xesam:artist", [])
|
||||
if artists:
|
||||
status.artist = str(artists[0]) if isinstance(artists, list) else str(artists)
|
||||
|
||||
status.album = str(metadata.get("xesam:album", "")) or None
|
||||
status.album_art_url = str(metadata.get("mpris:artUrl", "")) or None
|
||||
|
||||
# Duration in microseconds
|
||||
art_url = str(metadata.get("mpris:artUrl", "")) or None
|
||||
status.album_art_url = art_url
|
||||
# Invalidate cached bytes when the track changes. Real fetch
|
||||
# happens lazily in get_album_art() so the status hot path
|
||||
# never blocks on HTTP.
|
||||
with self._art_lock:
|
||||
if art_url != self._art_url:
|
||||
self._art_url = art_url
|
||||
self._art_bytes = None
|
||||
length = metadata.get("mpris:length", 0)
|
||||
if length:
|
||||
status.duration = int(length) / 1_000_000
|
||||
|
||||
# Get position (in microseconds)
|
||||
position = self._get_property(player_name, "Position")
|
||||
if position is not None:
|
||||
status.position = int(position) / 1_000_000
|
||||
|
||||
# Get source name
|
||||
status.source = player_name.replace(self.MPRIS_PREFIX, "")
|
||||
|
||||
return status
|
||||
|
||||
async def play(self) -> bool:
|
||||
"""Resume playback."""
|
||||
async def get_status(self) -> MediaStatus:
|
||||
"""Get current media playback status (off the event loop)."""
|
||||
# pactl + DBus calls each take 5-100ms on a Pi and would block every
|
||||
# other coroutine on the server. Run them in a worker thread.
|
||||
return await asyncio.to_thread(self._sync_get_status)
|
||||
|
||||
def _call_player(self, method_name: str) -> bool:
|
||||
player_name = self._get_active_player()
|
||||
if player_name is None:
|
||||
return False
|
||||
try:
|
||||
player = self._get_player_interface(player_name)
|
||||
player.Play()
|
||||
getattr(player, method_name)()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to play: {e}")
|
||||
logger.error(f"Failed to call player.{method_name}: {e}")
|
||||
return False
|
||||
|
||||
async def play(self) -> bool:
|
||||
return await asyncio.to_thread(self._call_player, "Play")
|
||||
|
||||
async def pause(self) -> bool:
|
||||
"""Pause playback."""
|
||||
player_name = self._get_active_player()
|
||||
if player_name is None:
|
||||
return False
|
||||
try:
|
||||
player = self._get_player_interface(player_name)
|
||||
player.Pause()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to pause: {e}")
|
||||
return False
|
||||
return await asyncio.to_thread(self._call_player, "Pause")
|
||||
|
||||
async def stop(self) -> bool:
|
||||
"""Stop playback."""
|
||||
player_name = self._get_active_player()
|
||||
if player_name is None:
|
||||
return False
|
||||
try:
|
||||
player = self._get_player_interface(player_name)
|
||||
player.Stop()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stop: {e}")
|
||||
return False
|
||||
return await asyncio.to_thread(self._call_player, "Stop")
|
||||
|
||||
async def next_track(self) -> bool:
|
||||
"""Skip to next track."""
|
||||
player_name = self._get_active_player()
|
||||
if player_name is None:
|
||||
return False
|
||||
try:
|
||||
player = self._get_player_interface(player_name)
|
||||
player.Next()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to skip next: {e}")
|
||||
return False
|
||||
return await asyncio.to_thread(self._call_player, "Next")
|
||||
|
||||
async def previous_track(self) -> bool:
|
||||
"""Go to previous track."""
|
||||
player_name = self._get_active_player()
|
||||
if player_name is None:
|
||||
return False
|
||||
try:
|
||||
player = self._get_player_interface(player_name)
|
||||
player.Previous()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to skip previous: {e}")
|
||||
return False
|
||||
return await asyncio.to_thread(self._call_player, "Previous")
|
||||
|
||||
async def set_volume(self, volume: int) -> bool:
|
||||
"""Set system volume."""
|
||||
return self._set_volume_pulseaudio(volume)
|
||||
return await asyncio.to_thread(self._set_volume_pulseaudio, volume)
|
||||
|
||||
async def toggle_mute(self) -> bool:
|
||||
"""Toggle mute state."""
|
||||
return self._toggle_mute_pulseaudio()
|
||||
return await asyncio.to_thread(self._toggle_mute_pulseaudio)
|
||||
|
||||
async def seek(self, position: float) -> bool:
|
||||
"""Seek to position in seconds."""
|
||||
def _sync_seek(self, position: float) -> bool:
|
||||
player_name = self._get_active_player()
|
||||
if player_name is None:
|
||||
return False
|
||||
try:
|
||||
player = self._get_player_interface(player_name)
|
||||
# MPRIS expects position in microseconds
|
||||
player.SetPosition(
|
||||
self._get_property(player_name, "Metadata").get("mpris:trackid", "/"),
|
||||
int(position * 1_000_000),
|
||||
@@ -294,6 +305,9 @@ class LinuxMediaController(MediaController):
|
||||
logger.error(f"Failed to seek: {e}")
|
||||
return False
|
||||
|
||||
async def seek(self, position: float) -> bool:
|
||||
return await asyncio.to_thread(self._sync_seek, position)
|
||||
|
||||
async def open_file(self, file_path: str) -> bool:
|
||||
"""Open a media file with the default system player (Linux).
|
||||
|
||||
@@ -317,3 +331,61 @@ class LinuxMediaController(MediaController):
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to open file {file_path}: {e}")
|
||||
return False
|
||||
|
||||
def _fetch_art_sync(self, url: str) -> Optional[bytes]:
|
||||
"""Resolve an ``mpris:artUrl`` to raw bytes (file://, http(s)://).
|
||||
|
||||
Other schemes (data:, ftp:, …) are rejected — we only support the
|
||||
two cases real-world MPRIS providers use. The HTTP path is capped
|
||||
at _MAX_ART_BYTES and the file path is read with a size guard so a
|
||||
symlink to /dev/zero can't OOM the server.
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
scheme = parsed.scheme.lower()
|
||||
if scheme == "file":
|
||||
path = unquote(parsed.path)
|
||||
try:
|
||||
size = os.stat(path).st_size
|
||||
if size <= 0 or size > _MAX_ART_BYTES:
|
||||
return None
|
||||
with open(path, "rb") as f:
|
||||
return f.read(_MAX_ART_BYTES)
|
||||
except OSError as e:
|
||||
logger.debug("Could not read local art %s: %s", path, e)
|
||||
return None
|
||||
|
||||
if scheme in ("http", "https"):
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "media-server/0.x"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=_ART_FETCH_TIMEOUT) as resp:
|
||||
# Cap reads to defend against unbounded responses.
|
||||
return resp.read(_MAX_ART_BYTES + 1)[:_MAX_ART_BYTES] or None
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, OSError) as e:
|
||||
logger.debug("Could not fetch remote art %s: %s", url, e)
|
||||
return None
|
||||
|
||||
logger.debug("Unsupported art URL scheme: %s", scheme)
|
||||
return None
|
||||
|
||||
async def get_album_art(self) -> Optional[bytes]:
|
||||
"""Return cached MPRIS art, fetching on first access per track."""
|
||||
with self._art_lock:
|
||||
url = self._art_url
|
||||
cached = self._art_bytes
|
||||
if cached is not None:
|
||||
return cached
|
||||
if not url:
|
||||
return None
|
||||
data = await asyncio.to_thread(self._fetch_art_sync, url)
|
||||
# Store even on None so we don't re-hammer a 404 every second.
|
||||
with self._art_lock:
|
||||
if url == self._art_url:
|
||||
self._art_bytes = data
|
||||
return data
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from ..models import MediaState, MediaStatus
|
||||
@@ -10,10 +11,20 @@ from .media_controller import MediaController
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cap remote artwork downloads (Spotify's artwork url is http(s)://).
|
||||
_MAX_ART_BYTES = 8 * 1024 * 1024
|
||||
_ART_FETCH_TIMEOUT = 5.0 # seconds
|
||||
|
||||
|
||||
class MacOSMediaController(MediaController):
|
||||
"""Media controller for macOS using osascript and system commands."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Cached art bytes keyed by the active art URL.
|
||||
self._art_lock = threading.Lock()
|
||||
self._art_url: Optional[str] = None
|
||||
self._art_bytes: Optional[bytes] = None
|
||||
|
||||
def _run_osascript(self, script: str) -> Optional[str]:
|
||||
"""Run an AppleScript and return the output."""
|
||||
try:
|
||||
@@ -193,12 +204,60 @@ class MacOSMediaController(MediaController):
|
||||
status.album = info.get("album")
|
||||
status.duration = info.get("duration")
|
||||
status.position = info.get("position")
|
||||
status.album_art_url = info.get("art_url")
|
||||
art_url = info.get("art_url")
|
||||
status.album_art_url = art_url
|
||||
# Track changes invalidate the cached image bytes — actual
|
||||
# fetch happens lazily in get_album_art().
|
||||
with self._art_lock:
|
||||
if art_url != self._art_url:
|
||||
self._art_url = art_url
|
||||
self._art_bytes = None
|
||||
else:
|
||||
status.state = MediaState.IDLE
|
||||
|
||||
return status
|
||||
|
||||
def _fetch_art_sync(self, url: str) -> Optional[bytes]:
|
||||
"""Resolve a Spotify/Music art URL (http(s)://) to bytes.
|
||||
|
||||
File-scheme URLs aren't expected on macOS (AppleScript apps return
|
||||
artwork as remote URLs), so only http(s) is supported.
|
||||
"""
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(url)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if parsed.scheme.lower() not in ("http", "https"):
|
||||
return None
|
||||
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "media-server/0.x"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=_ART_FETCH_TIMEOUT) as resp:
|
||||
return resp.read(_MAX_ART_BYTES + 1)[:_MAX_ART_BYTES] or None
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, OSError) as e:
|
||||
logger.debug("Could not fetch macOS art %s: %s", url, e)
|
||||
return None
|
||||
|
||||
async def get_album_art(self) -> Optional[bytes]:
|
||||
"""Return cached art bytes, fetching once per track URL."""
|
||||
with self._art_lock:
|
||||
url = self._art_url
|
||||
cached = self._art_bytes
|
||||
if cached is not None:
|
||||
return cached
|
||||
if not url:
|
||||
return None
|
||||
data = await asyncio.to_thread(self._fetch_art_sync, url)
|
||||
with self._art_lock:
|
||||
if url == self._art_url:
|
||||
self._art_bytes = data
|
||||
return data
|
||||
|
||||
async def play(self) -> bool:
|
||||
"""Resume playback using media key simulation."""
|
||||
# Use system media key
|
||||
@@ -264,8 +323,12 @@ class MacOSMediaController(MediaController):
|
||||
|
||||
async def set_volume(self, volume: int) -> bool:
|
||||
"""Set system volume."""
|
||||
result = self._run_osascript(f"set volume output volume {volume}")
|
||||
return result is not None or True # osascript returns empty on success
|
||||
# osascript returns empty string on success and None on failure (the
|
||||
# _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:
|
||||
"""Toggle mute state."""
|
||||
|
||||
@@ -106,3 +106,12 @@ class MediaController(ABC):
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
async def get_album_art(self) -> bytes | None:
|
||||
"""Return the current album art bytes, or ``None`` when unavailable.
|
||||
|
||||
Default impl returns ``None`` — controllers that can produce art
|
||||
(Windows via SMTC thumbnail, Linux via mpris:artUrl, macOS via the
|
||||
Spotify/Music artwork-url field) override this.
|
||||
"""
|
||||
return None
|
||||
|
||||
@@ -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"
|
||||
@@ -26,12 +26,23 @@ class ThumbnailService:
|
||||
def get_cache_dir() -> Path:
|
||||
"""Get the thumbnail cache directory path.
|
||||
|
||||
Returns:
|
||||
Path to the cache directory (project-local).
|
||||
Returns user-writable platform cache dir so installs under
|
||||
``%PROGRAMFILES%`` / ``/opt`` work without elevated permissions.
|
||||
Mirrors the platform branching of ``config.get_config_dir``.
|
||||
"""
|
||||
# Store cache in project directory: media-server/.cache/thumbnails/
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
cache_dir = project_root / ".cache" / "thumbnails"
|
||||
import os
|
||||
|
||||
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)
|
||||
return cache_dir
|
||||
@@ -321,7 +332,7 @@ class ThumbnailService:
|
||||
|
||||
if suffix in AUDIO_EXTENSIONS:
|
||||
# Audio files - run in executor (sync operation)
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
thumbnail_data = await loop.run_in_executor(
|
||||
None,
|
||||
ThumbnailService.generate_audio_thumbnail,
|
||||
|
||||
@@ -19,6 +19,9 @@ class ConnectionManager:
|
||||
self._active_connections: set[WebSocket] = set()
|
||||
self._lock = asyncio.Lock()
|
||||
self._last_status: dict[str, Any] | None = None
|
||||
self._last_foreground: dict[str, Any] | None = None
|
||||
self._foreground_poll_interval: float = 1.0
|
||||
self._last_foreground_poll: float = 0.0
|
||||
self._get_status_func: Callable[[], Coroutine[Any, Any, Any]] | None = None
|
||||
self._broadcast_task: asyncio.Task | None = None
|
||||
self._poll_interval: float = 0.5 # Internal poll interval for change detection
|
||||
@@ -30,9 +33,15 @@ class ConnectionManager:
|
||||
self._audio_task: asyncio.Task | None = None
|
||||
self._audio_analyzer = None
|
||||
|
||||
async def connect(self, websocket: WebSocket) -> None:
|
||||
"""Accept a new WebSocket connection."""
|
||||
await websocket.accept()
|
||||
async def connect(self, websocket: WebSocket, already_accepted: bool = False) -> None:
|
||||
"""Accept a new WebSocket connection.
|
||||
|
||||
``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:
|
||||
self._active_connections.add(websocket)
|
||||
logger.info(
|
||||
@@ -54,6 +63,18 @@ class ConnectionManager:
|
||||
except Exception as e:
|
||||
logger.debug("Failed to send initial status: %s", e)
|
||||
|
||||
# Push a fresh foreground snapshot on connect so the UI can render
|
||||
# the tile immediately instead of waiting for the next change.
|
||||
try:
|
||||
from .foreground_service import get_foreground_info
|
||||
|
||||
fg = await asyncio.to_thread(get_foreground_info)
|
||||
fg_dict = fg.to_dict()
|
||||
self._last_foreground = fg_dict
|
||||
await websocket.send_json({"type": "foreground", "data": fg_dict})
|
||||
except Exception as e:
|
||||
logger.debug("Failed to send initial foreground snapshot: %s", e)
|
||||
|
||||
async def disconnect(self, websocket: WebSocket) -> None:
|
||||
"""Remove a WebSocket connection. Stops audio capture if last visualizer subscriber."""
|
||||
should_stop = False
|
||||
@@ -70,16 +91,27 @@ class ConnectionManager:
|
||||
)
|
||||
|
||||
async def broadcast(self, message: dict[str, Any]) -> None:
|
||||
"""Broadcast a message to all connected clients concurrently."""
|
||||
"""Broadcast a message to all connected clients concurrently.
|
||||
|
||||
The payload is serialized once and pushed via ``send_text`` to every
|
||||
client, instead of having Starlette/Pydantic encode it N times via
|
||||
``send_json``.
|
||||
"""
|
||||
async with self._lock:
|
||||
connections = list(self._active_connections)
|
||||
|
||||
if not connections:
|
||||
return
|
||||
|
||||
try:
|
||||
payload = json.dumps(message, default=str)
|
||||
except (TypeError, ValueError) as e:
|
||||
logger.error("Failed to encode broadcast message: %s", e)
|
||||
return
|
||||
|
||||
async def _send(ws: WebSocket) -> WebSocket | None:
|
||||
try:
|
||||
await ws.send_json(message)
|
||||
await ws.send_text(payload)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug("Failed to send to client: %s", e)
|
||||
@@ -104,6 +136,35 @@ class ConnectionManager:
|
||||
await self.broadcast(message)
|
||||
logger.info("Broadcast sent: links_changed")
|
||||
|
||||
def foreground_changed(
|
||||
self, old: dict[str, Any] | None, new: dict[str, Any]
|
||||
) -> bool:
|
||||
"""Detect a meaningful change in the foreground process snapshot.
|
||||
|
||||
The probe also returns ``window_geometry`` which jitters on every
|
||||
pixel of cursor drag — comparing the whole dict would flood clients.
|
||||
We only diff the fields a user (or HA automation) would actually act
|
||||
on. ``window_geometry``/``monitor_geometry``/``started_at`` are still
|
||||
delivered in the payload, but they don't drive broadcast cadence.
|
||||
"""
|
||||
if old is None:
|
||||
return True
|
||||
diff_fields = (
|
||||
"pid",
|
||||
"process_name",
|
||||
"executable_path",
|
||||
"window_title",
|
||||
"is_fullscreen",
|
||||
"is_minimized",
|
||||
"monitor_id",
|
||||
"available",
|
||||
"error",
|
||||
)
|
||||
for f in diff_fields:
|
||||
if old.get(f) != new.get(f):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def subscribe_visualizer(self, websocket: WebSocket) -> None:
|
||||
"""Subscribe a client to audio visualizer data. Starts capture on first subscriber."""
|
||||
should_start = False
|
||||
@@ -129,7 +190,7 @@ class ConnectionManager:
|
||||
async def _maybe_start_capture(self) -> None:
|
||||
"""Start audio capture if not already running (called on first subscriber)."""
|
||||
if self._audio_analyzer and not self._audio_analyzer.running:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
started = await loop.run_in_executor(None, self._audio_analyzer.start)
|
||||
if started:
|
||||
logger.info("Audio capture started (first subscriber)")
|
||||
@@ -139,7 +200,7 @@ class ConnectionManager:
|
||||
async def _maybe_stop_capture(self) -> None:
|
||||
"""Stop audio capture if running (called when last subscriber leaves)."""
|
||||
if self._audio_analyzer and self._audio_analyzer.running:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, self._audio_analyzer.stop)
|
||||
logger.info("Audio capture stopped (no subscribers)")
|
||||
|
||||
@@ -161,26 +222,48 @@ class ConnectionManager:
|
||||
self._audio_task = None
|
||||
|
||||
async def _audio_broadcast_loop(self) -> None:
|
||||
"""Background loop: read frequency data from analyzer and broadcast to subscribers."""
|
||||
from ..config import settings
|
||||
interval = 1.0 / settings.visualizer_fps
|
||||
"""Background loop: read frequency data from analyzer and broadcast to subscribers.
|
||||
|
||||
_last_data = None
|
||||
Event-driven: blocks on the analyzer's data_event so it wakes up
|
||||
exactly once per produced frame, instead of polling on a timer.
|
||||
Backstop sleep applies when capture is idle / has no subscribers.
|
||||
"""
|
||||
from ..config import settings
|
||||
idle_interval = 1.0 / max(1, settings.visualizer_fps)
|
||||
# Bounded wait so we still notice subscribe/unsubscribe transitions.
|
||||
wake_timeout = max(0.05, idle_interval)
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
last_seq = -1
|
||||
|
||||
while True:
|
||||
try:
|
||||
async with self._lock:
|
||||
subscribers = list(self._visualizer_subscribers)
|
||||
|
||||
if not subscribers or not self._audio_analyzer or not self._audio_analyzer.running:
|
||||
await asyncio.sleep(interval)
|
||||
analyzer = self._audio_analyzer
|
||||
if not subscribers or not analyzer or not analyzer.running:
|
||||
await asyncio.sleep(idle_interval)
|
||||
continue
|
||||
|
||||
data = self._audio_analyzer.get_frequency_data()
|
||||
if data is None or data is _last_data:
|
||||
await asyncio.sleep(interval)
|
||||
# Wait off-loop for a fresh frame. The capture thread sets
|
||||
# data_event after each FFT update; we clear it before the
|
||||
# next wait so we never burn a wake on stale data.
|
||||
ev = analyzer.data_event
|
||||
|
||||
def _wait() -> bool:
|
||||
return ev.wait(wake_timeout)
|
||||
|
||||
got = await loop.run_in_executor(None, _wait)
|
||||
if not got:
|
||||
# Timeout — loop around to re-check subscriber state.
|
||||
continue
|
||||
_last_data = data
|
||||
ev.clear()
|
||||
|
||||
data, seq = analyzer.get_frequency_data_versioned()
|
||||
if data is None or seq == last_seq:
|
||||
continue
|
||||
last_seq = seq
|
||||
|
||||
# Pre-serialize once for all subscribers (avoids per-client JSON encoding)
|
||||
text = json.dumps({"type": "audio_data", "data": data}, separators=(',', ':'))
|
||||
@@ -198,13 +281,11 @@ class ConnectionManager:
|
||||
for ws in failed:
|
||||
await self.disconnect(ws)
|
||||
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error in audio broadcast: %s", e)
|
||||
await asyncio.sleep(interval)
|
||||
await asyncio.sleep(idle_interval)
|
||||
|
||||
def status_changed(
|
||||
self, old: dict[str, Any] | None, new: dict[str, Any]
|
||||
@@ -283,6 +364,10 @@ class ConnectionManager:
|
||||
get_status_func: Callable[[], Coroutine[Any, Any, Any]],
|
||||
) -> None:
|
||||
"""Background loop that polls for status changes and broadcasts."""
|
||||
# Foreground tracker is imported lazily so unit tests of the WS
|
||||
# manager don't drag in platform-specific probe code.
|
||||
from .foreground_service import get_foreground_info
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
# Only poll if we have connected clients
|
||||
@@ -309,6 +394,28 @@ class ConnectionManager:
|
||||
# Update cached status even without broadcast
|
||||
self._last_status = status_dict
|
||||
|
||||
# Foreground process — poll at a coarser interval than media
|
||||
# status. Broadcasts only fire on a real change, so a quiet
|
||||
# desktop costs nothing.
|
||||
now = time.time()
|
||||
if (
|
||||
now - self._last_foreground_poll
|
||||
) >= self._foreground_poll_interval:
|
||||
self._last_foreground_poll = now
|
||||
try:
|
||||
fg = await asyncio.to_thread(get_foreground_info)
|
||||
fg_dict = fg.to_dict()
|
||||
if self.foreground_changed(self._last_foreground, fg_dict):
|
||||
self._last_foreground = fg_dict
|
||||
await self.broadcast(
|
||||
{"type": "foreground_update", "data": fg_dict}
|
||||
)
|
||||
logger.debug("Broadcast sent: foreground change")
|
||||
else:
|
||||
self._last_foreground = fg_dict
|
||||
except Exception as e:
|
||||
logger.debug("Foreground poll failed: %s", e)
|
||||
|
||||
await asyncio.sleep(self._poll_interval)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
|
||||
@@ -15,8 +15,31 @@ logger = logging.getLogger(__name__)
|
||||
# Thread pool for WinRT operations (they don't play well with asyncio)
|
||||
_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="winrt")
|
||||
|
||||
# Global storage for current album art (as bytes)
|
||||
# Cache an asyncio event loop per worker thread so the 500ms status poll
|
||||
# doesn't allocate + tear down a new loop on every tick. Creating a loop
|
||||
# every 0.5s churns CPU and leaks finalized loop references that linger in
|
||||
# WinRT callbacks. With this helper a thread reuses one loop forever and
|
||||
# we only pay the setup cost once per worker.
|
||||
_thread_local = threading.local()
|
||||
|
||||
|
||||
def _thread_loop() -> asyncio.AbstractEventLoop:
|
||||
loop = getattr(_thread_local, "loop", None)
|
||||
if loop is None or loop.is_closed():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
_thread_local.loop = loop
|
||||
return loop
|
||||
|
||||
# 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
|
||||
_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
|
||||
_position_lock = threading.Lock()
|
||||
@@ -40,8 +63,9 @@ _track_skip_pending = {
|
||||
|
||||
|
||||
def get_current_album_art() -> bytes | None:
|
||||
"""Get the current album art bytes."""
|
||||
return _current_album_art_bytes
|
||||
"""Get the current album art bytes (thread-safe snapshot)."""
|
||||
with _art_lock:
|
||||
return _current_album_art_bytes
|
||||
|
||||
# Windows-specific imports
|
||||
try:
|
||||
@@ -161,8 +185,6 @@ WINDOWS_AVAILABLE = WINSDK_AVAILABLE
|
||||
|
||||
def _sync_get_media_status() -> dict[str, Any]:
|
||||
"""Synchronously get media status (runs in thread pool)."""
|
||||
import asyncio
|
||||
|
||||
result = {
|
||||
"state": "idle",
|
||||
"title": None,
|
||||
@@ -174,9 +196,7 @@ def _sync_get_media_status() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
try:
|
||||
# Create a new event loop for this thread
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop = _thread_loop()
|
||||
|
||||
try:
|
||||
# Get media session manager
|
||||
@@ -367,33 +387,54 @@ def _sync_get_media_status() -> dict[str, Any]:
|
||||
except Exception as 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:
|
||||
try:
|
||||
thumbnail = media_props.thumbnail
|
||||
if thumbnail:
|
||||
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()
|
||||
track_key = (
|
||||
getattr(media_props, "title", "") or "",
|
||||
getattr(media_props, "artist", "") or "",
|
||||
getattr(media_props, "album_title", "") or "",
|
||||
)
|
||||
global _current_album_art_bytes, _current_album_art_key
|
||||
if track_key == _current_album_art_key and _current_album_art_bytes:
|
||||
# Same track — reuse cached art bytes without touching WinRT.
|
||||
result["album_art_url"] = "/api/media/artwork"
|
||||
else:
|
||||
try:
|
||||
thumbnail = media_props.thumbnail
|
||||
if thumbnail:
|
||||
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
|
||||
_current_album_art_bytes = bytes(buffer)
|
||||
result["album_art_url"] = "/api/media/artwork"
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get album art: {e}")
|
||||
with _art_lock:
|
||||
_current_album_art_bytes = bytes(buffer)
|
||||
_current_album_art_key = track_key
|
||||
result["album_art_url"] = "/api/media/artwork"
|
||||
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
|
||||
|
||||
finally:
|
||||
loop.close()
|
||||
# Reuse the loop across calls — see _thread_loop above.
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting media status: {e}")
|
||||
@@ -439,35 +480,28 @@ def _find_best_session(manager, loop):
|
||||
|
||||
def _sync_media_command(command: str) -> bool:
|
||||
"""Synchronously execute a media command (runs in thread pool)."""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
try:
|
||||
manager = loop.run_until_complete(MediaManager.request_async())
|
||||
if manager is None:
|
||||
return False
|
||||
|
||||
session = _find_best_session(manager, loop)
|
||||
if session is None:
|
||||
return False
|
||||
|
||||
if command == "play":
|
||||
return loop.run_until_complete(session.try_play_async())
|
||||
elif command == "pause":
|
||||
return loop.run_until_complete(session.try_pause_async())
|
||||
elif command == "stop":
|
||||
return loop.run_until_complete(session.try_stop_async())
|
||||
elif command == "next":
|
||||
return loop.run_until_complete(session.try_skip_next_async())
|
||||
elif command == "previous":
|
||||
return loop.run_until_complete(session.try_skip_previous_async())
|
||||
|
||||
loop = _thread_loop()
|
||||
manager = loop.run_until_complete(MediaManager.request_async())
|
||||
if manager is None:
|
||||
return False
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
session = _find_best_session(manager, loop)
|
||||
if session is None:
|
||||
return False
|
||||
|
||||
if command == "play":
|
||||
return loop.run_until_complete(session.try_play_async())
|
||||
elif command == "pause":
|
||||
return loop.run_until_complete(session.try_pause_async())
|
||||
elif command == "stop":
|
||||
return loop.run_until_complete(session.try_stop_async())
|
||||
elif command == "next":
|
||||
return loop.run_until_complete(session.try_skip_next_async())
|
||||
elif command == "previous":
|
||||
return loop.run_until_complete(session.try_skip_previous_async())
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing media command {command}: {e}")
|
||||
@@ -476,27 +510,20 @@ def _sync_media_command(command: str) -> bool:
|
||||
|
||||
def _sync_seek(position: float) -> bool:
|
||||
"""Synchronously seek to position."""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop = _thread_loop()
|
||||
manager = loop.run_until_complete(MediaManager.request_async())
|
||||
if manager is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
manager = loop.run_until_complete(MediaManager.request_async())
|
||||
if manager is None:
|
||||
return False
|
||||
session = _find_best_session(manager, loop)
|
||||
if session is None:
|
||||
return False
|
||||
|
||||
session = _find_best_session(manager, loop)
|
||||
if session is None:
|
||||
return False
|
||||
|
||||
position_ticks = int(position * 10_000_000)
|
||||
return loop.run_until_complete(
|
||||
session.try_change_playback_position_async(position_ticks)
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
position_ticks = int(position * 10_000_000)
|
||||
return loop.run_until_complete(
|
||||
session.try_change_playback_position_async(position_ticks)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error seeking: {e}")
|
||||
@@ -559,7 +586,7 @@ class WindowsMediaController(MediaController):
|
||||
|
||||
# Get media info in thread pool (avoids asyncio/WinRT issues)
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
media_info = await asyncio.wait_for(
|
||||
loop.run_in_executor(_executor, _sync_get_media_status),
|
||||
timeout=5.0
|
||||
@@ -592,7 +619,7 @@ class WindowsMediaController(MediaController):
|
||||
async def _run_command(self, command: str) -> bool:
|
||||
"""Run a media command in the thread pool."""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
return await asyncio.wait_for(
|
||||
loop.run_in_executor(_executor, _sync_media_command, command),
|
||||
timeout=5.0
|
||||
@@ -616,16 +643,15 @@ class WindowsMediaController(MediaController):
|
||||
"""Stop playback."""
|
||||
return await self._run_command("stop")
|
||||
|
||||
async def next_track(self) -> bool:
|
||||
"""Skip to next track."""
|
||||
# Get current title before skipping
|
||||
try:
|
||||
status = await self.get_status()
|
||||
old_title = status.title or ""
|
||||
except Exception:
|
||||
old_title = ""
|
||||
async def _skip_track(self, command: str) -> bool:
|
||||
# Read the current title from the position cache instead of doing a
|
||||
# full WinRT round-trip (which can take up to 5s) just for one field.
|
||||
with _position_lock:
|
||||
track_id = _position_cache.get("track_id") or ""
|
||||
# track_id is "title:artist:duration" — extract just the title.
|
||||
old_title = track_id.split(":", 1)[0] if track_id else ""
|
||||
|
||||
result = await self._run_command("next")
|
||||
result = await self._run_command(command)
|
||||
if result:
|
||||
with _position_lock:
|
||||
_track_skip_pending["active"] = True
|
||||
@@ -634,23 +660,13 @@ class WindowsMediaController(MediaController):
|
||||
logger.debug(f"Track skip initiated, old title: {old_title}")
|
||||
return result
|
||||
|
||||
async def next_track(self) -> bool:
|
||||
"""Skip to next track."""
|
||||
return await self._skip_track("next")
|
||||
|
||||
async def previous_track(self) -> bool:
|
||||
"""Go to previous track."""
|
||||
# Get current title before skipping
|
||||
try:
|
||||
status = await self.get_status()
|
||||
old_title = status.title or ""
|
||||
except Exception:
|
||||
old_title = ""
|
||||
|
||||
result = await self._run_command("previous")
|
||||
if result:
|
||||
with _position_lock:
|
||||
_track_skip_pending["active"] = True
|
||||
_track_skip_pending["old_title"] = old_title
|
||||
_track_skip_pending["skip_time"] = _time.time()
|
||||
logger.debug(f"Track skip initiated, old title: {old_title}")
|
||||
return result
|
||||
return await self._skip_track("previous")
|
||||
|
||||
async def set_volume(self, volume: int) -> bool:
|
||||
"""Set system volume."""
|
||||
@@ -680,7 +696,7 @@ class WindowsMediaController(MediaController):
|
||||
async def seek(self, position: float) -> bool:
|
||||
"""Seek to position in seconds."""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
return await asyncio.wait_for(
|
||||
loop.run_in_executor(_executor, _sync_seek, position),
|
||||
timeout=5.0
|
||||
@@ -705,7 +721,7 @@ class WindowsMediaController(MediaController):
|
||||
"""
|
||||
try:
|
||||
import os
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, lambda: os.startfile(file_path))
|
||||
logger.info(f"Opened file with default player: {file_path}")
|
||||
return True
|
||||
|
||||
+5968
-238
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 208 B After Width: | Height: | Size: 37 KiB |
@@ -1,10 +1,33 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Media Server">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1db954;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1ed760;stop-opacity:1" />
|
||||
<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>
|
||||
<circle cx="50" cy="50" r="45" fill="url(#grad)"/>
|
||||
<path fill="white" d="M35 25 L35 75 L75 50 Z"/>
|
||||
<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>
|
||||
|
||||
|
Before Width: | Height: | Size: 421 B After Width: | Height: | Size: 1.6 KiB |
+233
-129
@@ -6,6 +6,7 @@
|
||||
<title>Media Server</title>
|
||||
<meta name="description" content="Remote media player control and file browser">
|
||||
<meta name="theme-color" content="#121212">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Media Server">
|
||||
@@ -25,16 +26,16 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini-controls">
|
||||
<button class="mini-control-btn mini-nav-btn" onclick="previousTrack()" data-i18n-title="player.previous" title="Previous">
|
||||
<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
||||
<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" aria-hidden="true" focusable="false"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
||||
</button>
|
||||
<button class="mini-control-btn" onclick="togglePlayPause()" id="mini-btn-play-pause" title="Play/Pause">
|
||||
<svg viewBox="0 0 24 24" id="mini-play-pause-icon">
|
||||
<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" aria-hidden="true" focusable="false">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="mini-control-btn mini-nav-btn" onclick="nextTrack()" data-i18n-title="player.next" title="Next">
|
||||
<svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
||||
<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" aria-hidden="true" focusable="false"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mini-progress-container">
|
||||
@@ -47,8 +48,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini-volume-container">
|
||||
<button class="mini-control-btn" onclick="toggleMute()" id="mini-btn-mute" title="Mute">
|
||||
<svg viewBox="0 0 24 24" id="mini-mute-icon">
|
||||
<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" 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"/>
|
||||
</svg>
|
||||
</button>
|
||||
@@ -66,7 +67,7 @@
|
||||
<h2 data-i18n="app.title">Media Server</h2>
|
||||
<p data-i18n="auth.message">Enter your API token to connect to the media server.</p>
|
||||
<input type="text" id="token-input" data-i18n-placeholder="auth.placeholder" placeholder="Enter API Token" autocomplete="off">
|
||||
<button class="btn-connect" onclick="authenticate()" data-i18n="auth.connect">Connect</button>
|
||||
<button class="btn-connect" data-onclick="authenticate()" data-i18n="auth.connect">Connect</button>
|
||||
<div class="help-text">
|
||||
<p data-i18n="auth.help">To get your token, run:</p>
|
||||
<code>media-server --show-token</code>
|
||||
@@ -75,27 +76,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Folio marks at page corners -->
|
||||
<span class="folio tl"><span class="status-dot" id="status-dot" aria-live="polite"></span><span data-i18n="header.connected">Connected</span> · <span id="folio-host">Local 8765</span></span>
|
||||
<span class="folio tr"><span data-i18n="header.volume">Vol. I</span> — <span data-i18n="header.edition">Studio Reference</span> · <span id="version-label"></span></span>
|
||||
|
||||
<div class="container">
|
||||
<header>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<span class="status-dot" id="status-dot" aria-live="polite"></span>
|
||||
<span class="version-label" id="version-label"></span>
|
||||
<div class="brand">
|
||||
<span class="brand-name" data-i18n="app.title">Media Server</span>
|
||||
<span class="brand-sub" data-i18n="header.edition_sub">Studio Reference Edition</span>
|
||||
</div>
|
||||
<div class="header-toolbar">
|
||||
<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>
|
||||
</a>
|
||||
<button class="header-btn" data-onclick="showAboutDialog()" data-i18n-title="about.button_title" title="About" aria-label="About">
|
||||
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M11 7h2v2h-2V7zm0 4h2v6h-2v-6zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg>
|
||||
</button>
|
||||
<div class="accent-picker">
|
||||
<button class="header-btn" onclick="toggleAccentPicker()" title="Accent color" aria-label="Accent color">
|
||||
<button class="header-btn" data-onclick="toggleAccentPicker()" title="Accent color" aria-label="Accent color">
|
||||
<span class="accent-dot" id="accentDot"></span>
|
||||
</button>
|
||||
<div class="accent-picker-dropdown" id="accentDropdown"></div>
|
||||
</div>
|
||||
<button class="header-btn" onclick="toggleDynamicBackground()" data-i18n-title="player.background" title="Dynamic background" aria-label="Dynamic background" id="bgToggle">
|
||||
<button class="header-btn" data-onclick="toggleDynamicBackground()" data-i18n-title="player.background" title="Dynamic background" aria-label="Dynamic background" id="bgToggle">
|
||||
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 3v10.55A4 4 0 1 0 14 17V7h4V3h-6z"/></svg>
|
||||
</button>
|
||||
<button class="header-btn" onclick="toggleTheme()" data-i18n-title="player.theme" title="Toggle theme" aria-label="Toggle theme" id="theme-toggle">
|
||||
<button class="header-btn" data-onclick="togglePlayerFullscreen()" data-i18n-title="player.fullscreen" title="Fullscreen player" aria-label="Fullscreen player" id="fullscreenToggle">
|
||||
<svg id="fullscreen-icon-enter" viewBox="0 0 24 24"><path fill="currentColor" d="M5 5h5v2H7v3H5V5zm9 0h5v5h-2V7h-3V5zm0 14v-2h3v-3h2v5h-5zM5 14h2v3h3v2H5v-5z"/></svg>
|
||||
<svg id="fullscreen-icon-exit" viewBox="0 0 24 24" style="display:none"><path fill="currentColor" d="M8 8H5v2H3V6a1 1 0 0 1 1-1h4v3zm8 0h3v2h2V6a1 1 0 0 0-1-1h-4v3zm0 8h3v-2h2v4a1 1 0 0 1-1 1h-4v-3zM8 16H5v-2H3v4a1 1 0 0 0 1 1h4v-3z"/></svg>
|
||||
</button>
|
||||
<button class="header-btn" data-onclick="toggleTheme()" data-i18n-title="player.theme" title="Toggle theme" aria-label="Toggle theme" id="theme-toggle">
|
||||
<svg id="theme-icon-sun" viewBox="0 0 24 24" style="display: none;">
|
||||
<path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z"/>
|
||||
</svg>
|
||||
@@ -103,12 +115,12 @@
|
||||
<path d="M9 2c-1.05 0-2.05.16-3 .46 4.06 1.27 7 5.06 7 9.54 0 4.48-2.94 8.27-7 9.54.95.3 1.95.46 3 .46 5.52 0 10-4.48 10-10S14.52 2 9 2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<select id="locale-select" class="header-locale" onchange="changeLocale()" title="Change language">
|
||||
<select id="locale-select" class="header-locale" data-onchange="changeLocale()" title="Change language">
|
||||
<option value="en">EN</option>
|
||||
<option value="ru">RU</option>
|
||||
</select>
|
||||
<span class="header-toolbar-sep"></span>
|
||||
<button class="header-btn header-btn-logout" onclick="clearToken()" data-i18n-title="auth.logout.title" title="Clear saved token" aria-label="Logout">
|
||||
<button class="header-btn header-btn-logout" data-onclick="clearToken()" data-i18n-title="auth.logout.title" title="Clear saved token" aria-label="Logout">
|
||||
<svg viewBox="0 0 24 24"><path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -124,106 +136,177 @@
|
||||
<!-- Connection Banner -->
|
||||
<div class="connection-banner hidden" id="connectionBanner">
|
||||
<span id="connectionBannerText"></span>
|
||||
<button class="connection-banner-btn" id="connectionBannerBtn" onclick="manualReconnect()" style="display: none;" data-i18n="connection.reconnect">Reconnect</button>
|
||||
<button class="connection-banner-btn" id="connectionBannerBtn" data-onclick="manualReconnect()" style="display: none;" data-i18n="connection.reconnect">Reconnect</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Bar -->
|
||||
<!-- Tab Bar (editorial: numbered, italic active) -->
|
||||
<div class="tab-bar" id="tabBar" role="tablist">
|
||||
<div class="tab-indicator" id="tabIndicator"></div>
|
||||
<button class="tab-btn active" data-tab="player" onclick="switchTab('player')" role="tab" aria-selected="true" aria-controls="panel-player" tabindex="0">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>
|
||||
<span data-i18n="tab.player">Player</span>
|
||||
<button class="tab-btn active" data-tab="player" data-onclick="switchTab('player')" role="tab" aria-selected="true" aria-controls="panel-player" tabindex="0">
|
||||
<span class="tab-num">01</span>
|
||||
<span data-i18n="tab.player">Now Spinning</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="display" onclick="switchTab('display')" role="tab" aria-selected="false" aria-controls="panel-display" tabindex="-1">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M20 3H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h6v2H8v2h8v-2h-2v-2h6c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H4V5h16v10z"/></svg>
|
||||
<button class="tab-btn" data-tab="display" data-onclick="switchTab('display')" role="tab" aria-selected="false" aria-controls="panel-display" tabindex="-1">
|
||||
<span class="tab-num">02</span>
|
||||
<span data-i18n="tab.display">Display</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="browser" onclick="switchTab('browser')" role="tab" aria-selected="false" aria-controls="panel-browser" tabindex="-1">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>
|
||||
<span data-i18n="tab.browser">Browser</span>
|
||||
<button class="tab-btn" data-tab="browser" data-onclick="switchTab('browser')" role="tab" aria-selected="false" aria-controls="panel-browser" tabindex="-1">
|
||||
<span class="tab-num">03</span>
|
||||
<span data-i18n="tab.browser">Library</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="quick-actions" onclick="switchTab('quick-actions')" role="tab" aria-selected="false" aria-controls="panel-quick-actions" tabindex="-1">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M7 2v11h3v9l7-12h-4l4-8z"/></svg>
|
||||
<button class="tab-btn" data-tab="quick-actions" data-onclick="switchTab('quick-actions')" role="tab" aria-selected="false" aria-controls="panel-quick-actions" tabindex="-1">
|
||||
<span class="tab-num">04</span>
|
||||
<span data-i18n="tab.quick_access">Quick Access</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="settings" onclick="switchTab('settings')" role="tab" aria-selected="false" aria-controls="panel-settings" tabindex="-1">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
|
||||
<button class="tab-btn" data-tab="settings" data-onclick="switchTab('settings')" role="tab" aria-selected="false" aria-controls="panel-settings" tabindex="-1">
|
||||
<span class="tab-num">05</span>
|
||||
<span data-i18n="tab.settings">Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="player-container" data-tab-content="player" role="tabpanel" id="panel-player">
|
||||
<div class="player-layout">
|
||||
<div class="album-art-container">
|
||||
<!-- Fullscreen-only chrome: floating top strip with kicker + exit. Auto-hides on idle. -->
|
||||
<div class="fs-chrome" id="fsChrome" aria-hidden="true">
|
||||
<div class="fs-chrome-mark">
|
||||
<span class="fs-chrome-edition" data-i18n="header.edition">Studio Reference</span>
|
||||
<span class="fs-chrome-sep">·</span>
|
||||
<span class="fs-chrome-kicker" data-i18n="player.kicker">Now Playing</span>
|
||||
</div>
|
||||
<button class="fs-chrome-exit" data-onclick="togglePlayerFullscreen()" data-i18n-title="player.fullscreen.exit" title="Exit fullscreen" aria-label="Exit fullscreen">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M8 8H5v2H3V6a1 1 0 0 1 1-1h4v3zm8 0h3v2h2V6a1 1 0 0 0-1-1h-4v3zm0 8h3v-2h2v4a1 1 0 0 1-1 1h-4v-3zM8 16H5v-2H3v4a1 1 0 0 0 1 1h4v-3z"/></svg>
|
||||
<span data-i18n="player.fullscreen.exit_short">Exit</span>
|
||||
<kbd class="fs-chrome-kbd">ESC</kbd>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Ambient album-art bloom: paints the room in the record's color while in fullscreen -->
|
||||
<div class="fs-bloom" id="fsBloom" aria-hidden="true">
|
||||
<img id="fs-bloom-art" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3C/svg%3E" alt="">
|
||||
</div>
|
||||
|
||||
<section class="now-playing player-layout">
|
||||
|
||||
<!-- Vinyl stage: cardstock sleeve + disc peeking out, plus tonearm -->
|
||||
<div class="vinyl-stage album-art-container">
|
||||
<img id="album-art-glow" class="album-art-glow" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3C/svg%3E" alt="" aria-hidden="true">
|
||||
<img id="album-art" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3Cpath fill='%236a6a6a' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E" alt="Album Art">
|
||||
<div class="sleeve">
|
||||
<img id="album-art" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Cpath fill='%236a6a6a' opacity='0.35' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E" alt="Album Art">
|
||||
<div class="sleeve-grain" aria-hidden="true"></div>
|
||||
<div class="sleeve-corner" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="vinyl-wrap">
|
||||
<div class="vinyl">
|
||||
<div class="vinyl-label">
|
||||
<!-- Stylised record-label catalogue mark, not user-facing
|
||||
copy — intentionally not in the i18n bundle. -->
|
||||
<span class="vinyl-label-text">REF · 24</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="armGrad" x1="0" x2="1">
|
||||
<stop offset="0" stop-color="#6d5f44"/>
|
||||
<stop offset="0.5" stop-color="#d8c39a"/>
|
||||
<stop offset="1" stop-color="#8a7a5a"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="176" cy="24" r="14" fill="#2a241c" stroke="#9C835A" stroke-width="1.5"/>
|
||||
<circle cx="176" cy="24" r="6" fill="#5C5447"/>
|
||||
<circle cx="176" cy="24" r="2.5" fill="#E08038"/>
|
||||
<line x1="176" y1="24" x2="64" y2="136" stroke="url(#armGrad)" stroke-width="5" stroke-linecap="round"/>
|
||||
<rect x="180" y="14" width="14" height="20" fill="#3A3528" stroke="#9C835A" stroke-width="1"/>
|
||||
<rect x="56" y="128" width="22" height="18" rx="2" fill="#3A3528" stroke="#9C835A" stroke-width="1" transform="rotate(-45 67 137)"/>
|
||||
<circle cx="62" cy="138" r="3.5" fill="#E08038" opacity="0.95"/>
|
||||
<circle cx="62" cy="138" r="7" fill="none" stroke="#E08038" stroke-width="0.8" opacity="0.5"/>
|
||||
</svg>
|
||||
<canvas id="spectrogram-canvas" class="spectrogram-canvas" width="300" height="64"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="player-details">
|
||||
<div class="track-info">
|
||||
<div id="track-title" data-i18n="player.no_media">No media playing</div>
|
||||
<div id="artist"></div>
|
||||
<div id="album"></div>
|
||||
<div class="playback-state">
|
||||
<svg class="state-icon" id="state-icon" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>
|
||||
</svg>
|
||||
<span id="playback-state" data-i18n="state.idle">Idle</span>
|
||||
<!-- Track masthead -->
|
||||
<div class="track-masthead player-details">
|
||||
|
||||
<div class="kicker"><span data-i18n="player.kicker">Now Playing</span></div>
|
||||
|
||||
<h1 class="track-title" id="track-title" data-i18n="player.no_media">No media playing</h1>
|
||||
<div class="track-byline" id="artist"></div>
|
||||
<div class="track-album" id="album"></div>
|
||||
|
||||
<!-- 2-cell metadata grid -->
|
||||
<div class="meta-grid meta-grid-2">
|
||||
<div class="meta-cell">
|
||||
<div class="label" data-i18n="meta.state">State</div>
|
||||
<div class="value">
|
||||
<svg class="state-icon" id="state-icon" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>
|
||||
</svg>
|
||||
<span id="playback-state" data-i18n="state.idle">Idle</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta-cell">
|
||||
<div class="label" data-i18n="meta.source">Source</div>
|
||||
<div class="value source-value">
|
||||
<span class="source-icon" id="sourceIcon"></span>
|
||||
<span id="source" data-i18n="player.unknown_source">Unknown</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-container">
|
||||
<div class="time-display">
|
||||
<span id="current-time">0:00</span>
|
||||
<span id="total-time">0:00</span>
|
||||
<!-- Spectrum bars — driven by real audio when visualizer is active,
|
||||
CSS-animated synthetic motion otherwise. JS injects the spans. -->
|
||||
<div class="spectrum" id="player-spectrum" aria-hidden="true"></div>
|
||||
|
||||
<!-- Transport -->
|
||||
<div class="transport">
|
||||
<div class="progress-row">
|
||||
<span class="timecode elapsed" id="current-time">0:00</span>
|
||||
<div class="progress-track progress-bar" id="progress-bar" data-duration="0" role="slider" aria-label="Playback position" aria-valuemin="0" aria-valuemax="0" aria-valuenow="0">
|
||||
<div class="progress-fill" id="progress-fill"></div>
|
||||
</div>
|
||||
<span class="timecode" id="total-time">0:00</span>
|
||||
</div>
|
||||
<div class="progress-bar" id="progress-bar" data-duration="0" role="slider" aria-label="Playback position" aria-valuemin="0" aria-valuemax="0" aria-valuenow="0">
|
||||
<div class="progress-fill" id="progress-fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button onclick="previousTrack()" data-i18n-title="player.previous" title="Previous" id="btn-previous">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="primary" onclick="togglePlayPause()" data-i18n-title="player.play" title="Play/Pause" id="btn-play-pause">
|
||||
<svg viewBox="0 0 24 24" id="play-pause-icon">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button onclick="nextTrack()" data-i18n-title="player.next" title="Next" id="btn-next">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="volume-container">
|
||||
<button class="mute-btn" onclick="toggleMute()" data-i18n-title="player.mute" title="Mute" id="btn-mute">
|
||||
<svg viewBox="0 0 24 24" id="mute-icon">
|
||||
<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>
|
||||
</button>
|
||||
<input type="range" id="volume-slider" min="0" max="100" value="50" aria-label="Volume">
|
||||
<div class="volume-display" id="volume-display">50%</div>
|
||||
</div>
|
||||
|
||||
<div class="source-info">
|
||||
<span class="source-label"><span class="source-icon" id="sourceIcon"></span><span id="source" data-i18n="player.unknown_source">Unknown</span></span>
|
||||
<div class="player-toggles">
|
||||
<button class="vinyl-toggle-btn" onclick="toggleVinylMode()" id="vinylToggle" data-i18n-title="player.vinyl" title="Vinyl mode">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="12" cy="12" r="3" fill="currentColor"/><circle cx="12" cy="12" r="6.5" fill="none" stroke="currentColor" stroke-width="0.5" opacity="0.5"/></svg>
|
||||
<div class="controls">
|
||||
<button class="btn-trans" data-onclick="previousTrack()" data-i18n-title="player.previous" title="Previous" id="btn-previous">
|
||||
<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
||||
</button>
|
||||
<button class="vinyl-toggle-btn" onclick="toggleVisualizer()" id="visualizerToggle" data-i18n-title="player.visualizer" title="Audio visualizer" style="display:none">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3 18h2v-8H3v8zm4 0h2V6H7v12zm4 0h2V2h-2v16zm4 0h2v-6h-2v6zm4 0h2V9h-2v9z"/></svg>
|
||||
<button class="btn-trans primary" data-onclick="togglePlayPause()" data-i18n-title="player.play" title="Play/Pause" id="btn-play-pause">
|
||||
<svg viewBox="0 0 24 24" id="play-pause-icon"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
<button class="btn-trans" data-onclick="nextTrack()" data-i18n-title="player.next" title="Next" id="btn-next">
|
||||
<svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="vu-cluster">
|
||||
<div class="vu-meter" aria-hidden="true">
|
||||
<div class="vu-needle" id="vuNeedle"></div>
|
||||
</div>
|
||||
<div class="vu-readout">
|
||||
<span>OUT <strong id="vu-out">SYS</strong></span>
|
||||
<span>VOL <strong id="vu-vol">50%</strong></span>
|
||||
</div>
|
||||
<!-- Volume control: mute + slim slider, integrated -->
|
||||
<div class="vu-volume">
|
||||
<button class="mute-btn" data-onclick="toggleMute()" data-i18n-title="player.mute" title="Mute" id="btn-mute">
|
||||
<svg viewBox="0 0 24 24" id="mute-icon">
|
||||
<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>
|
||||
</button>
|
||||
<input type="range" id="volume-slider" min="0" max="100" value="50" aria-label="Volume">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden but functional: legacy display + visualizer toggle. -->
|
||||
<div class="visually-hidden">
|
||||
<div id="volume-display">50%</div>
|
||||
<button data-onclick="toggleVisualizer()" id="visualizerToggle" data-i18n-title="player.visualizer" title="Audio visualizer" style="display:none">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3 18h2v-8H3v8zm4 0h2V6H7v12zm4 0h2V2h-2v16zm4 0h2v-6h-2v6zm4 0h2V9h-2v9z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Media Browser Section -->
|
||||
@@ -235,34 +318,34 @@
|
||||
<div class="browser-toolbar" id="browserToolbar">
|
||||
<div class="browser-toolbar-left">
|
||||
<div class="view-toggle">
|
||||
<button class="view-toggle-btn active" id="viewGridBtn" onclick="setViewMode('grid')" data-i18n-title="browser.view_grid" title="Grid view">
|
||||
<button class="view-toggle-btn active" id="viewGridBtn" data-onclick="setViewMode('grid')" data-i18n-title="browser.view_grid" title="Grid view">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M3 3h8v8H3V3zm0 10h8v8H3v-8zm10-10h8v8h-8V3zm0 10h8v8h-8v-8z"/></svg>
|
||||
</button>
|
||||
<button class="view-toggle-btn" id="viewCompactBtn" onclick="setViewMode('compact')" data-i18n-title="browser.view_compact" title="Compact view">
|
||||
<button class="view-toggle-btn" id="viewCompactBtn" data-onclick="setViewMode('compact')" data-i18n-title="browser.view_compact" title="Compact view">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M2 2h5v5H2V2zm0 8h5v5H2v-5zm0 8h5v5H2v-5zm7-16h5v5H9V2zm0 8h5v5H9v-5zm0 8h5v5H9v-5zm7-16h5v5h-5V2zm0 8h5v5h-5v-5zm0 8h5v5h-5v-5z"/></svg>
|
||||
</button>
|
||||
<button class="view-toggle-btn" id="viewListBtn" onclick="setViewMode('list')" data-i18n-title="browser.view_list" title="List view">
|
||||
<button class="view-toggle-btn" id="viewListBtn" data-onclick="setViewMode('list')" data-i18n-title="browser.view_list" title="List view">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M3 4h18v2H3V4zm0 7h18v2H3v-2zm0 7h18v2H3v-2z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<button class="view-toggle-btn browser-refresh-btn" id="refreshBtn" onclick="refreshBrowser()" data-i18n-title="browser.refresh" title="Refresh">
|
||||
<button class="view-toggle-btn browser-refresh-btn" id="refreshBtn" data-onclick="refreshBrowser()" data-i18n-title="browser.refresh" title="Refresh">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||||
</button>
|
||||
<button class="browser-play-all-btn" id="playAllBtn" onclick="playAllFolder()" data-i18n-title="browser.play_all" title="Play All" style="display: none;">
|
||||
<button class="browser-play-all-btn" id="playAllBtn" data-onclick="playAllFolder()" data-i18n-title="browser.play_all" title="Play All" style="display: none;">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="browser-search-wrapper" id="browserSearchWrapper" style="display: none;">
|
||||
<svg class="browser-search-icon" viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0016 9.5 6.5 6.5 0 109.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
|
||||
<input type="text" id="browserSearchInput" class="browser-search-input" data-i18n-placeholder="browser.search" placeholder="Search..." oninput="onBrowserSearch()">
|
||||
<button class="browser-search-clear" id="browserSearchClear" onclick="clearBrowserSearch()" style="display: none;">
|
||||
<input type="text" id="browserSearchInput" class="browser-search-input" data-i18n-placeholder="browser.search" placeholder="Search..." data-oninput="onBrowserSearch()">
|
||||
<button class="browser-search-clear" id="browserSearchClear" data-onclick="clearBrowserSearch()" style="display: none;">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="browser-toolbar-right">
|
||||
<label class="items-per-page-label">
|
||||
<span data-i18n="browser.items_per_page">Items per page:</span>
|
||||
<select id="itemsPerPageSelect" onchange="onItemsPerPageChanged()">
|
||||
<select id="itemsPerPageSelect" data-onchange="onItemsPerPageChanged()">
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100" selected>100</option>
|
||||
@@ -283,13 +366,13 @@
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="pagination" id="browserPagination" style="display: none;">
|
||||
<button id="prevPage" onclick="previousPage()" data-i18n="browser.previous">Previous</button>
|
||||
<button id="prevPage" data-onclick="previousPage()" data-i18n="browser.previous">Previous</button>
|
||||
<div class="pagination-center">
|
||||
<span data-i18n="browser.page">Page</span>
|
||||
<input type="number" id="pageInput" class="page-input" min="1" value="1" onchange="goToPage()">
|
||||
<input type="number" id="pageInput" class="page-input" min="1" value="1" data-onchange="goToPage()">
|
||||
<span id="pageTotal">/ 1</span>
|
||||
</div>
|
||||
<button id="nextPage" onclick="nextPage()" data-i18n="browser.next">Next</button>
|
||||
<button id="nextPage" data-onclick="nextPage()" data-i18n="browser.next">Next</button>
|
||||
<span class="pagination-showing" id="paginationShowing"></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -315,7 +398,7 @@
|
||||
<div class="audio-device-selector">
|
||||
<label>
|
||||
<span data-i18n="settings.audio.device">Loopback Device</span>
|
||||
<select id="audioDeviceSelect" onchange="onAudioDeviceChanged()">
|
||||
<select id="audioDeviceSelect" data-onchange="onAudioDeviceChanged()">
|
||||
<option value="" data-i18n="settings.audio.auto">Auto-detect</option>
|
||||
</select>
|
||||
</label>
|
||||
@@ -351,7 +434,7 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="add-card" onclick="showAddFolderDialog()">
|
||||
<div class="add-card" data-onclick="showAddFolderDialog()">
|
||||
<span class="add-card-icon">+</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -381,7 +464,7 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="add-card" onclick="showAddScriptDialog()">
|
||||
<div class="add-card" data-onclick="showAddScriptDialog()">
|
||||
<span class="add-card-icon">+</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -413,7 +496,7 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="add-card" onclick="showAddLinkDialog()">
|
||||
<div class="add-card" data-onclick="showAddLinkDialog()">
|
||||
<span class="add-card-icon">+</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -439,20 +522,20 @@
|
||||
<td colspan="4" class="empty-state">
|
||||
<div class="empty-state-illustration">
|
||||
<svg viewBox="0 0 64 64"><circle cx="32" cy="32" r="24"/><path d="M32 20v12l8 8"/></svg>
|
||||
<p>No callbacks configured. Click "Add" to create one.</p>
|
||||
<p data-i18n="callbacks.empty">No callbacks configured. Click "Add" to create one.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="add-card" onclick="showAddCallbackDialog()">
|
||||
<div class="add-card" data-onclick="showAddCallbackDialog()">
|
||||
<span class="add-card-icon">+</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Display Control Section -->
|
||||
<!-- Display Control Section (monitors first, foreground overview below) -->
|
||||
<div class="display-container" data-tab-content="display" role="tabpanel" id="panel-display">
|
||||
<div class="display-monitors" id="displayMonitors">
|
||||
<div class="empty-state-illustration">
|
||||
@@ -460,6 +543,12 @@
|
||||
<p data-i18n="display.loading">Loading monitors...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="foreground-stage" id="foregroundStage">
|
||||
<div class="empty-state-illustration">
|
||||
<svg viewBox="0 0 64 64"><rect x="6" y="12" width="52" height="36" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><line x1="6" y1="20" x2="58" y2="20" stroke="currentColor" stroke-width="2"/><circle cx="11" cy="16" r="1.4" fill="currentColor"/><circle cx="15" cy="16" r="1.4" fill="currentColor"/><circle cx="19" cy="16" r="1.4" fill="currentColor"/></svg>
|
||||
<p data-i18n="foreground.loading">Waiting for foreground signal…</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -468,7 +557,7 @@
|
||||
<div class="dialog-header">
|
||||
<h3 id="dialogTitle" data-i18n="scripts.dialog.add">Add Script</h3>
|
||||
</div>
|
||||
<form id="scriptForm" onsubmit="saveScript(event)">
|
||||
<form id="scriptForm" data-onsubmit="saveScript(event)">
|
||||
<div class="dialog-body">
|
||||
<input type="hidden" id="scriptOriginalName">
|
||||
<input type="hidden" id="scriptIsEdit">
|
||||
@@ -510,13 +599,13 @@
|
||||
<div class="params-section">
|
||||
<div class="params-header">
|
||||
<span data-i18n="scripts.field.parameters">Parameters</span>
|
||||
<button type="button" class="btn-small" onclick="addParameterRow()" data-i18n="scripts.params.add">+ Add</button>
|
||||
<button type="button" class="btn-small" data-onclick="addParameterRow()" data-i18n="scripts.params.add">+ Add</button>
|
||||
</div>
|
||||
<div id="scriptParamsContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeScriptDialog()" data-i18n="scripts.button.cancel">Cancel</button>
|
||||
<button type="button" class="btn-secondary" data-onclick="closeScriptDialog()" data-i18n="scripts.button.cancel">Cancel</button>
|
||||
<button type="submit" class="btn-primary" data-i18n="scripts.button.save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -525,14 +614,14 @@
|
||||
<!-- Script Parameters Input Dialog (shown before executing scripts with params) -->
|
||||
<dialog id="scriptParamsDialog">
|
||||
<div class="dialog-header">
|
||||
<h3 id="scriptParamsDialogTitle">Execute Script</h3>
|
||||
<h3 id="scriptParamsDialogTitle" data-i18n="scripts.params.execute">Execute Script</h3>
|
||||
</div>
|
||||
<form id="scriptParamsForm" onsubmit="submitScriptWithParams(event)">
|
||||
<form id="scriptParamsForm" data-onsubmit="submitScriptWithParams(event)">
|
||||
<div class="dialog-body">
|
||||
<div id="scriptParamsInputs"></div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeScriptParamsDialog()" data-i18n="scripts.button.cancel">Cancel</button>
|
||||
<button type="button" class="btn-secondary" data-onclick="closeScriptParamsDialog()" data-i18n="scripts.button.cancel">Cancel</button>
|
||||
<button type="submit" class="btn-primary" data-i18n="scripts.params.execute">Execute</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -543,7 +632,7 @@
|
||||
<div class="dialog-header">
|
||||
<h3 id="callbackDialogTitle" data-i18n="callbacks.dialog.add">Add Callback</h3>
|
||||
</div>
|
||||
<form id="callbackForm" onsubmit="saveCallback(event)">
|
||||
<form id="callbackForm" data-onsubmit="saveCallback(event)">
|
||||
<div class="dialog-body">
|
||||
<input type="hidden" id="callbackIsEdit">
|
||||
|
||||
@@ -581,7 +670,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeCallbackDialog()" data-i18n="callbacks.button.cancel">Cancel</button>
|
||||
<button type="button" class="btn-secondary" data-onclick="closeCallbackDialog()" data-i18n="callbacks.button.cancel">Cancel</button>
|
||||
<button type="submit" class="btn-primary" data-i18n="callbacks.button.save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -592,7 +681,7 @@
|
||||
<div class="dialog-header">
|
||||
<h3 id="linkDialogTitle" data-i18n="links.dialog.add">Add Link</h3>
|
||||
</div>
|
||||
<form id="linkForm" onsubmit="saveLink(event)">
|
||||
<form id="linkForm" data-onsubmit="saveLink(event)">
|
||||
<div class="dialog-body">
|
||||
<input type="hidden" id="linkOriginalName">
|
||||
<input type="hidden" id="linkIsEdit">
|
||||
@@ -627,7 +716,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeLinkDialog()" data-i18n="links.button.cancel">Cancel</button>
|
||||
<button type="button" class="btn-secondary" data-onclick="closeLinkDialog()" data-i18n="links.button.cancel">Cancel</button>
|
||||
<button type="submit" class="btn-primary" data-i18n="links.button.save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -654,7 +743,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeExecutionDialog()" data-i18n="scripts.execution.close">Close</button>
|
||||
<button type="button" class="btn-secondary" data-onclick="closeExecutionDialog()" data-i18n="scripts.execution.close">Close</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
@@ -663,7 +752,7 @@
|
||||
<div class="dialog-header">
|
||||
<h3 id="folderDialogTitle" data-i18n="browser.folder_dialog.title_add">Add Media Folder</h3>
|
||||
</div>
|
||||
<form id="folderForm" onsubmit="saveFolder(event)">
|
||||
<form id="folderForm" data-onsubmit="saveFolder(event)">
|
||||
<div class="dialog-body">
|
||||
<input type="hidden" id="folderIsEdit">
|
||||
<input type="hidden" id="folderOriginalId">
|
||||
@@ -690,7 +779,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeFolderDialog()" data-i18n="browser.folder_dialog.cancel">Cancel</button>
|
||||
<button type="button" class="btn-secondary" data-onclick="closeFolderDialog()" data-i18n="browser.folder_dialog.cancel">Cancel</button>
|
||||
<button type="submit" class="btn-primary" data-i18n="browser.folder_dialog.save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -708,16 +797,31 @@
|
||||
<!-- Toast Notifications -->
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer>
|
||||
<div>
|
||||
<span data-i18n="footer.created_by">Created by</span> <strong>Alexei Dolgolyov</strong>
|
||||
<span class="separator">•</span>
|
||||
<a href="mailto:dolgolyov.alexei@gmail.com">dolgolyov.alexei@gmail.com</a>
|
||||
<span class="separator">•</span>
|
||||
<a href="https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server" target="_blank" rel="noopener noreferrer" data-i18n="footer.source_code">Source Code</a>
|
||||
<!-- About Dialog -->
|
||||
<dialog id="aboutDialog" class="about-dialog">
|
||||
<div class="dialog-header">
|
||||
<h3 data-i18n="about.title">About</h3>
|
||||
</div>
|
||||
</footer>
|
||||
<div class="dialog-body">
|
||||
<p class="about-credit">
|
||||
<span data-i18n="about.created_by">Created by</span>
|
||||
<strong>Alexei Dolgolyov</strong>
|
||||
</p>
|
||||
<ul class="about-links">
|
||||
<li>
|
||||
<span class="about-links-label" data-i18n="about.email">Email</span>
|
||||
<a href="mailto:dolgolyov.alexei@gmail.com">dolgolyov.alexei@gmail.com</a>
|
||||
</li>
|
||||
<li>
|
||||
<span class="about-links-label" data-i18n="about.repository">Repository</span>
|
||||
<a href="https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server" target="_blank" rel="noopener noreferrer" data-i18n="about.source_code">Source Code</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button type="button" class="btn-secondary" data-onclick="closeAboutDialog()" data-i18n="dialog.close">Close</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script src="/static/dist/app.bundle.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
VOLUME_THROTTLE_MS, VOLUME_RELEASE_DELAY_MS,
|
||||
changeLocale, t,
|
||||
setAuthRequired,
|
||||
showAboutDialog, closeAboutDialog,
|
||||
} from './core.js';
|
||||
|
||||
// Layer 1: Player (tabs, theme, accent, vinyl, visualizer, UI)
|
||||
@@ -21,11 +22,11 @@ import {
|
||||
activeTab, switchTab, updateTabIndicator, setMiniPlayerVisible,
|
||||
initTheme, toggleTheme, initAccentColor, applyAccentColor,
|
||||
renderAccentSwatches, selectAccentColor, toggleAccentPicker, lightenColor,
|
||||
toggleVinylMode, applyVinylMode,
|
||||
visualizerEnabled, visualizerAvailable,
|
||||
visualizerEnabled, visualizerAvailable, setVisualizerEnabled,
|
||||
checkVisualizerAvailability, toggleVisualizer, applyVisualizerMode,
|
||||
loadAudioDevices, onAudioDeviceChanged,
|
||||
setupProgressDrag, updateUI, updatePlaybackState, stopPositionInterpolation,
|
||||
togglePlayerFullscreen, initPlayerFullscreen,
|
||||
} from './player.js';
|
||||
|
||||
// Layer 2: WebSocket
|
||||
@@ -62,6 +63,8 @@ import {
|
||||
|
||||
import {
|
||||
loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange,
|
||||
onDisplayContrastInput, onDisplayContrastChange,
|
||||
onDisplayInputSourceChange, onDisplayColorPresetChange, onDisplayPictureModeChange,
|
||||
toggleDisplayPower, loadHeaderLinks, loadLinksTable,
|
||||
showAddLinkDialog, showEditLinkDialog, closeLinkDialog, saveLink, deleteLinkConfirm,
|
||||
linkFormDirty, setLinkFormDirty,
|
||||
@@ -71,6 +74,10 @@ import {
|
||||
toggleDynamicBackground, applyDynamicBackground, updateBackgroundColors,
|
||||
} from './background.js';
|
||||
|
||||
import {
|
||||
updateForegroundUI, loadForegroundProcess,
|
||||
} from './foreground.js';
|
||||
|
||||
// ============================================================
|
||||
// Register late-bound callbacks for core's updateAllText()
|
||||
// ============================================================
|
||||
@@ -96,10 +103,12 @@ Object.assign(window, {
|
||||
switchTab,
|
||||
// Theme & accent
|
||||
toggleTheme, toggleAccentPicker, selectAccentColor, lightenColor,
|
||||
// Vinyl & visualizer
|
||||
toggleVinylMode, toggleVisualizer,
|
||||
// Visualizer (vinyl spin is structural CSS — no toggle)
|
||||
toggleVisualizer,
|
||||
// Background
|
||||
toggleDynamicBackground,
|
||||
// Fullscreen
|
||||
togglePlayerFullscreen,
|
||||
// Auth
|
||||
authenticate, clearToken, manualReconnect,
|
||||
// Locale
|
||||
@@ -124,9 +133,15 @@ Object.assign(window, {
|
||||
saveLink, deleteLinkConfirm,
|
||||
// Display
|
||||
loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange,
|
||||
onDisplayContrastInput, onDisplayContrastChange,
|
||||
onDisplayInputSourceChange, onDisplayColorPresetChange, onDisplayPictureModeChange,
|
||||
toggleDisplayPower,
|
||||
// Audio device
|
||||
onAudioDeviceChanged,
|
||||
// About
|
||||
showAboutDialog, closeAboutDialog,
|
||||
// Foreground
|
||||
loadForegroundProcess,
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
@@ -150,25 +165,116 @@ HTMLDialogElement.prototype.showModal = function (...args) {
|
||||
return result;
|
||||
};
|
||||
|
||||
// CSP-safe replacement for inline on* handlers. HTML uses data-onclick,
|
||||
// data-onchange, data-oninput, data-onsubmit with simple call expressions
|
||||
// like "fn()", "fn('arg')", "fn(event)". We parse those at startup and
|
||||
// attach proper addEventListener calls so script-src 'self' stays strict.
|
||||
const INLINE_HANDLER_EVENTS = {
|
||||
'data-onclick': 'click',
|
||||
'data-onchange': 'change',
|
||||
'data-oninput': 'input',
|
||||
'data-onsubmit': 'submit',
|
||||
};
|
||||
|
||||
function parseInlineHandlerArg(token) {
|
||||
const t = token.trim();
|
||||
if (t === '') return { kind: 'empty' };
|
||||
if (t === 'event') return { kind: 'event' };
|
||||
if (t === 'true') return { kind: 'literal', value: true };
|
||||
if (t === 'false') return { kind: 'literal', value: false };
|
||||
if (t === 'null') return { kind: 'literal', value: null };
|
||||
if (/^-?\d+(\.\d+)?$/.test(t)) return { kind: 'literal', value: Number(t) };
|
||||
if ((t.startsWith("'") && t.endsWith("'")) || (t.startsWith('"') && t.endsWith('"'))) {
|
||||
return { kind: 'literal', value: t.slice(1, -1) };
|
||||
}
|
||||
console.warn('inline-handler: unsupported arg token', token);
|
||||
return { kind: 'literal', value: undefined };
|
||||
}
|
||||
|
||||
function compileInlineHandler(expr) {
|
||||
const m = expr.match(/^\s*([A-Za-z_$][\w$]*)\s*\((.*)\)\s*;?\s*$/s);
|
||||
if (!m) {
|
||||
console.warn('inline-handler: unparsable expression', expr);
|
||||
return null;
|
||||
}
|
||||
const fnName = m[1];
|
||||
const argsRaw = m[2].trim();
|
||||
const argTokens = argsRaw === '' ? [] : argsRaw.split(',').map(s => s.trim());
|
||||
const parsedArgs = argTokens.map(parseInlineHandlerArg);
|
||||
return function (event) {
|
||||
const fn = window[fnName];
|
||||
if (typeof fn !== 'function') {
|
||||
console.error('inline-handler: missing global function', fnName);
|
||||
return;
|
||||
}
|
||||
const args = parsedArgs.map(a => a.kind === 'event' ? event : a.value);
|
||||
return fn.apply(this, args);
|
||||
};
|
||||
}
|
||||
|
||||
function wireInlineHandlers(root) {
|
||||
for (const [attr, eventName] of Object.entries(INLINE_HANDLER_EVENTS)) {
|
||||
const nodes = root.querySelectorAll(`[${attr}]`);
|
||||
for (const el of nodes) {
|
||||
const expr = el.getAttribute(attr);
|
||||
const handler = compileInlineHandler(expr);
|
||||
if (handler) el.addEventListener(eventName, handler);
|
||||
el.removeAttribute(attr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
// Cache DOM references
|
||||
cacheDom();
|
||||
|
||||
// Wire CSP-safe inline-handler stand-ins from index.html
|
||||
wireInlineHandlers(document);
|
||||
|
||||
// Initialize theme and accent color
|
||||
initTheme();
|
||||
initAccentColor();
|
||||
initPlayerFullscreen();
|
||||
|
||||
// Register service worker for PWA installability
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
}
|
||||
|
||||
// Initialize vinyl mode
|
||||
applyVinylMode();
|
||||
// Build the editorial spectrum bars. Fewer, fatter bars read better
|
||||
// than many thin ones at this column width. JS-managed so we can
|
||||
// drive heights from real audio data when available.
|
||||
const spectrumRoot = document.getElementById('player-spectrum');
|
||||
if (spectrumRoot && !spectrumRoot.children.length) {
|
||||
const SPECTRUM_BARS = 40;
|
||||
// CSS repeat() doesn't accept a var() for its count — set the
|
||||
// grid column template from JS so it always matches the bar
|
||||
// count and stretches each bar to claim 1fr of the row.
|
||||
spectrumRoot.style.gridTemplateColumns =
|
||||
`repeat(${SPECTRUM_BARS}, minmax(0, 1fr))`;
|
||||
const frag = document.createDocumentFragment();
|
||||
for (let i = 0; i < SPECTRUM_BARS; i++) {
|
||||
const s = document.createElement('span');
|
||||
// Pseudo-random initial scaleY for the synthetic CSS-only
|
||||
// animation (used while no real audio is flowing).
|
||||
const scale = (0.25 + Math.abs(Math.sin(i * 0.7)) * 0.70).toFixed(2);
|
||||
s.style.setProperty('--bar-h-scale', scale);
|
||||
s.style.setProperty('--bar-delay', (-Math.random() * 1.1).toFixed(2) + 's');
|
||||
frag.appendChild(s);
|
||||
}
|
||||
spectrumRoot.appendChild(frag);
|
||||
}
|
||||
|
||||
// Initialize audio visualizer
|
||||
// Initialize audio visualizer — auto-enable when supported so the
|
||||
// spectrum shows real audio out of the box.
|
||||
checkVisualizerAvailability().then(() => {
|
||||
if (visualizerEnabled && visualizerAvailable) {
|
||||
if (!visualizerAvailable) return;
|
||||
// First install: opt the user in by default since the spectrum
|
||||
// is the centerpiece of the player view.
|
||||
const stored = localStorage.getItem('visualizerEnabled');
|
||||
const shouldEnable = stored === null ? true : stored === 'true';
|
||||
if (shouldEnable) {
|
||||
setVisualizerEnabled(true); // updates the let in player.js
|
||||
applyVisualizerMode();
|
||||
}
|
||||
});
|
||||
@@ -368,6 +474,16 @@ window.addEventListener('DOMContentLoaded', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// About dialog backdrop click to close
|
||||
const aboutDialog = document.getElementById('aboutDialog');
|
||||
if (aboutDialog) {
|
||||
aboutDialog.addEventListener('click', (e) => {
|
||||
if (e.target === aboutDialog) {
|
||||
closeAboutDialog();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Delegated click handlers for link table actions (XSS-safe)
|
||||
document.getElementById('linksTableBody').addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
|
||||
@@ -236,27 +236,54 @@ export function updateBackgroundColors() {
|
||||
|
||||
// ---- Render loop ----
|
||||
|
||||
// Cached step into the bins array; recomputed only when bins.length
|
||||
// changes (which happens at most once after the first audio frame
|
||||
// arrives or when num_bins is reconfigured).
|
||||
let bgBinsLength = -1;
|
||||
let bgBinsStep = 1;
|
||||
// Last applied resolution — drawing with stale viewport is harmless,
|
||||
// but we still need to refresh the uniform after the resize listener
|
||||
// has updated the canvas.
|
||||
let bgLastResW = -1;
|
||||
let bgLastResH = -1;
|
||||
|
||||
function renderBackgroundFrame() {
|
||||
bgAnimFrame = requestAnimationFrame(renderBackgroundFrame);
|
||||
|
||||
const gl = bgGL;
|
||||
if (!gl || !bgUniforms) return;
|
||||
|
||||
resizeBackgroundCanvas();
|
||||
gl.viewport(0, 0, bgCanvas.width, bgCanvas.height);
|
||||
// Resize listener already keeps canvas dimensions in sync — only
|
||||
// touch the viewport when the canvas actually changed size, so the
|
||||
// per-frame path doesn't read window.innerWidth (a layout-flushing
|
||||
// property).
|
||||
if (bgCanvas.width !== bgLastResW || bgCanvas.height !== bgLastResH) {
|
||||
bgLastResW = bgCanvas.width;
|
||||
bgLastResH = bgCanvas.height;
|
||||
gl.viewport(0, 0, bgLastResW, bgLastResH);
|
||||
gl.uniform2f(bgUniforms.resolution, bgLastResW, bgLastResH);
|
||||
}
|
||||
|
||||
const time = performance.now() / 1000 - bgStartTime;
|
||||
|
||||
// Smooth audio data from the imported frequencyData (shared with visualizer)
|
||||
// Smooth audio data from the imported frequencyData (shared with visualizer).
|
||||
// Backend may send float bins (legacy) or int×1000 (new); .scale tells us which.
|
||||
if (frequencyData && frequencyData.frequencies) {
|
||||
const bins = frequencyData.frequencies;
|
||||
const step = Math.max(1, Math.floor(bins.length / BG_BAND_COUNT));
|
||||
const scale = frequencyData.scale && frequencyData.scale > 0
|
||||
? 1.0 / frequencyData.scale : 1.0;
|
||||
if (bins.length !== bgBinsLength) {
|
||||
bgBinsLength = bins.length;
|
||||
bgBinsStep = Math.max(1, Math.floor(bgBinsLength / BG_BAND_COUNT));
|
||||
}
|
||||
const step = bgBinsStep;
|
||||
for (let i = 0; i < BG_BAND_COUNT; i++) {
|
||||
const idx = Math.min(i * step, bins.length - 1);
|
||||
const target = bins[idx] || 0;
|
||||
let idx = i * step;
|
||||
if (idx >= bgBinsLength) idx = bgBinsLength - 1;
|
||||
const target = (bins[idx] || 0) * scale;
|
||||
bgSmoothedBands[i] += (target - bgSmoothedBands[i]) * (1 - BG_SMOOTHING);
|
||||
}
|
||||
const targetBass = frequencyData.bass || 0;
|
||||
const targetBass = (frequencyData.bass || 0) * scale;
|
||||
bgSmoothedBass += (targetBass - bgSmoothedBass) * (1 - BG_SMOOTHING);
|
||||
} else {
|
||||
// Gentle decay when no audio
|
||||
@@ -267,7 +294,6 @@ function renderBackgroundFrame() {
|
||||
}
|
||||
|
||||
// Set uniforms (locations cached at init, colors cached on change)
|
||||
gl.uniform2f(bgUniforms.resolution, bgCanvas.width, bgCanvas.height);
|
||||
gl.uniform1f(bgUniforms.time, time);
|
||||
gl.uniform1f(bgUniforms.bass, bgSmoothedBass);
|
||||
gl.uniform1fv(bgUniforms.bands, bgSmoothedBands);
|
||||
|
||||
@@ -66,12 +66,14 @@ function showRootFolders() {
|
||||
// Hide search at root level
|
||||
showBrowserSearch(false);
|
||||
|
||||
// Render breadcrumb with just "Home" (not clickable at root)
|
||||
// Render breadcrumb with just "Home" (already at root — not interactive).
|
||||
const breadcrumb = document.getElementById('breadcrumb');
|
||||
breadcrumb.innerHTML = '';
|
||||
const root = document.createElement('span');
|
||||
root.className = 'breadcrumb-item breadcrumb-home';
|
||||
root.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>';
|
||||
root.setAttribute('aria-current', 'page');
|
||||
root.setAttribute('aria-label', t('browser.home') || 'Home');
|
||||
root.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>';
|
||||
breadcrumb.appendChild(root);
|
||||
|
||||
// Hide play all button and pagination
|
||||
@@ -133,8 +135,10 @@ function showRootFolders() {
|
||||
}
|
||||
|
||||
async function browsePath(folderId, path, offset = 0, nocache = false) {
|
||||
// Clear search when navigating
|
||||
// Clear search when navigating; bump browse generation so in-flight
|
||||
// thumbnail fetches from the previous folder can be discarded.
|
||||
showBrowserSearch(false);
|
||||
bumpBrowseGen();
|
||||
|
||||
try {
|
||||
if (!hasCredentials()) return;
|
||||
@@ -195,10 +199,13 @@ function renderBreadcrumbs(currentPathStr, parentPath) {
|
||||
const parts = (currentPathStr || '').split('/').filter(p => p);
|
||||
let path = '/';
|
||||
|
||||
// Home link (back to folder list)
|
||||
const home = document.createElement('span');
|
||||
// Home link (back to folder list) — use a real <button> so it's
|
||||
// keyboard-focusable and reachable by screen readers.
|
||||
const home = document.createElement('button');
|
||||
home.type = 'button';
|
||||
home.className = 'breadcrumb-item breadcrumb-home';
|
||||
home.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>';
|
||||
home.setAttribute('aria-label', t('browser.home') || 'Home');
|
||||
home.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>';
|
||||
home.onclick = () => showRootFolders();
|
||||
breadcrumb.appendChild(home);
|
||||
|
||||
@@ -512,16 +519,34 @@ function formatBitrate(bps) {
|
||||
return Math.round(bps / 1000) + ' kbps';
|
||||
}
|
||||
|
||||
// Bump this whenever the user changes folder/path so in-flight fetches from
|
||||
// the previous view can be ignored when they finally resolve.
|
||||
let _browseGen = 0;
|
||||
function bumpBrowseGen() { return ++_browseGen; }
|
||||
function currentBrowseGen() { return _browseGen; }
|
||||
|
||||
function buildRelativeFilePath(relativePath, fileName) {
|
||||
const base = (relativePath === '/' || relativePath === '') ? '' : relativePath.replace(/\/$/, '');
|
||||
return base + '/' + fileName;
|
||||
}
|
||||
|
||||
async function loadThumbnail(imgElement, fileName) {
|
||||
const myGen = currentBrowseGen();
|
||||
const folderId = currentFolderId;
|
||||
const relPath = buildRelativeFilePath(currentPath, fileName);
|
||||
const cacheKey = `${folderId}|${relPath}`;
|
||||
try {
|
||||
if (!hasCredentials()) return;
|
||||
|
||||
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName);
|
||||
// Note: the imgElement is intentionally NOT in the DOM yet when
|
||||
// renderBrowserGrid/renderBrowserList call us — it's still inside a
|
||||
// detached wrapper. Don't bail on isConnected here; rely on the
|
||||
// post-await checks below, which correctly catch navigation away.
|
||||
|
||||
// Check cache first
|
||||
if (thumbnailCache.has(absolutePath)) {
|
||||
const cachedUrl = thumbnailCache.get(absolutePath);
|
||||
if (thumbnailCache.has(cacheKey)) {
|
||||
const cachedUrl = thumbnailCache.get(cacheKey);
|
||||
imgElement.onload = () => {
|
||||
if (!imgElement.isConnected) return;
|
||||
imgElement.classList.remove('loading');
|
||||
imgElement.classList.add('loaded');
|
||||
};
|
||||
@@ -529,17 +554,24 @@ async function loadThumbnail(imgElement, fileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const encodedPath = encodeURIComponent(absolutePath);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
folder_id: folderId,
|
||||
path: relPath,
|
||||
size: 'medium',
|
||||
});
|
||||
const response = await fetch(
|
||||
`/api/browser/thumbnail?path=${encodedPath}&size=medium`,
|
||||
`/api/browser/thumbnail?${params.toString()}`,
|
||||
{ headers: getAuthHeaders() }
|
||||
);
|
||||
|
||||
// Drop the response if the user has since navigated away.
|
||||
if (myGen !== currentBrowseGen() || !imgElement.isConnected) return;
|
||||
|
||||
if (response.status === 200) {
|
||||
const blob = await response.blob();
|
||||
if (myGen !== currentBrowseGen() || !imgElement.isConnected) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
thumbnailCache.set(absolutePath, url);
|
||||
thumbnailCache.set(cacheKey, url);
|
||||
|
||||
// Evict oldest entries when cache exceeds limit
|
||||
if (thumbnailCache.size > THUMBNAIL_CACHE_MAX) {
|
||||
@@ -548,13 +580,11 @@ async function loadThumbnail(imgElement, fileName) {
|
||||
thumbnailCache.delete(oldest);
|
||||
}
|
||||
|
||||
// Wait for image to actually load before showing it
|
||||
imgElement.onload = () => {
|
||||
imgElement.classList.remove('loading');
|
||||
imgElement.classList.add('loaded');
|
||||
};
|
||||
|
||||
// Revoke previous blob URL if not managed by cache
|
||||
if (imgElement.src && imgElement.src.startsWith('blob:')) {
|
||||
let isCached = false;
|
||||
for (const cachedUrl of thumbnailCache.values()) {
|
||||
@@ -564,8 +594,8 @@ async function loadThumbnail(imgElement, fileName) {
|
||||
}
|
||||
imgElement.src = url;
|
||||
} else {
|
||||
// Fallback to icon (204 = no thumbnail available)
|
||||
const parent = imgElement.parentElement;
|
||||
if (!parent) return;
|
||||
const isList = parent.classList.contains('browser-list-icon');
|
||||
imgElement.remove();
|
||||
if (isList) {
|
||||
@@ -579,7 +609,7 @@ async function loadThumbnail(imgElement, fileName) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading thumbnail:', error);
|
||||
imgElement.classList.remove('loading');
|
||||
if (imgElement.isConnected) imgElement.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -601,12 +631,12 @@ async function playMediaFile(fileName) {
|
||||
try {
|
||||
if (!hasCredentials()) return;
|
||||
|
||||
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName);
|
||||
const relativePath = buildRelativeFilePath(currentPath, fileName);
|
||||
|
||||
const response = await fetch('/api/browser/play', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({ path: absolutePath })
|
||||
body: JSON.stringify({ folder_id: currentFolderId, path: relativePath })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to play file');
|
||||
|
||||
@@ -81,7 +81,7 @@ async function _loadCallbacksTableImpl() {
|
||||
`).join('');
|
||||
} catch (error) {
|
||||
console.error('Error loading callbacks:', error);
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="empty-state" style="color: var(--error);">Failed to load callbacks</td></tr>';
|
||||
tbody.innerHTML = `<tr><td colspan="4" class="empty-state" style="color: var(--error);">${escapeHtml(t('callbacks.msg.load_failed'))}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ export async function showEditCallbackDialog(callbackName) {
|
||||
const callback = callbacksList.find(c => c.name === callbackName);
|
||||
|
||||
if (!callback) {
|
||||
showToast('Callback not found', 'error');
|
||||
showToast(t('callbacks.msg.not_found'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ export async function showEditCallbackDialog(callbackName) {
|
||||
dialog.showModal();
|
||||
} catch (error) {
|
||||
console.error('Error loading callback for edit:', error);
|
||||
showToast('Failed to load callback details', 'error');
|
||||
showToast(t('callbacks.msg.load_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,9 +175,10 @@ export async function saveCallback(event) {
|
||||
shell: true
|
||||
};
|
||||
|
||||
const encodedName = encodeURIComponent(callbackName);
|
||||
const endpoint = isEdit ?
|
||||
`/api/callbacks/update/${callbackName}` :
|
||||
`/api/callbacks/create/${callbackName}`;
|
||||
`/api/callbacks/update/${encodedName}` :
|
||||
`/api/callbacks/create/${encodedName}`;
|
||||
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
|
||||
@@ -191,16 +192,16 @@ export async function saveCallback(event) {
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast(`Callback ${isEdit ? 'updated' : 'created'} successfully`, 'success');
|
||||
showToast(t(isEdit ? 'callbacks.msg.updated' : 'callbacks.msg.created'), 'success');
|
||||
callbackFormDirty = false;
|
||||
closeCallbackDialog();
|
||||
loadCallbacksTable();
|
||||
} else {
|
||||
showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} callback`, 'error');
|
||||
showToast(result.detail || t(isEdit ? 'callbacks.msg.update_failed' : 'callbacks.msg.create_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving callback:', error);
|
||||
showToast(`Error ${isEdit ? 'updating' : 'creating'} callback`, 'error');
|
||||
showToast(t(isEdit ? 'callbacks.msg.update_failed' : 'callbacks.msg.create_failed'), 'error');
|
||||
} finally {
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
}
|
||||
@@ -212,7 +213,7 @@ export async function deleteCallbackConfirm(callbackName) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/callbacks/delete/${callbackName}`, {
|
||||
const response = await fetch(`/api/callbacks/delete/${encodeURIComponent(callbackName)}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
@@ -220,13 +221,13 @@ export async function deleteCallbackConfirm(callbackName) {
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast('Callback deleted successfully', 'success');
|
||||
showToast(t('callbacks.msg.deleted'), 'success');
|
||||
loadCallbacksTable();
|
||||
} else {
|
||||
showToast(result.detail || 'Failed to delete callback', 'error');
|
||||
showToast(result.detail || t('callbacks.msg.delete_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting callback:', error);
|
||||
showToast('Error deleting callback', 'error');
|
||||
showToast(t('callbacks.msg.delete_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,8 @@ export function cacheDom() {
|
||||
dom.progressFill = document.getElementById('progress-fill');
|
||||
dom.currentTime = document.getElementById('current-time');
|
||||
dom.totalTime = document.getElementById('total-time');
|
||||
dom.metaElapsed = document.getElementById('meta-elapsed');
|
||||
dom.metaLength = document.getElementById('meta-length');
|
||||
dom.progressBar = document.getElementById('progress-bar');
|
||||
dom.miniProgressFill = document.getElementById('mini-progress-fill');
|
||||
dom.miniCurrentTime = document.getElementById('mini-current-time');
|
||||
@@ -138,7 +140,10 @@ export function cacheDom() {
|
||||
|
||||
// Timing constants
|
||||
export const VOLUME_THROTTLE_MS = 16;
|
||||
export const POSITION_INTERPOLATION_MS = 100;
|
||||
// 250ms is plenty for sub-second progress; the inline updateProgress
|
||||
// also short-circuits when the rounded second hasn't moved, so there's
|
||||
// no visible difference for the user.
|
||||
export const POSITION_INTERPOLATION_MS = 250;
|
||||
export const SEARCH_DEBOUNCE_MS = 200;
|
||||
export const TOAST_DURATION_MS = 3000;
|
||||
export const WS_BACKOFF_BASE_MS = 3000;
|
||||
@@ -150,6 +155,7 @@ export const VOLUME_RELEASE_DELAY_MS = 500;
|
||||
// Shared state (accessed across multiple modules)
|
||||
export let ws = null;
|
||||
export function setWs(value) { ws = value; }
|
||||
export function getWs() { return ws; }
|
||||
export let currentState = 'idle';
|
||||
export function setCurrentState(value) { currentState = value; }
|
||||
export let currentDuration = 0;
|
||||
@@ -317,6 +323,8 @@ export async function fetchVersion() {
|
||||
const label = document.getElementById('version-label');
|
||||
if (data.version) {
|
||||
label.textContent = `v${data.version}`;
|
||||
const folioVersion = document.getElementById('folio-version');
|
||||
if (folioVersion) folioVersion.textContent = `v${data.version}`;
|
||||
}
|
||||
if (data.update_available) {
|
||||
showUpdateBanner(data.update_available);
|
||||
@@ -390,6 +398,16 @@ export function closeDialog(dialog) {
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
export function showAboutDialog() {
|
||||
const dialog = document.getElementById('aboutDialog');
|
||||
if (dialog) dialog.showModal();
|
||||
}
|
||||
|
||||
export function closeAboutDialog() {
|
||||
const dialog = document.getElementById('aboutDialog');
|
||||
if (dialog) closeDialog(dialog);
|
||||
}
|
||||
|
||||
export function showConfirm(message) {
|
||||
return new Promise((resolve) => {
|
||||
const dialog = document.getElementById('confirmDialog');
|
||||
@@ -496,6 +514,12 @@ export function setVolume(volume) {
|
||||
sendCommand('volume', { volume: volume });
|
||||
}
|
||||
}
|
||||
// Reset the de-dupe cache whenever the server reports a fresh volume value
|
||||
// (e.g., another client moved the slider). Otherwise the user can end up
|
||||
// unable to "set volume back to the value we last sent" after a remote change.
|
||||
export function notifyRemoteVolume(volume) {
|
||||
lastSentVolume = volume;
|
||||
}
|
||||
|
||||
export function toggleMute() {
|
||||
sendCommand('mute');
|
||||
@@ -519,23 +543,68 @@ function _persistMdiCache() {
|
||||
try { localStorage.setItem('mdiIconCache', JSON.stringify(mdiIconCache)); } catch {}
|
||||
}
|
||||
|
||||
// Strict iconify MDI slug — used to reject anything that could be path-traversal
|
||||
// or query injection before we even hit the network.
|
||||
const MDI_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
|
||||
function sanitizeSvg(rawSvg) {
|
||||
// Parse the SVG and strip anything that could execute script. Anything
|
||||
// unparseable returns null so callers fall back to the placeholder.
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(rawSvg, 'image/svg+xml');
|
||||
const root = doc.documentElement;
|
||||
if (!root || root.tagName.toLowerCase() !== 'svg' || root.querySelector('parsererror')) {
|
||||
return null;
|
||||
}
|
||||
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
|
||||
const toRemove = [];
|
||||
let node = walker.currentNode;
|
||||
while (node) {
|
||||
const tag = node.tagName?.toLowerCase();
|
||||
if (tag === 'script' || tag === 'foreignobject') {
|
||||
toRemove.push(node);
|
||||
} else if (node.attributes) {
|
||||
for (const attr of Array.from(node.attributes)) {
|
||||
const name = attr.name.toLowerCase();
|
||||
if (name.startsWith('on') ||
|
||||
((name === 'href' || name === 'xlink:href') &&
|
||||
/^\s*(javascript|data):/i.test(attr.value))) {
|
||||
node.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
node = walker.nextNode();
|
||||
}
|
||||
toRemove.forEach((el) => el.remove());
|
||||
return root.outerHTML;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const PLACEHOLDER_SVG = '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>';
|
||||
|
||||
export async function fetchMdiIcon(iconName) {
|
||||
const name = iconName.replace(/^mdi:/, '');
|
||||
const name = String(iconName || '').replace(/^mdi:/, '');
|
||||
if (!MDI_SLUG_RE.test(name)) return PLACEHOLDER_SVG;
|
||||
if (mdiIconCache[name]) return mdiIconCache[name];
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://api.iconify.design/mdi/${name}.svg?width=16&height=16`);
|
||||
const response = await fetch(`https://api.iconify.design/mdi/${encodeURIComponent(name)}.svg?width=16&height=16`);
|
||||
if (response.ok) {
|
||||
const svg = await response.text();
|
||||
mdiIconCache[name] = svg;
|
||||
_persistMdiCache();
|
||||
return svg;
|
||||
const raw = await response.text();
|
||||
const safe = sanitizeSvg(raw);
|
||||
if (safe) {
|
||||
mdiIconCache[name] = safe;
|
||||
_persistMdiCache();
|
||||
return safe;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch MDI icon:', name, e);
|
||||
}
|
||||
|
||||
return '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>';
|
||||
return PLACEHOLDER_SVG;
|
||||
}
|
||||
|
||||
export async function resolveMdiIcons(container) {
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// ============================================================
|
||||
// Foreground: Currently-focused desktop process card (rendered at
|
||||
// the top of the Display tab)
|
||||
// ============================================================
|
||||
|
||||
import { t } from './core.js';
|
||||
|
||||
let latestForeground = null;
|
||||
let agoTickTimer = null;
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (s === null || s === undefined) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function formatAgo(epoch) {
|
||||
if (!epoch) return '';
|
||||
const now = Date.now() / 1000;
|
||||
const diff = Math.max(0, now - epoch);
|
||||
if (diff < 60) {
|
||||
return t('foreground.ago.seconds', { n: Math.floor(diff) });
|
||||
}
|
||||
if (diff < 3600) {
|
||||
return t('foreground.ago.minutes', { n: Math.floor(diff / 60) });
|
||||
}
|
||||
if (diff < 86400) {
|
||||
const h = Math.floor(diff / 3600);
|
||||
const m = Math.floor((diff % 3600) / 60);
|
||||
return t('foreground.ago.hours', { n: h, m: m });
|
||||
}
|
||||
return t('foreground.ago.days', { n: Math.floor(diff / 86400) });
|
||||
}
|
||||
|
||||
function formatGeometry(g) {
|
||||
if (!g) return '—';
|
||||
const w = g.width ?? (g.right - g.left);
|
||||
const h = g.height ?? (g.bottom - g.top);
|
||||
return `${w}×${h} @ (${g.left}, ${g.top})`;
|
||||
}
|
||||
|
||||
function truncatePath(p, max = 64) {
|
||||
if (!p) return '';
|
||||
if (p.length <= max) return p;
|
||||
// Keep the tail (filename) visible — that's the part the user cares about.
|
||||
return '…' + p.slice(-(max - 1));
|
||||
}
|
||||
|
||||
function renderEmpty(message, errorMsg) {
|
||||
const stage = document.getElementById('foregroundStage');
|
||||
if (!stage) return;
|
||||
stage.innerHTML = `
|
||||
<div class="empty-state-illustration foreground-empty">
|
||||
<svg viewBox="0 0 64 64"><rect x="6" y="12" width="52" height="36" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><line x1="6" y1="20" x2="58" y2="20" stroke="currentColor" stroke-width="2"/><circle cx="11" cy="16" r="1.4" fill="currentColor"/><circle cx="15" cy="16" r="1.4" fill="currentColor"/><circle cx="19" cy="16" r="1.4" fill="currentColor"/></svg>
|
||||
<p>${escapeHtml(message)}</p>
|
||||
${errorMsg ? `<p class="foreground-empty-error">${escapeHtml(errorMsg)}</p>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderTile(data) {
|
||||
const stage = document.getElementById('foregroundStage');
|
||||
if (!stage) return;
|
||||
|
||||
const procName = data.process_name || '—';
|
||||
const winTitle = data.window_title || '';
|
||||
const execPath = data.executable_path || '';
|
||||
const pid = data.pid ?? '—';
|
||||
const startedEpoch = data.started_at;
|
||||
const startedAgo = startedEpoch ? formatAgo(startedEpoch) : '—';
|
||||
const startedAbs = startedEpoch
|
||||
? new Date(startedEpoch * 1000).toLocaleString()
|
||||
: '';
|
||||
const geom = formatGeometry(data.window_geometry);
|
||||
const platform = data.platform || '—';
|
||||
const monitorId = data.monitor_id;
|
||||
|
||||
// Chips: only render ones that apply
|
||||
const chips = [];
|
||||
if (data.is_fullscreen) {
|
||||
chips.push(`<span class="fg-chip fg-chip-accent">${escapeHtml(t('foreground.fullscreen'))}</span>`);
|
||||
} else if (!data.is_minimized) {
|
||||
chips.push(`<span class="fg-chip">${escapeHtml(t('foreground.windowed'))}</span>`);
|
||||
}
|
||||
if (data.is_minimized) {
|
||||
chips.push(`<span class="fg-chip fg-chip-mute">${escapeHtml(t('foreground.minimized'))}</span>`);
|
||||
}
|
||||
if (monitorId !== null && monitorId !== undefined) {
|
||||
chips.push(`<span class="fg-chip">${escapeHtml(t('foreground.monitor', { n: monitorId + 1 }))}</span>`);
|
||||
}
|
||||
if (data.is_browser) {
|
||||
chips.push(`<span class="fg-chip fg-chip-mute">${escapeHtml(t('foreground.browser'))}</span>`);
|
||||
}
|
||||
|
||||
// Optional browser-only detail rows (page title + URL when available)
|
||||
const browserRows = [];
|
||||
if (data.is_browser) {
|
||||
if (data.browser_page_title) {
|
||||
browserRows.push(`
|
||||
<div class="fg-row">
|
||||
<dt>${escapeHtml(t('foreground.page_title'))}</dt>
|
||||
<dd title="${escapeHtml(data.browser_page_title)}">${escapeHtml(data.browser_page_title)}</dd>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
if (data.browser_url) {
|
||||
browserRows.push(`
|
||||
<div class="fg-row">
|
||||
<dt>${escapeHtml(t('foreground.url'))}</dt>
|
||||
<dd title="${escapeHtml(data.browser_url)}"><span class="fg-mono">${escapeHtml(truncatePath(data.browser_url, 80))}</span></dd>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
stage.innerHTML = `
|
||||
<article class="foreground-card" data-fullscreen="${data.is_fullscreen ? '1' : '0'}">
|
||||
<div class="fg-kicker">
|
||||
<span data-i18n="foreground.kicker">Foreground</span>
|
||||
</div>
|
||||
<h1 class="fg-process" title="${escapeHtml(procName)}">${escapeHtml(procName)}</h1>
|
||||
<div class="fg-window-title" title="${escapeHtml(winTitle)}">${escapeHtml(winTitle)}</div>
|
||||
|
||||
<div class="fg-chips">${chips.join('')}</div>
|
||||
|
||||
<dl class="fg-details">
|
||||
${browserRows.join('')}
|
||||
<div class="fg-row">
|
||||
<dt>${escapeHtml(t('foreground.executable'))}</dt>
|
||||
<dd title="${escapeHtml(execPath)}"><span class="fg-mono">${escapeHtml(truncatePath(execPath))}</span></dd>
|
||||
</div>
|
||||
<div class="fg-row">
|
||||
<dt>${escapeHtml(t('foreground.pid'))}</dt>
|
||||
<dd><span class="fg-mono">${escapeHtml(String(pid))}</span></dd>
|
||||
</div>
|
||||
<div class="fg-row">
|
||||
<dt>${escapeHtml(t('foreground.started'))}</dt>
|
||||
<dd title="${escapeHtml(startedAbs)}"><span class="fg-ago" data-started="${startedEpoch ?? ''}">${escapeHtml(startedAgo)}</span></dd>
|
||||
</div>
|
||||
<div class="fg-row">
|
||||
<dt>${escapeHtml(t('foreground.geometry'))}</dt>
|
||||
<dd><span class="fg-mono">${escapeHtml(geom)}</span></dd>
|
||||
</div>
|
||||
<div class="fg-row">
|
||||
<dt>${escapeHtml(t('foreground.platform'))}</dt>
|
||||
<dd>${escapeHtml(platform)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function startAgoTicker() {
|
||||
if (agoTickTimer) return;
|
||||
agoTickTimer = setInterval(() => {
|
||||
const el = document.querySelector('.fg-ago[data-started]');
|
||||
if (!el) return;
|
||||
const epoch = parseFloat(el.getAttribute('data-started'));
|
||||
if (!epoch) return;
|
||||
el.textContent = formatAgo(epoch);
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
export function updateForegroundUI(data) {
|
||||
latestForeground = data;
|
||||
|
||||
if (!data || data.available === false) {
|
||||
const errMsg = data && data.error ? data.error : '';
|
||||
renderEmpty(t('foreground.unavailable'), errMsg);
|
||||
} else if (!data.process_name && !data.pid) {
|
||||
renderEmpty(t('foreground.no_process'), '');
|
||||
} else {
|
||||
renderTile(data);
|
||||
startAgoTicker();
|
||||
}
|
||||
}
|
||||
|
||||
export function loadForegroundProcess() {
|
||||
// Push-only — just render the cached state. If nothing has arrived
|
||||
// yet, leave the loading placeholder visible.
|
||||
if (latestForeground !== null) {
|
||||
updateForegroundUI(latestForeground);
|
||||
}
|
||||
}
|
||||
+390
-22
@@ -1,12 +1,107 @@
|
||||
// ============================================================
|
||||
// Display Brightness & Power Control + Links Management
|
||||
// Display Brightness, Power, Contrast, Input Source, Color Preset,
|
||||
// Picture Mode Control + Links Management
|
||||
// ============================================================
|
||||
|
||||
import { t, showToast, escapeHtml, closeDialog, showConfirm, resolveMdiIcons, fetchMdiIcon, getAuthHeaders, hasCredentials } from './core.js';
|
||||
import { IconSelect } from './icon-select.js';
|
||||
|
||||
let displayBrightnessTimers = {};
|
||||
let displayContrastTimers = {};
|
||||
let _displayIconSelects = [];
|
||||
const DISPLAY_THROTTLE_MS = 50;
|
||||
|
||||
// ─── Icon palette for the tuning IconSelects ───────────────────────────
|
||||
// All SVGs are 24x24 monochrome — IconSelect's CSS fills them with currentColor.
|
||||
|
||||
const ICON_PORT_GENERIC =
|
||||
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 8h16v8H4V8zm2 2v4h12v-4H6zm2 1h2v2H8v-2zm4 0h2v2h-2v-2zm-9 6h18v2H3v-2z"/></svg>';
|
||||
const ICON_PORT_HDMI =
|
||||
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 9l2-2h14l2 2v5l-2 2h-3l-1 1H9l-1-1H5l-2-2V9zm2.5.5v4l1 1h2l1 1h7l1-1h2l1-1v-4l-1-.5H6.5l-1 .5z"/></svg>';
|
||||
const ICON_PORT_DP =
|
||||
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 8l2-2h12l2 2v8l-2 2H6l-2-2V8zm2 .5V15l1 1h10l1-1V8.5L17 8H7l-1 .5zM8 10h2v4H8v-4zm6 0h2v4h-2v-4z"/></svg>';
|
||||
const ICON_PORT_DVI =
|
||||
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 8h18v8H3V8zm2 1.5v5h14v-5H5zM7 11h1.5v2H7v-2zm3 0h1.5v2H10v-2zm3 0h1.5v2H13v-2zm3 0h1.5v2H16v-2z"/></svg>';
|
||||
const ICON_PORT_VGA =
|
||||
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M5 7h14a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2V9a2 2 0 012-2zm0 2v6h14V9H5zm2 1.5a.75.75 0 100 1.5.75.75 0 000-1.5zm3 0a.75.75 0 100 1.5.75.75 0 000-1.5zm3 0a.75.75 0 100 1.5.75.75 0 000-1.5zm3 0a.75.75 0 100 1.5.75.75 0 000-1.5z"/></svg>';
|
||||
const ICON_PORT_USBC =
|
||||
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M5 10a3 3 0 013-3h8a3 3 0 013 3v4a3 3 0 01-3 3H8a3 3 0 01-3-3v-4zm3-1.5A1.5 1.5 0 006.5 10v4A1.5 1.5 0 008 15.5h8a1.5 1.5 0 001.5-1.5v-4A1.5 1.5 0 0016 8.5H8zm1 2h6v3H9v-3z"/></svg>';
|
||||
|
||||
const ICON_THERMOMETER =
|
||||
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 3a3 3 0 00-3 3v8.17A4 4 0 1015 14.17V6a3 3 0 00-3-3zm-1.5 3a1.5 1.5 0 113 0v8.76a2.5 2.5 0 11-3 0V6zm1.5 5a1 1 0 011 1v2.27a1.5 1.5 0 11-2 0V12a1 1 0 011-1z"/></svg>';
|
||||
|
||||
const ICON_MODE_MOVIE =
|
||||
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 5h16v14H4V5zm2 2v2h2V7H6zm0 4v2h2v-2H6zm0 4v2h2v-2H6zm10-8v2h2V7h-2zm0 4v2h2v-2h-2zm0 4v2h2v-2h-2zm-6-7h4v8h-4V8z"/></svg>';
|
||||
const ICON_MODE_GAME =
|
||||
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M7 8a5 5 0 00-5 5 4 4 0 007.4 2.1L11 14h2l1.6 1.1A4 4 0 0022 13a5 5 0 00-5-5H7zm1 3v1H7v-1H6v-1h1V9h1v1h1v1H8zm7 0a1 1 0 110-2 1 1 0 010 2zm2 2a1 1 0 110-2 1 1 0 010 2z"/></svg>';
|
||||
const ICON_MODE_SPORT =
|
||||
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 2a10 10 0 100 20 10 10 0 000-20zm0 2c1.7 0 3.3.5 4.6 1.4l-1.4 2.4L12 6.5l-3.2 1.3-1.4-2.4A8 8 0 0112 4zm-7.6 4l2.5 1.3-.5 3.5L4 16.4A8 8 0 014.4 8zm15.2 0a8 8 0 01.4 8.4l-2.4-1.6-.5-3.5L19.6 8zM12 8.7l3 1.2.6 3.2L13 15h-2l-2.6-1.9.6-3.2L12 8.7zm-5.3 8.8L9 16.5l2.4 1h1.2l2.4-1 2.3 1A8 8 0 016.7 17.5z"/></svg>';
|
||||
const ICON_MODE_PRO =
|
||||
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 4h18v12H13v2h4v2H7v-2h4v-2H3V4zm2 2v8h14V6H5zm2 2h6v2H7V8zm0 3h10v2H7v-2z"/></svg>';
|
||||
const ICON_MODE_DOCS =
|
||||
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M6 3h9l4 4v14H6V3zm2 2v14h10V9h-4V5H8zm2 6h6v2h-6v-2zm0 3h6v2h-6v-2z"/></svg>';
|
||||
const ICON_MODE_USER =
|
||||
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 3a4 4 0 100 8 4 4 0 000-8zm0 2a2 2 0 110 4 2 2 0 010-4zm0 8c-3.3 0-7 1.5-7 4.5V20h14v-2.5c0-3-3.7-4.5-7-4.5z"/></svg>';
|
||||
const ICON_MODE_DEFAULT =
|
||||
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 5h18v12H3V5zm2 2v8h14V7H5zm-2 12h18v2H3v-2z"/></svg>';
|
||||
const ICON_MODE_MIXED =
|
||||
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 5h18v14H3V5zm2 2v10h7V7H5zm9 0v10h5V7h-5z"/></svg>';
|
||||
|
||||
function inputSourceIcon(src) {
|
||||
const s = String(src || '').toUpperCase();
|
||||
if (s.startsWith('HDMI')) return ICON_PORT_HDMI;
|
||||
if (s.startsWith('DP')) return ICON_PORT_DP;
|
||||
if (s.startsWith('DVI')) return ICON_PORT_DVI;
|
||||
if (s.startsWith('VGA')) return ICON_PORT_VGA;
|
||||
if (s.startsWith('USB')) return ICON_PORT_USBC;
|
||||
return ICON_PORT_GENERIC;
|
||||
}
|
||||
|
||||
function pictureModeIcon(label) {
|
||||
const k = String(label || '').toLowerCase();
|
||||
if (k.includes('movie')) return ICON_MODE_MOVIE;
|
||||
if (k.includes('game')) return ICON_MODE_GAME;
|
||||
if (k.includes('sport')) return ICON_MODE_SPORT;
|
||||
if (k.includes('professional')) return ICON_MODE_PRO;
|
||||
if (k.includes('productivity')) return ICON_MODE_DOCS;
|
||||
if (k.includes('user')) return ICON_MODE_USER;
|
||||
if (k.includes('mixed')) return ICON_MODE_MIXED;
|
||||
return ICON_MODE_DEFAULT;
|
||||
}
|
||||
|
||||
// Humanise enum-style identifiers returned by monitorcontrol so users
|
||||
// don't see SHOUT_CASE strings in the UI.
|
||||
function humanizeInputSource(raw) {
|
||||
if (!raw) return '';
|
||||
// OFF / RESERVED → "Off" / "Reserved"
|
||||
// VGA1 → "VGA 1", HDMI1 → "HDMI 1", DP1 → "DisplayPort 1"
|
||||
const map = { DP: 'DisplayPort', DVI: 'DVI', HDMI: 'HDMI', VGA: 'VGA', USBC: 'USB-C' };
|
||||
const m = String(raw).toUpperCase().match(/^(DP|DVI|HDMI|VGA|USBC|USB_C)(\d*)$/);
|
||||
if (m) {
|
||||
const key = m[1] === 'USB_C' ? 'USBC' : m[1];
|
||||
return `${map[key]}${m[2] ? ' ' + m[2] : ''}`;
|
||||
}
|
||||
return String(raw)
|
||||
.replace(/_/g, ' ')
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
function humanizeColorPreset(raw) {
|
||||
if (!raw) return '';
|
||||
// COLOR_TEMP_6500K → "6500 K", COLOR_TEMP_NATIVE → "Native",
|
||||
// COLOR_TEMP_USER1 → "User 1"
|
||||
const s = String(raw).replace(/^COLOR_TEMP_?/i, '');
|
||||
const kelvin = s.match(/^(\d{4,5})K?$/);
|
||||
if (kelvin) return `${kelvin[1]} K`;
|
||||
const user = s.match(/^USER\s*_?(\d+)$/i);
|
||||
if (user) return `User ${user[1]}`;
|
||||
return s
|
||||
.replace(/_/g, ' ')
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
export async function loadDisplayMonitors() {
|
||||
if (!hasCredentials()) return;
|
||||
|
||||
@@ -14,7 +109,7 @@ export async function loadDisplayMonitors() {
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/display/monitors?refresh=true', {
|
||||
const response = await fetch('/api/display/monitors', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
@@ -36,7 +131,13 @@ export async function loadDisplayMonitors() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Destroy IconSelects from a previous render so listeners + popups
|
||||
// don't pile up.
|
||||
_displayIconSelects.forEach(inst => { try { inst.destroy(); } catch (_) {} });
|
||||
_displayIconSelects = [];
|
||||
|
||||
container.innerHTML = '';
|
||||
const pendingIconSelects = [];
|
||||
monitors.forEach(monitor => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'display-monitor-card';
|
||||
@@ -47,17 +148,135 @@ export async function loadDisplayMonitors() {
|
||||
|
||||
let powerBtn = '';
|
||||
if (monitor.power_supported) {
|
||||
// Inline onclick with string-interpolated monitor.name is a DOM-XSS
|
||||
// foot-gun if the OS ever reports a name containing quotes / angle
|
||||
// brackets. Use a delegated click handler bound to data-* attrs.
|
||||
powerBtn = `
|
||||
<button class="display-power-btn ${monitor.power_on ? 'on' : 'off'}" id="power-btn-${monitor.id}"
|
||||
onclick="toggleDisplayPower(${monitor.id}, '${monitor.name.replace(/'/g, "\\'")}')"
|
||||
title="${monitor.power_on ? t('display.power_off') : t('display.power_on')}">
|
||||
data-action="toggle-power" data-monitor-id="${monitor.id}"
|
||||
title="${escapeHtml(monitor.power_on ? t('display.power_off') : t('display.power_on'))}">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M13 3h-2v10h2V3zm4.83 2.17l-1.42 1.42A6.92 6.92 0 0119 12c0 3.87-3.13 7-7 7s-7-3.13-7-7c0-2.27 1.08-4.28 2.76-5.56L6.34 5.02A8.95 8.95 0 003 12c0 4.97 4.03 9 9 9s9-4.03 9-9a8.95 8.95 0 00-3.17-6.83z"/></svg>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
const details = [monitor.resolution, monitor.manufacturer].filter(Boolean).join(' \u00B7 ');
|
||||
const detailsHtml = details ? `<span class="display-monitor-details">${details}</span>` : '';
|
||||
const primaryBadge = monitor.is_primary ? `<span class="display-primary-badge">${t('display.primary')}</span>` : '';
|
||||
const detailsHtml = details ? `<span class="display-monitor-details">${escapeHtml(details)}</span>` : '';
|
||||
const primaryBadge = monitor.is_primary
|
||||
? `<span class="display-primary-badge" title="${t('display.primary')}" aria-label="${t('display.primary')}">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
|
||||
<path fill="currentColor" d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
|
||||
</svg>
|
||||
</span>`
|
||||
: '';
|
||||
|
||||
// Contrast (DDC/CI) — render only if the monitor reports it.
|
||||
let contrastRow = '';
|
||||
if (monitor.contrast_supported) {
|
||||
const contrastValue = monitor.contrast !== null && monitor.contrast !== undefined
|
||||
? monitor.contrast : 50;
|
||||
contrastRow = `
|
||||
<div class="display-slider-row">
|
||||
<svg class="display-slider-icon" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||||
<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18V4c4.41 0 8 3.59 8 8s-3.59 8-8 8z"/>
|
||||
</svg>
|
||||
<span class="display-slider-label" data-i18n="display.contrast">${t('display.contrast')}</span>
|
||||
<input type="range" class="display-slider display-contrast-slider"
|
||||
min="0" max="100" value="${contrastValue}"
|
||||
data-display-slider="contrast" data-monitor-id="${monitor.id}">
|
||||
<span class="display-slider-value" id="contrast-val-${monitor.id}">${contrastValue}%</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Build the picture-tuning selects (input source / color preset / picture mode).
|
||||
const tuningRows = [];
|
||||
|
||||
// Each tuning field renders a hidden <select> (state holder)
|
||||
// which IconSelect then enhances after the card lands in the DOM.
|
||||
const tuningTargets = [];
|
||||
|
||||
if (monitor.input_source_supported && monitor.available_input_sources.length > 0) {
|
||||
const current = monitor.input_source;
|
||||
const options = monitor.available_input_sources.map(src => {
|
||||
const selected = src === current ? 'selected' : '';
|
||||
return `<option value="${escapeHtml(src)}" ${selected}>${escapeHtml(humanizeInputSource(src))}</option>`;
|
||||
}).join('');
|
||||
tuningRows.push(`
|
||||
<div class="display-tuning-field">
|
||||
<span class="display-tuning-label" data-i18n="display.input_source">${t('display.input_source')}</span>
|
||||
<select data-display-select="input" data-monitor-id="${monitor.id}"
|
||||
aria-label="${t('display.input_source')}">
|
||||
${options}
|
||||
</select>
|
||||
</div>`);
|
||||
tuningTargets.push({
|
||||
kind: 'input',
|
||||
monitorId: monitor.id,
|
||||
items: monitor.available_input_sources.map(src => ({
|
||||
value: src,
|
||||
icon: inputSourceIcon(src),
|
||||
label: humanizeInputSource(src),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (monitor.color_preset_supported && monitor.available_color_presets.length > 0) {
|
||||
const current = monitor.color_preset;
|
||||
const options = monitor.available_color_presets.map(p => {
|
||||
const selected = p === current ? 'selected' : '';
|
||||
return `<option value="${escapeHtml(p)}" ${selected}>${escapeHtml(humanizeColorPreset(p))}</option>`;
|
||||
}).join('');
|
||||
tuningRows.push(`
|
||||
<div class="display-tuning-field">
|
||||
<span class="display-tuning-label" data-i18n="display.color_preset">${t('display.color_preset')}</span>
|
||||
<select data-display-select="color" data-monitor-id="${monitor.id}"
|
||||
aria-label="${t('display.color_preset')}">
|
||||
${options}
|
||||
</select>
|
||||
</div>`);
|
||||
tuningTargets.push({
|
||||
kind: 'color',
|
||||
monitorId: monitor.id,
|
||||
items: monitor.available_color_presets.map(p => ({
|
||||
value: p,
|
||||
icon: ICON_THERMOMETER,
|
||||
label: humanizeColorPreset(p),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (monitor.picture_mode_supported && monitor.available_picture_modes.length > 0) {
|
||||
const current = monitor.picture_mode_code;
|
||||
const options = monitor.available_picture_modes.map(m => {
|
||||
const selected = m.code === current ? 'selected' : '';
|
||||
return `<option value="${m.code}" ${selected}>${escapeHtml(m.label)}</option>`;
|
||||
}).join('');
|
||||
tuningRows.push(`
|
||||
<div class="display-tuning-field">
|
||||
<span class="display-tuning-label" data-i18n="display.picture_mode">${t('display.picture_mode')}</span>
|
||||
<select data-display-select="mode" data-monitor-id="${monitor.id}"
|
||||
aria-label="${t('display.picture_mode')}">
|
||||
${options}
|
||||
</select>
|
||||
</div>`);
|
||||
tuningTargets.push({
|
||||
kind: 'mode',
|
||||
monitorId: monitor.id,
|
||||
items: monitor.available_picture_modes.map(m => ({
|
||||
value: String(m.code),
|
||||
icon: pictureModeIcon(m.label),
|
||||
label: m.label,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
pendingIconSelects.push(...tuningTargets);
|
||||
|
||||
const tuningBlock = tuningRows.length > 0
|
||||
? `<div class="display-tuning">
|
||||
<div class="display-tuning-title" data-i18n="display.tuning">${t('display.tuning')}</div>
|
||||
<div class="display-tuning-grid">${tuningRows.join('')}</div>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="display-monitor-header">
|
||||
@@ -65,23 +284,54 @@ export async function loadDisplayMonitors() {
|
||||
<path fill="currentColor" d="M20 3H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h6v2H8v2h8v-2h-2v-2h6c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H4V5h16v10z"/>
|
||||
</svg>
|
||||
<div class="display-monitor-info">
|
||||
<span class="display-monitor-name">${monitor.name}${primaryBadge}</span>
|
||||
<span class="display-monitor-name"><span class="display-monitor-name-text">${escapeHtml(monitor.name)}</span>${primaryBadge}</span>
|
||||
${detailsHtml}
|
||||
</div>
|
||||
${powerBtn}
|
||||
</div>
|
||||
<div class="display-brightness-control">
|
||||
<svg class="display-brightness-icon" viewBox="0 0 24 24" width="16" height="16">
|
||||
<div class="display-slider-row display-brightness-control">
|
||||
<svg class="display-slider-icon display-brightness-icon" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||||
<path fill="currentColor" d="M20 8.69V4h-4.69L12 .69 8.69 4H4v4.69L.69 12 4 15.31V20h4.69L12 23.31 15.31 20H20v-4.69L23.31 12 20 8.69zM12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6zm0-10c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z"/>
|
||||
</svg>
|
||||
<input type="range" class="display-brightness-slider" min="0" max="100" value="${brightnessValue}" ${brightnessDisabled}
|
||||
oninput="onDisplayBrightnessInput(${monitor.id}, this.value)"
|
||||
onchange="onDisplayBrightnessChange(${monitor.id}, this.value)">
|
||||
<span class="display-brightness-value" id="brightness-val-${monitor.id}">${brightnessValue}%</span>
|
||||
</div>`;
|
||||
<span class="display-slider-label" data-i18n="display.brightness">${t('display.brightness')}</span>
|
||||
<input type="range" class="display-slider display-brightness-slider" min="0" max="100" value="${brightnessValue}" ${brightnessDisabled}
|
||||
data-display-slider="brightness" data-monitor-id="${monitor.id}">
|
||||
<span class="display-slider-value display-brightness-value" id="brightness-val-${monitor.id}">${brightnessValue}%</span>
|
||||
</div>
|
||||
${contrastRow}
|
||||
${tuningBlock}`;
|
||||
|
||||
container.appendChild(card);
|
||||
});
|
||||
|
||||
// Bind a single delegated click handler for the power buttons,
|
||||
// plus input/change handlers for the brightness & contrast sliders.
|
||||
// Avoids inline on* attributes (blocked by script-src 'self' CSP).
|
||||
container.removeEventListener('click', _onPowerButtonClick);
|
||||
container.addEventListener('click', _onPowerButtonClick);
|
||||
container.removeEventListener('input', _onDisplaySliderInput);
|
||||
container.addEventListener('input', _onDisplaySliderInput);
|
||||
container.removeEventListener('change', _onDisplaySliderChange);
|
||||
container.addEventListener('change', _onDisplaySliderChange);
|
||||
|
||||
// Enhance every tuning <select> with an IconSelect now that the
|
||||
// cards are in the DOM (IconSelect needs offsetParent + sibling).
|
||||
pendingIconSelects.forEach(({ kind, monitorId, items }) => {
|
||||
const sel = container.querySelector(
|
||||
`select[data-display-select="${kind}"][data-monitor-id="${monitorId}"]`
|
||||
);
|
||||
if (!sel) return;
|
||||
const handler = kind === 'input' ? onDisplayInputSourceChange
|
||||
: kind === 'color' ? onDisplayColorPresetChange
|
||||
: onDisplayPictureModeChange;
|
||||
_displayIconSelects.push(new IconSelect({
|
||||
target: sel,
|
||||
items,
|
||||
columns: 1,
|
||||
horizontal: true,
|
||||
onChange: (value) => handler(monitorId, value),
|
||||
}));
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to load display monitors:', e);
|
||||
}
|
||||
@@ -118,7 +368,122 @@ async function sendDisplayBrightness(monitorId, brightness) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleDisplayPower(monitorId, monitorName) {
|
||||
export function onDisplayContrastInput(monitorId, value) {
|
||||
const label = document.getElementById(`contrast-val-${monitorId}`);
|
||||
if (label) label.textContent = `${value}%`;
|
||||
|
||||
if (displayContrastTimers[monitorId]) clearTimeout(displayContrastTimers[monitorId]);
|
||||
displayContrastTimers[monitorId] = setTimeout(() => {
|
||||
sendDisplayContrast(monitorId, parseInt(value));
|
||||
displayContrastTimers[monitorId] = null;
|
||||
}, DISPLAY_THROTTLE_MS);
|
||||
}
|
||||
|
||||
export function onDisplayContrastChange(monitorId, value) {
|
||||
if (displayContrastTimers[monitorId]) {
|
||||
clearTimeout(displayContrastTimers[monitorId]);
|
||||
displayContrastTimers[monitorId] = null;
|
||||
}
|
||||
sendDisplayContrast(monitorId, parseInt(value));
|
||||
}
|
||||
|
||||
async function sendDisplayContrast(monitorId, contrast) {
|
||||
try {
|
||||
const r = await fetch(`/api/display/contrast/${monitorId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({ contrast })
|
||||
});
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!data.success) showToast(t('display.msg.contrast_failed'), 'error');
|
||||
} catch (e) {
|
||||
console.error('Failed to set contrast:', e);
|
||||
showToast(t('display.msg.contrast_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function onDisplayInputSourceChange(monitorId, source) {
|
||||
try {
|
||||
const r = await fetch(`/api/display/input_source/${monitorId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({ source })
|
||||
});
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (data.success) showToast(t('display.msg.input_changed'), 'success');
|
||||
else showToast(t('display.msg.input_failed'), 'error');
|
||||
} catch (e) {
|
||||
console.error('Failed to set input source:', e);
|
||||
showToast(t('display.msg.input_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function onDisplayColorPresetChange(monitorId, preset) {
|
||||
try {
|
||||
const r = await fetch(`/api/display/color_preset/${monitorId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({ preset })
|
||||
});
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (data.success) showToast(t('display.msg.color_changed'), 'success');
|
||||
else showToast(t('display.msg.color_failed'), 'error');
|
||||
} catch (e) {
|
||||
console.error('Failed to set color preset:', e);
|
||||
showToast(t('display.msg.color_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function onDisplayPictureModeChange(monitorId, codeRaw) {
|
||||
const code = parseInt(codeRaw, 10);
|
||||
if (Number.isNaN(code)) return;
|
||||
try {
|
||||
const r = await fetch(`/api/display/picture_mode/${monitorId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({ code })
|
||||
});
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (data.success) showToast(t('display.msg.mode_changed'), 'success');
|
||||
else showToast(t('display.msg.mode_failed'), 'error');
|
||||
} catch (e) {
|
||||
console.error('Failed to set picture mode:', e);
|
||||
showToast(t('display.msg.mode_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function _onPowerButtonClick(event) {
|
||||
const btn = event.target.closest('[data-action="toggle-power"]');
|
||||
if (!btn) return;
|
||||
const id = Number(btn.dataset.monitorId);
|
||||
if (Number.isFinite(id)) toggleDisplayPower(id);
|
||||
}
|
||||
|
||||
function _onDisplaySliderInput(event) {
|
||||
const el = event.target.closest('input[data-display-slider]');
|
||||
if (!el) return;
|
||||
const id = Number(el.dataset.monitorId);
|
||||
if (!Number.isFinite(id)) return;
|
||||
if (el.dataset.displaySlider === 'brightness') {
|
||||
onDisplayBrightnessInput(id, el.value);
|
||||
} else if (el.dataset.displaySlider === 'contrast') {
|
||||
onDisplayContrastInput(id, el.value);
|
||||
}
|
||||
}
|
||||
|
||||
function _onDisplaySliderChange(event) {
|
||||
const el = event.target.closest('input[data-display-slider]');
|
||||
if (!el) return;
|
||||
const id = Number(el.dataset.monitorId);
|
||||
if (!Number.isFinite(id)) return;
|
||||
if (el.dataset.displaySlider === 'brightness') {
|
||||
onDisplayBrightnessChange(id, el.value);
|
||||
} else if (el.dataset.displaySlider === 'contrast') {
|
||||
onDisplayContrastChange(id, el.value);
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleDisplayPower(monitorId) {
|
||||
const btn = document.getElementById(`power-btn-${monitorId}`);
|
||||
const isOn = btn && btn.classList.contains('on');
|
||||
const newState = !isOn;
|
||||
@@ -136,13 +501,13 @@ export async function toggleDisplayPower(monitorId, monitorName) {
|
||||
btn.classList.toggle('off', !newState);
|
||||
btn.title = newState ? t('display.power_off') : t('display.power_on');
|
||||
}
|
||||
showToast(newState ? 'Monitor turned on' : 'Monitor turned off', 'success');
|
||||
showToast(t(newState ? 'display.msg.power_on' : 'display.msg.power_off'), 'success');
|
||||
} else {
|
||||
showToast('Failed to change monitor power', 'error');
|
||||
showToast(t('display.msg.power_failed'), 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to set display power:', e);
|
||||
showToast('Failed to change monitor power', 'error');
|
||||
showToast(t('display.msg.power_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +536,8 @@ export async function loadHeaderLinks() {
|
||||
a.href = link.url;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener noreferrer';
|
||||
// Prevent leaking the WebUI URL (with ?token=) via Referer.
|
||||
a.referrerPolicy = 'no-referrer';
|
||||
a.className = 'header-link';
|
||||
a.title = link.label || link.url;
|
||||
|
||||
@@ -239,7 +606,7 @@ async function _loadLinksTableImpl() {
|
||||
resolveMdiIcons(tbody);
|
||||
} catch (error) {
|
||||
console.error('Error loading links:', error);
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="empty-state" style="color: var(--error);">Failed to load links</td></tr>';
|
||||
tbody.innerHTML = `<tr><td colspan="4" class="empty-state" style="color: var(--error);">${escapeHtml(t('links.msg.load_failed'))}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,9 +709,10 @@ export async function saveLink(event) {
|
||||
description: document.getElementById('linkDescription').value || ''
|
||||
};
|
||||
|
||||
const encodedName = encodeURIComponent(linkName);
|
||||
const endpoint = isEdit ?
|
||||
`/api/links/update/${linkName}` :
|
||||
`/api/links/create/${linkName}`;
|
||||
`/api/links/update/${encodedName}` :
|
||||
`/api/links/create/${encodedName}`;
|
||||
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
|
||||
@@ -378,7 +746,7 @@ export async function deleteLinkConfirm(linkName) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/links/delete/${linkName}`, {
|
||||
const response = await fetch(`/api/links/delete/${encodeURIComponent(linkName)}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
+795
-185
File diff suppressed because it is too large
Load Diff
@@ -80,6 +80,9 @@ export async function displayQuickAccess() {
|
||||
card.href = link.url;
|
||||
card.target = '_blank';
|
||||
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) {
|
||||
const iconEl = document.createElement('div');
|
||||
@@ -150,7 +153,7 @@ async function executeScript(scriptName, buttonElement) {
|
||||
|
||||
async function _doExecuteScript(scriptName, params) {
|
||||
try {
|
||||
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
|
||||
const response = await fetch(`/api/scripts/execute/${encodeURIComponent(scriptName)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({ params })
|
||||
@@ -393,7 +396,7 @@ async function _loadScriptsTableImpl() {
|
||||
resolveMdiIcons(tbody);
|
||||
} catch (error) {
|
||||
console.error('Error loading scripts:', error);
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="empty-state" style="color: var(--error);">Failed to load scripts</td></tr>';
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="empty-state" style="color: var(--error);">${escapeHtml(t('scripts.msg.load_failed'))}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,7 +436,7 @@ export async function showEditScriptDialog(scriptName) {
|
||||
const script = scriptsList.find(s => s.name === scriptName);
|
||||
|
||||
if (!script) {
|
||||
showToast('Script not found', 'error');
|
||||
showToast(t('scripts.msg.not_found'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -470,7 +473,7 @@ export async function showEditScriptDialog(scriptName) {
|
||||
dialog.showModal();
|
||||
} catch (error) {
|
||||
console.error('Error loading script for edit:', error);
|
||||
showToast('Failed to load script details', 'error');
|
||||
showToast(t('scripts.msg.load_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,9 +511,10 @@ export async function saveScript(event) {
|
||||
parameters: _collectParameterDefinitions(),
|
||||
};
|
||||
|
||||
const encodedName = encodeURIComponent(scriptName);
|
||||
const endpoint = isEdit ?
|
||||
`/api/scripts/update/${scriptName}` :
|
||||
`/api/scripts/create/${scriptName}`;
|
||||
`/api/scripts/update/${encodedName}` :
|
||||
`/api/scripts/create/${encodedName}`;
|
||||
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
|
||||
@@ -524,15 +528,15 @@ export async function saveScript(event) {
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast(`Script ${isEdit ? 'updated' : 'created'} successfully`, 'success');
|
||||
showToast(t(isEdit ? 'scripts.msg.updated' : 'scripts.msg.created'), 'success');
|
||||
scriptFormDirty = false;
|
||||
closeScriptDialog();
|
||||
} else {
|
||||
showToast(result.detail || `Failed to ${isEdit ? 'update' : 'create'} script`, 'error');
|
||||
showToast(result.detail || t(isEdit ? 'scripts.msg.update_failed' : 'scripts.msg.create_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving script:', error);
|
||||
showToast(`Error ${isEdit ? 'updating' : 'creating'} script`, 'error');
|
||||
showToast(t(isEdit ? 'scripts.msg.update_failed' : 'scripts.msg.create_failed'), 'error');
|
||||
} finally {
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
}
|
||||
@@ -544,7 +548,7 @@ export async function deleteScriptConfirm(scriptName) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/scripts/delete/${scriptName}`, {
|
||||
const response = await fetch(`/api/scripts/delete/${encodeURIComponent(scriptName)}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
@@ -552,13 +556,13 @@ export async function deleteScriptConfirm(scriptName) {
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast('Script deleted successfully', 'success');
|
||||
showToast(t('scripts.msg.deleted'), 'success');
|
||||
} else {
|
||||
showToast(result.detail || 'Failed to delete script', 'error');
|
||||
showToast(result.detail || t('scripts.msg.delete_failed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting script:', error);
|
||||
showToast('Error deleting script', 'error');
|
||||
showToast(t('scripts.msg.delete_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,23 +585,23 @@ function showExecutionResult(name, result, type = 'script') {
|
||||
const outputPre = document.getElementById('executionOutput');
|
||||
const errorPre = document.getElementById('executionError');
|
||||
|
||||
title.textContent = `Execution Result: ${name}`;
|
||||
title.textContent = `${t('execution.result')}: ${name}`;
|
||||
|
||||
const success = result.success && result.exit_code === 0;
|
||||
const statusClass = success ? 'success' : 'error';
|
||||
const statusText = success ? 'Success' : 'Failed';
|
||||
const statusText = t(success ? 'execution.success' : 'execution.failed');
|
||||
|
||||
statusDiv.innerHTML = `
|
||||
<div class="status-item ${statusClass}">
|
||||
<label>Status</label>
|
||||
<value>${statusText}</value>
|
||||
<label>${escapeHtml(t('execution.status'))}</label>
|
||||
<value>${escapeHtml(statusText)}</value>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<label>Exit Code</label>
|
||||
<label>${escapeHtml(t('execution.exit_code'))}</label>
|
||||
<value>${result.exit_code !== undefined ? result.exit_code : 'N/A'}</value>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<label>Duration</label>
|
||||
<label>${escapeHtml(t('execution.duration'))}</label>
|
||||
<value>${result.execution_time !== undefined && result.execution_time !== null ? result.execution_time.toFixed(3) + 's' : 'N/A'}</value>
|
||||
</div>
|
||||
`;
|
||||
@@ -606,7 +610,7 @@ function showExecutionResult(name, result, type = 'script') {
|
||||
if (result.stdout && result.stdout.trim()) {
|
||||
outputPre.textContent = result.stdout;
|
||||
} else {
|
||||
outputPre.textContent = '(no output)';
|
||||
outputPre.textContent = t('execution.no_output');
|
||||
outputPre.style.fontStyle = 'italic';
|
||||
outputPre.style.color = 'var(--text-secondary)';
|
||||
}
|
||||
@@ -642,11 +646,11 @@ async function _executeScriptDebugWithParams(scriptName, params) {
|
||||
const title = document.getElementById('executionDialogTitle');
|
||||
const statusDiv = document.getElementById('executionStatus');
|
||||
|
||||
title.textContent = `Executing: ${scriptName}`;
|
||||
title.textContent = `${t('execution.executing')}: ${scriptName}`;
|
||||
statusDiv.innerHTML = `
|
||||
<div class="status-item">
|
||||
<label>Status</label>
|
||||
<value><span class="loading-spinner"></span> Running...</value>
|
||||
<label>${escapeHtml(t('execution.status'))}</label>
|
||||
<value><span class="loading-spinner"></span> ${escapeHtml(t('execution.running'))}</value>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('outputSection').style.display = 'none';
|
||||
@@ -813,11 +817,11 @@ export async function executeCallbackDebug(callbackName) {
|
||||
const title = document.getElementById('executionDialogTitle');
|
||||
const statusDiv = document.getElementById('executionStatus');
|
||||
|
||||
title.textContent = `Executing: ${callbackName}`;
|
||||
title.textContent = `${t('execution.executing')}: ${callbackName}`;
|
||||
statusDiv.innerHTML = `
|
||||
<div class="status-item">
|
||||
<label>Status</label>
|
||||
<value><span class="loading-spinner"></span> Running...</value>
|
||||
<label>${escapeHtml(t('execution.status'))}</label>
|
||||
<value><span class="loading-spinner"></span> ${escapeHtml(t('execution.running'))}</value>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('outputSection').style.display = 'none';
|
||||
@@ -826,7 +830,7 @@ export async function executeCallbackDebug(callbackName) {
|
||||
dialog.showModal();
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/callbacks/execute/${callbackName}`, {
|
||||
const response = await fetch(`/api/callbacks/execute/${encodeURIComponent(callbackName)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }
|
||||
});
|
||||
|
||||
@@ -3,19 +3,38 @@
|
||||
// ============================================================
|
||||
|
||||
import {
|
||||
dom, t, showToast, setWs,
|
||||
dom, t, setWs, getWs,
|
||||
WS_BACKOFF_BASE_MS, WS_BACKOFF_MAX_MS,
|
||||
WS_MAX_RECONNECT_ATTEMPTS, WS_PING_INTERVAL_MS,
|
||||
authRequired, showUpdateBanner,
|
||||
} from './core.js';
|
||||
import { updateUI, visualizerEnabled, visualizerAvailable, setFrequencyData, stopPositionInterpolation, loadAudioDevices } from './player.js';
|
||||
import { updateUI, setFrequencyData, stopPositionInterpolation, loadAudioDevices } from './player.js';
|
||||
import { loadScripts, loadScriptsTable, displayQuickAccess } from './scripts.js';
|
||||
import { loadCallbacksTable } from './callbacks.js';
|
||||
import { loadHeaderLinks, loadLinksTable } from './links.js';
|
||||
import { updateForegroundUI } from './foreground.js';
|
||||
|
||||
let reconnectTimeout = null;
|
||||
let pingInterval = null;
|
||||
let wsReconnectAttempts = 0;
|
||||
// Track the ping interval against the socket that owns it so we never leak
|
||||
// a timer if connectWebSocket() is called while a previous socket is still
|
||||
// alive. The pair is wiped on close to avoid double-clear races.
|
||||
let activeSocket = null;
|
||||
let activePingInterval = null;
|
||||
|
||||
function clearReconnect() {
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout);
|
||||
reconnectTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearPing() {
|
||||
if (activePingInterval) {
|
||||
clearInterval(activePingInterval);
|
||||
activePingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function showAuthForm(errorMessage = '') {
|
||||
const overlay = document.getElementById('auth-overlay');
|
||||
@@ -47,26 +66,45 @@ export function authenticate() {
|
||||
|
||||
export function clearToken() {
|
||||
localStorage.removeItem('media_server_token');
|
||||
// Access ws via import
|
||||
import('./core.js').then(core => {
|
||||
if (core.ws) {
|
||||
core.ws.close();
|
||||
}
|
||||
});
|
||||
const current = getWs();
|
||||
if (current) {
|
||||
try { current.close(1000, 'token cleared'); } catch { /* ignore */ }
|
||||
}
|
||||
showAuthForm(t('auth.cleared'));
|
||||
}
|
||||
|
||||
export function connectWebSocket(token) {
|
||||
if (pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
pingInterval = null;
|
||||
// Always cancel a pending reconnect first — otherwise a user-triggered
|
||||
// reconnect can race a scheduled one and create two live sockets.
|
||||
clearReconnect();
|
||||
clearPing();
|
||||
|
||||
// Close any previous socket cleanly before opening a new one.
|
||||
const previous = activeSocket;
|
||||
activeSocket = null;
|
||||
if (previous && previous.readyState <= WebSocket.OPEN) {
|
||||
try { previous.close(1000, 'reconnecting'); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : '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;
|
||||
setWs(newWs);
|
||||
|
||||
newWs.onopen = () => {
|
||||
@@ -81,16 +119,21 @@ export function connectWebSocket(token) {
|
||||
loadLinksTable();
|
||||
loadHeaderLinks();
|
||||
loadAudioDevices();
|
||||
if (visualizerEnabled && visualizerAvailable) {
|
||||
newWs.send(JSON.stringify({ type: 'enable_visualizer' }));
|
||||
}
|
||||
};
|
||||
|
||||
newWs.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(event.data);
|
||||
} catch (err) {
|
||||
console.warn('Ignoring malformed WebSocket frame:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'status' || msg.type === 'status_update') {
|
||||
updateUI(msg.data);
|
||||
} else if (msg.type === 'foreground' || msg.type === 'foreground_update') {
|
||||
updateForegroundUI(msg.data);
|
||||
} else if (msg.type === 'scripts_changed') {
|
||||
console.log('Scripts changed, reloading...');
|
||||
loadScripts();
|
||||
@@ -119,6 +162,13 @@ export function connectWebSocket(token) {
|
||||
updateConnectionStatus(false);
|
||||
stopPositionInterpolation();
|
||||
|
||||
// Drop this socket's ping interval. Guard so we don't kill a newer
|
||||
// socket's interval if reconnect already started.
|
||||
if (activeSocket === newWs) {
|
||||
clearPing();
|
||||
activeSocket = null;
|
||||
}
|
||||
|
||||
if (event.code === 4001) {
|
||||
localStorage.removeItem('media_server_token');
|
||||
showAuthForm(t('auth.invalid'));
|
||||
@@ -136,7 +186,9 @@ export function connectWebSocket(token) {
|
||||
showConnectionBanner(t('connection.reconnecting').replace('{attempt}', wsReconnectAttempts), false);
|
||||
}
|
||||
|
||||
clearReconnect();
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
reconnectTimeout = null;
|
||||
const savedToken = localStorage.getItem('media_server_token');
|
||||
if (savedToken || !authRequired) {
|
||||
connectWebSocket(savedToken || '');
|
||||
@@ -148,9 +200,10 @@ export function connectWebSocket(token) {
|
||||
}
|
||||
};
|
||||
|
||||
pingInterval = setInterval(() => {
|
||||
if (newWs && newWs.readyState === WebSocket.OPEN) {
|
||||
newWs.send(JSON.stringify({ type: 'ping' }));
|
||||
clearPing();
|
||||
activePingInterval = setInterval(() => {
|
||||
if (newWs.readyState === WebSocket.OPEN) {
|
||||
try { newWs.send(JSON.stringify({ type: 'ping' })); } catch { /* ignore */ }
|
||||
}
|
||||
}, WS_PING_INTERVAL_MS);
|
||||
}
|
||||
@@ -185,3 +238,23 @@ export function manualReconnect() {
|
||||
connectWebSocket(savedToken || '');
|
||||
}
|
||||
}
|
||||
|
||||
// When the browser regains connectivity or the tab becomes visible again,
|
||||
// drop the backoff and reconnect immediately rather than waiting out the
|
||||
// current timer.
|
||||
function reconnectIfNeeded() {
|
||||
const current = activeSocket;
|
||||
if (current && (current.readyState === WebSocket.OPEN || current.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
const savedToken = localStorage.getItem('media_server_token');
|
||||
if (savedToken || !authRequired) {
|
||||
wsReconnectAttempts = 0;
|
||||
connectWebSocket(savedToken || '');
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('online', reconnectIfNeeded);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) reconnectIfNeeded();
|
||||
});
|
||||
|
||||
@@ -19,12 +19,23 @@
|
||||
"player.status.connected": "Connected",
|
||||
"player.status.disconnected": "Disconnected",
|
||||
"player.no_media": "No media playing",
|
||||
"player.kicker": "Now Playing",
|
||||
"player.modes": "Modes",
|
||||
"header.connected": "Connected",
|
||||
"header.volume": "Vol. I",
|
||||
"header.edition": "Studio Reference",
|
||||
"header.edition_sub": "Studio Reference Edition",
|
||||
"meta.state": "State",
|
||||
"meta.source": "Source",
|
||||
"player.title_unavailable": "Title unavailable",
|
||||
"player.source": "Source:",
|
||||
"player.unknown_source": "Unknown",
|
||||
"player.vinyl": "Vinyl mode",
|
||||
"player.visualizer": "Audio visualizer",
|
||||
"player.background": "Dynamic background",
|
||||
"player.fullscreen": "Fullscreen player",
|
||||
"player.fullscreen.exit": "Exit fullscreen",
|
||||
"player.fullscreen.exit_short": "Exit",
|
||||
"state.playing": "Playing",
|
||||
"state.paused": "Paused",
|
||||
"state.stopped": "Stopped",
|
||||
@@ -126,8 +137,8 @@
|
||||
"callbacks.msg.list_failed": "Failed to load callbacks",
|
||||
"callbacks.confirm.delete": "Are you sure you want to delete the callback \"{name}\"?",
|
||||
"callbacks.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?",
|
||||
"tab.player": "Player",
|
||||
"tab.browser": "Browser",
|
||||
"tab.player": "Now Spinning",
|
||||
"tab.browser": "Library",
|
||||
"tab.quick_access": "Quick Access",
|
||||
"tab.settings": "Settings",
|
||||
"tab.display": "Display",
|
||||
@@ -150,6 +161,31 @@
|
||||
"display.power_on": "Turn on",
|
||||
"display.power_off": "Turn off",
|
||||
"display.primary": "Primary",
|
||||
"display.brightness": "Brightness",
|
||||
"display.contrast": "Contrast",
|
||||
"display.tuning": "Picture tuning",
|
||||
"display.input_source": "Input",
|
||||
"display.color_preset": "Color temp",
|
||||
"display.picture_mode": "Picture mode",
|
||||
"display.msg.contrast_failed": "Failed to set contrast",
|
||||
"display.msg.input_changed": "Input source switched",
|
||||
"display.msg.input_failed": "Failed to switch input source",
|
||||
"display.msg.color_changed": "Color preset applied",
|
||||
"display.msg.color_failed": "Failed to apply color preset",
|
||||
"display.msg.mode_changed": "Picture mode applied",
|
||||
"display.msg.mode_failed": "Failed to apply picture mode",
|
||||
"display.msg.power_on": "Monitor turned on",
|
||||
"display.msg.power_off": "Monitor turned off",
|
||||
"display.msg.power_failed": "Failed to change monitor power",
|
||||
"execution.result": "Execution Result",
|
||||
"execution.executing": "Executing",
|
||||
"execution.status": "Status",
|
||||
"execution.exit_code": "Exit Code",
|
||||
"execution.duration": "Duration",
|
||||
"execution.success": "Success",
|
||||
"execution.failed": "Failed",
|
||||
"execution.running": "Running...",
|
||||
"execution.no_output": "(no output)",
|
||||
"browser.title": "Media Browser",
|
||||
"browser.home": "Home",
|
||||
"browser.manage_folders": "Manage Folders",
|
||||
@@ -248,8 +284,37 @@
|
||||
"links.msg.load_failed": "Failed to load link details",
|
||||
"links.confirm.delete": "Are you sure you want to delete the link \"{name}\"?",
|
||||
"links.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?",
|
||||
"footer.created_by": "Created by",
|
||||
"footer.source_code": "Source Code",
|
||||
"about.button_title": "About",
|
||||
"about.title": "About",
|
||||
"about.created_by": "Created by",
|
||||
"about.email": "Email",
|
||||
"about.repository": "Repository",
|
||||
"about.source_code": "Source Code",
|
||||
"dialog.close": "Close",
|
||||
"update.available": "Update available: v{version}",
|
||||
"update.view_release": "View Release"
|
||||
"update.view_release": "View Release",
|
||||
"tab.foreground": "Foreground",
|
||||
"foreground.kicker": "Foreground",
|
||||
"foreground.loading": "Waiting for foreground signal…",
|
||||
"foreground.no_process": "No foreground process",
|
||||
"foreground.unavailable": "Foreground tracking unavailable on this platform",
|
||||
"foreground.process": "Process",
|
||||
"foreground.window_title": "Window title",
|
||||
"foreground.executable": "Executable",
|
||||
"foreground.pid": "PID",
|
||||
"foreground.monitor": "Monitor {n}",
|
||||
"foreground.started": "Started",
|
||||
"foreground.geometry": "Geometry",
|
||||
"foreground.platform": "Platform",
|
||||
"foreground.fullscreen": "Fullscreen",
|
||||
"foreground.minimized": "Minimized",
|
||||
"foreground.windowed": "Windowed",
|
||||
"foreground.browser": "Browser",
|
||||
"foreground.page_title": "Page title",
|
||||
"foreground.url": "URL",
|
||||
"foreground.badge.title": "View foreground process",
|
||||
"foreground.ago.seconds": "{n}s ago",
|
||||
"foreground.ago.minutes": "{n}m ago",
|
||||
"foreground.ago.hours": "{n}h {m}m ago",
|
||||
"foreground.ago.days": "{n}d ago"
|
||||
}
|
||||
|
||||
@@ -19,12 +19,23 @@
|
||||
"player.status.connected": "Подключено",
|
||||
"player.status.disconnected": "Отключено",
|
||||
"player.no_media": "Медиа не воспроизводится",
|
||||
"player.kicker": "Сейчас играет",
|
||||
"player.modes": "Режимы",
|
||||
"header.connected": "Подключено",
|
||||
"header.volume": "Том I",
|
||||
"header.edition": "Studio Reference",
|
||||
"header.edition_sub": "Studio Reference Edition",
|
||||
"meta.state": "Состояние",
|
||||
"meta.source": "Источник",
|
||||
"player.title_unavailable": "Название недоступно",
|
||||
"player.source": "Источник:",
|
||||
"player.unknown_source": "Неизвестно",
|
||||
"player.vinyl": "Режим винила",
|
||||
"player.visualizer": "Аудио визуализатор",
|
||||
"player.background": "Динамический фон",
|
||||
"player.fullscreen": "Полноэкранный режим",
|
||||
"player.fullscreen.exit": "Выйти из полного экрана",
|
||||
"player.fullscreen.exit_short": "Выйти",
|
||||
"state.playing": "Воспроизведение",
|
||||
"state.paused": "Пауза",
|
||||
"state.stopped": "Остановлено",
|
||||
@@ -126,8 +137,8 @@
|
||||
"callbacks.msg.list_failed": "Не удалось загрузить обратные вызовы",
|
||||
"callbacks.confirm.delete": "Вы уверены, что хотите удалить обратный вызов \"{name}\"?",
|
||||
"callbacks.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
|
||||
"tab.player": "Плеер",
|
||||
"tab.browser": "Браузер",
|
||||
"tab.player": "Сейчас играет",
|
||||
"tab.browser": "Библиотека",
|
||||
"tab.quick_access": "Быстрый Доступ",
|
||||
"tab.settings": "Настройки",
|
||||
"tab.display": "Дисплей",
|
||||
@@ -150,6 +161,31 @@
|
||||
"display.power_on": "Включить",
|
||||
"display.power_off": "Выключить",
|
||||
"display.primary": "Основной",
|
||||
"display.brightness": "Яркость",
|
||||
"display.contrast": "Контраст",
|
||||
"display.tuning": "Настройка изображения",
|
||||
"display.input_source": "Вход",
|
||||
"display.color_preset": "Цветовая температура",
|
||||
"display.picture_mode": "Режим изображения",
|
||||
"display.msg.contrast_failed": "Не удалось установить контраст",
|
||||
"display.msg.input_changed": "Источник входа переключён",
|
||||
"display.msg.input_failed": "Не удалось переключить источник",
|
||||
"display.msg.color_changed": "Цветовая температура применена",
|
||||
"display.msg.color_failed": "Не удалось применить цветовую температуру",
|
||||
"display.msg.mode_changed": "Режим изображения применён",
|
||||
"display.msg.mode_failed": "Не удалось применить режим изображения",
|
||||
"display.msg.power_on": "Монитор включён",
|
||||
"display.msg.power_off": "Монитор выключен",
|
||||
"display.msg.power_failed": "Не удалось переключить питание монитора",
|
||||
"execution.result": "Результат выполнения",
|
||||
"execution.executing": "Выполняется",
|
||||
"execution.status": "Статус",
|
||||
"execution.exit_code": "Код выхода",
|
||||
"execution.duration": "Длительность",
|
||||
"execution.success": "Успешно",
|
||||
"execution.failed": "Ошибка",
|
||||
"execution.running": "Выполняется...",
|
||||
"execution.no_output": "(нет вывода)",
|
||||
"browser.title": "Медиа Браузер",
|
||||
"browser.home": "Главная",
|
||||
"browser.manage_folders": "Управление папками",
|
||||
@@ -248,8 +284,37 @@
|
||||
"links.msg.load_failed": "Не удалось загрузить данные ссылки",
|
||||
"links.confirm.delete": "Вы уверены, что хотите удалить ссылку \"{name}\"?",
|
||||
"links.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
|
||||
"footer.created_by": "Создано",
|
||||
"footer.source_code": "Исходный код",
|
||||
"about.button_title": "О программе",
|
||||
"about.title": "О программе",
|
||||
"about.created_by": "Создано",
|
||||
"about.email": "Эл. почта",
|
||||
"about.repository": "Репозиторий",
|
||||
"about.source_code": "Исходный код",
|
||||
"dialog.close": "Закрыть",
|
||||
"update.available": "Доступно обновление: v{version}",
|
||||
"update.view_release": "Перейти к релизу"
|
||||
"update.view_release": "Перейти к релизу",
|
||||
"tab.foreground": "Активное окно",
|
||||
"foreground.kicker": "Активное окно",
|
||||
"foreground.loading": "Ожидание сигнала об активном окне…",
|
||||
"foreground.no_process": "Активное окно не определено",
|
||||
"foreground.unavailable": "Отслеживание активного окна недоступно",
|
||||
"foreground.process": "Процесс",
|
||||
"foreground.window_title": "Заголовок окна",
|
||||
"foreground.executable": "Путь к программе",
|
||||
"foreground.pid": "PID",
|
||||
"foreground.monitor": "Монитор {n}",
|
||||
"foreground.started": "Запущено",
|
||||
"foreground.geometry": "Геометрия",
|
||||
"foreground.platform": "Платформа",
|
||||
"foreground.fullscreen": "Полноэкранный",
|
||||
"foreground.minimized": "Свёрнут",
|
||||
"foreground.windowed": "Оконный",
|
||||
"foreground.browser": "Браузер",
|
||||
"foreground.page_title": "Заголовок страницы",
|
||||
"foreground.url": "URL",
|
||||
"foreground.badge.title": "Открыть активное окно",
|
||||
"foreground.ago.seconds": "{n} с назад",
|
||||
"foreground.ago.minutes": "{n} мин назад",
|
||||
"foreground.ago.hours": "{n} ч {m} мин назад",
|
||||
"foreground.ago.days": "{n} дн назад"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "Media Server",
|
||||
"short_name": "Media",
|
||||
"description": "Remote media player control and file browser",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#121212",
|
||||
"theme_color": "#121212",
|
||||
"background_color": "#0E0D0B",
|
||||
"theme_color": "#0E0D0B",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/icon.svg",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
||||
// Minimal service worker for PWA installability.
|
||||
// Minimal service worker for PWA installability only.
|
||||
// This app requires a live WebSocket connection, so offline caching is not useful.
|
||||
// All fetch requests are passed through to the network.
|
||||
// We intentionally do NOT register a `fetch` handler — a pass-through handler
|
||||
// forces every navigation through the SW for no benefit and breaks the
|
||||
// browser's normal HTTP cache + error semantics.
|
||||
|
||||
self.addEventListener('install', () => {
|
||||
self.skipWaiting();
|
||||
@@ -9,7 +11,3 @@ self.addEventListener('install', () => {
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
event.respondWith(fetch(event.request));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,911 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vinyl Variants · Studio Reference</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/icons/icon.svg">
|
||||
|
||||
<style>
|
||||
/* ───────── Local fonts (re-using main app's woff2 files) ───── */
|
||||
@font-face {
|
||||
font-family: 'Fraunces';
|
||||
font-style: italic;
|
||||
font-weight: 300 900;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/Fraunces-italic-latin.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Fraunces';
|
||||
font-style: normal;
|
||||
font-weight: 300 900;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/Fraunces-latin.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
font-style: normal;
|
||||
font-weight: 300 700;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/Geist-latin.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
font-style: normal;
|
||||
font-weight: 300 600;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/GeistMono-latin.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* ───────── Tokens (Studio Reference, dark) ───── */
|
||||
:root {
|
||||
--bg-deep: #0E0D0B;
|
||||
--bg-paper: #18150F;
|
||||
--bg-card: #211E18;
|
||||
--bg-card-2: #26211A;
|
||||
--bg-rule: #2E2820;
|
||||
--ink: #F2EBDC;
|
||||
--ink-soft: #D6CDB9;
|
||||
--ink-mute: #9C937F;
|
||||
--ink-faint: #5C5447;
|
||||
--ink-ghost: #3A3528;
|
||||
--copper: #E08038;
|
||||
--copper-hi: #F4A064;
|
||||
--copper-lo: #B0561F;
|
||||
--copper-glow: rgba(224, 128, 56, 0.35);
|
||||
--rule: rgba(242, 235, 220, 0.08);
|
||||
--rule-strong: rgba(242, 235, 220, 0.18);
|
||||
--serif: 'Fraunces', Georgia, serif;
|
||||
--sans: 'Geist', system-ui, sans-serif;
|
||||
--mono: 'Geist Mono', ui-monospace, monospace;
|
||||
--ease: cubic-bezier(.2, .7, .2, 1);
|
||||
--ease-out: cubic-bezier(.16, 1, .3, 1);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html { background: var(--bg-deep); }
|
||||
body {
|
||||
font-family: var(--sans);
|
||||
background: var(--bg-deep);
|
||||
color: var(--ink);
|
||||
min-height: 100vh;
|
||||
padding: 56px 36px 80px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* Film grain */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
opacity: 0.05;
|
||||
mix-blend-mode: overlay;
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.95 0 0 0 0 0.92 0 0 0 0 0.86 0 0 0 0.7 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* ───────── Page header (editorial) ───── */
|
||||
header.page-head {
|
||||
max-width: 1320px;
|
||||
margin: 0 auto 48px;
|
||||
text-align: center;
|
||||
}
|
||||
.kicker {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.32em;
|
||||
text-transform: uppercase;
|
||||
color: var(--copper);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.kicker::before, .kicker::after {
|
||||
content: "";
|
||||
height: 1px;
|
||||
width: 40px;
|
||||
background: var(--copper);
|
||||
opacity: 0.6;
|
||||
}
|
||||
h1 {
|
||||
font-family: var(--serif);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: clamp(36px, 5vw, 56px);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 14px;
|
||||
font-variation-settings: 'opsz' 144;
|
||||
}
|
||||
.subtitle {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-mute);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.return-link {
|
||||
display: inline-block;
|
||||
margin-top: 24px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--ink-faint);
|
||||
padding-bottom: 2px;
|
||||
transition: all 200ms var(--ease);
|
||||
}
|
||||
.return-link:hover { color: var(--copper); border-color: var(--copper); }
|
||||
|
||||
/* ───────── Variant grid ───── */
|
||||
.grid {
|
||||
max-width: 1320px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
||||
gap: 56px 40px;
|
||||
}
|
||||
|
||||
article.variant {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.stage {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
width: 100%;
|
||||
background:
|
||||
radial-gradient(ellipse at center, var(--bg-card-2) 0%, var(--bg-deep) 80%);
|
||||
border: 1px solid var(--rule);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.label-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.label-num {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--copper);
|
||||
}
|
||||
.label-name {
|
||||
font-family: var(--serif);
|
||||
font-style: italic;
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
font-variation-settings: 'opsz' 60;
|
||||
flex: 1;
|
||||
}
|
||||
.label-tag {
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--rule-strong);
|
||||
}
|
||||
.tag-css { color: var(--jade, #7AB294); border-color: rgba(122, 178, 148, 0.3); }
|
||||
.tag-needs-js { color: var(--copper); border-color: var(--copper-lo); }
|
||||
|
||||
p.descr {
|
||||
font-family: var(--sans);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
p.descr strong {
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ───────── Shared vinyl base ───── */
|
||||
.vinyl {
|
||||
position: relative;
|
||||
width: 86%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle at 50% 50%,
|
||||
#0a0907 0%, #0a0907 18%,
|
||||
#1a1611 18.3%, #0a0907 18.6%,
|
||||
#14110c 22%, #0a0907 22.3%,
|
||||
#14110c 26%, #0a0907 26.3%,
|
||||
#14110c 30%, #0a0907 30.3%,
|
||||
#14110c 34%, #0a0907 34.3%,
|
||||
#14110c 38%, #0a0907 38.3%,
|
||||
#14110c 42%, #0a0907 42.3%,
|
||||
#14110c 46%, #0a0907 46.3%,
|
||||
#1c1812 47%, #0a0907 100%);
|
||||
box-shadow:
|
||||
inset 0 0 60px rgba(0, 0, 0, 0.7),
|
||||
0 30px 80px rgba(0, 0, 0, 0.6),
|
||||
0 6px 20px rgba(0, 0, 0, 0.5);
|
||||
animation: spin 14s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.vinyl::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 12%;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
conic-gradient(from 0deg,
|
||||
rgba(255,255,255,0.04) 0deg,
|
||||
transparent 30deg,
|
||||
rgba(255,255,255,0.06) 90deg,
|
||||
transparent 150deg,
|
||||
rgba(255,255,255,0.03) 210deg,
|
||||
transparent 270deg,
|
||||
rgba(255,255,255,0.05) 330deg,
|
||||
transparent 360deg);
|
||||
mix-blend-mode: screen;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vinyl-label {
|
||||
position: absolute;
|
||||
inset: 28%;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
inset 0 0 24px rgba(0, 0, 0, 0.4),
|
||||
0 0 0 4px var(--bg-deep),
|
||||
0 0 0 5px var(--copper-lo);
|
||||
background: var(--bg-card);
|
||||
z-index: 1;
|
||||
}
|
||||
.vinyl-label::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 8%; height: 8%;
|
||||
top: 46%; left: 46%;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-deep);
|
||||
box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.1);
|
||||
z-index: 3;
|
||||
}
|
||||
.vinyl-label img,
|
||||
.vinyl-label svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Album art (shared SVG used by every variant) */
|
||||
.album-art {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Tonearm (decorative, on every stage so they read as "now playing") */
|
||||
.tonearm {
|
||||
position: absolute;
|
||||
top: -4%;
|
||||
right: -2%;
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
pointer-events: none;
|
||||
transform-origin: 88% 12%;
|
||||
transform: rotate(0deg);
|
||||
z-index: 5;
|
||||
filter: drop-shadow(0 4px 12px rgba(0,0,0,0.5));
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
ORIGINAL — current shipping look (control)
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
.v0 .stage { /* nothing extra */ }
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
VARIANT 1 — Sleeve frame
|
||||
Vinyl peeks out of a square cardstock sleeve.
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
.v1 .stage {
|
||||
background:
|
||||
radial-gradient(ellipse at center, #1a1611 0%, var(--bg-deep) 80%);
|
||||
}
|
||||
.v1 .sleeve-stage {
|
||||
position: relative;
|
||||
width: 90%;
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.v1 .sleeve {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 6%;
|
||||
width: 70%;
|
||||
aspect-ratio: 1;
|
||||
background: var(--bg-card-2);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(0,0,0,0.4),
|
||||
inset 4px 4px 24px rgba(0,0,0,0.35),
|
||||
-2px 8px 24px rgba(0,0,0,0.5),
|
||||
-4px 16px 40px rgba(0,0,0,0.35);
|
||||
z-index: 3;
|
||||
/* Casually-placed tilt — like a sleeve set down on a console */
|
||||
transform: rotate(-3.2deg);
|
||||
transform-origin: 60% 60%;
|
||||
/* worn-edge cardstock effect */
|
||||
filter: contrast(1.05) brightness(0.97);
|
||||
}
|
||||
.v1 .sleeve::before {
|
||||
/* Cardstock paper grain */
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.2' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.10 0 0 0 0 0.08 0 0 0 0 0.06 0 0 0 0.7 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
mix-blend-mode: multiply;
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.v1 .sleeve::after {
|
||||
/* Ring-wear: faint circle from the LP rubbing the cardstock */
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 6%;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0,0,0,0.25);
|
||||
box-shadow:
|
||||
inset 0 0 12px rgba(0,0,0,0.18),
|
||||
inset 0 0 0 1px rgba(255,255,255,0.04);
|
||||
pointer-events: none;
|
||||
}
|
||||
.v1 .sleeve-art {
|
||||
position: absolute;
|
||||
inset: 6%;
|
||||
z-index: 1;
|
||||
filter: contrast(0.88) saturate(0.6) brightness(0.88);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.v1 .sleeve-art svg { width: 100%; height: 100%; }
|
||||
/* Worn corner notch */
|
||||
.v1 .sleeve-corner {
|
||||
position: absolute;
|
||||
width: 14%;
|
||||
height: 14%;
|
||||
bottom: -1px;
|
||||
right: -1px;
|
||||
background: var(--bg-deep);
|
||||
clip-path: polygon(100% 0, 100% 100%, 0 100%);
|
||||
opacity: 0.7;
|
||||
z-index: 4;
|
||||
}
|
||||
.v1 .vinyl-wrap {
|
||||
position: absolute;
|
||||
right: -2%;
|
||||
top: 16%;
|
||||
width: 70%;
|
||||
aspect-ratio: 1;
|
||||
z-index: 2;
|
||||
}
|
||||
.v1 .vinyl-wrap .vinyl {
|
||||
width: 100%;
|
||||
}
|
||||
.v1 .vinyl-label {
|
||||
/* Smaller label since the disc here is showing; album art lives on sleeve */
|
||||
inset: 32%;
|
||||
background: #2E2820;
|
||||
box-shadow:
|
||||
inset 0 0 18px rgba(0,0,0,0.4),
|
||||
0 0 0 3px var(--bg-deep),
|
||||
0 0 0 4px var(--copper-lo);
|
||||
}
|
||||
.v1 .vinyl-label::before {
|
||||
/* Plain-color label with faux pressing imprint */
|
||||
content: "REF · 24";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.3em;
|
||||
color: var(--copper);
|
||||
z-index: 2;
|
||||
}
|
||||
.v1 .tonearm {
|
||||
right: -8%;
|
||||
top: 8%;
|
||||
width: 44%;
|
||||
height: 44%;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
VARIANT 2 — Sheen + paper grain + dead-wax + off-center
|
||||
The high-impact variant.
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
.v2 .vinyl-label {
|
||||
/* Slightly off-center spindle for "pressed off-axis" feel */
|
||||
inset: 27% 27% 29% 29%;
|
||||
}
|
||||
.v2 .vinyl-label::after {
|
||||
/* Spindle hole offset 1.5% from true center */
|
||||
top: 47%;
|
||||
left: 47.5%;
|
||||
}
|
||||
/* Paper grain on the label, multiplied so it sits inside the print */
|
||||
.v2 .vinyl-label .label-grain {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.6' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.05 0 0 0 0 0.04 0 0 0 0 0.03 0 0 0 0.55 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
mix-blend-mode: multiply;
|
||||
pointer-events: none;
|
||||
z-index: 4;
|
||||
}
|
||||
/* Dead-wax: micro-text engraved between the label and the run-out groove */
|
||||
.v2 .dead-wax {
|
||||
position: absolute;
|
||||
inset: 21%;
|
||||
border-radius: 50%;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
/* Animation OFF the disc — engraving is part of the press, so it does spin with the vinyl */
|
||||
animation: spin 14s linear infinite;
|
||||
}
|
||||
.v2 .dead-wax svg { width: 100%; height: 100%; }
|
||||
/* Reflection sweep — fixed in viewer space, not rotating with the disc */
|
||||
.v2 .sheen {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
background:
|
||||
conic-gradient(from 110deg,
|
||||
transparent 0deg,
|
||||
rgba(255, 245, 220, 0) 30deg,
|
||||
rgba(255, 245, 220, 0.07) 60deg,
|
||||
rgba(255, 245, 220, 0.14) 80deg,
|
||||
rgba(255, 245, 220, 0.07) 100deg,
|
||||
transparent 140deg,
|
||||
transparent 280deg,
|
||||
rgba(255, 245, 220, 0.04) 305deg,
|
||||
rgba(255, 245, 220, 0.08) 320deg,
|
||||
rgba(255, 245, 220, 0.04) 335deg,
|
||||
transparent 360deg);
|
||||
mix-blend-mode: screen;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
VARIANT 3 — Tone-graded album art (duotone)
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
.v3 .vinyl-label .album-art {
|
||||
filter:
|
||||
saturate(0.35)
|
||||
sepia(0.45)
|
||||
hue-rotate(345deg)
|
||||
brightness(0.85)
|
||||
contrast(1.18);
|
||||
}
|
||||
.v3 .vinyl-label::before {
|
||||
/* Subtle copper duotone overlay tints the highlights */
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(135deg,
|
||||
rgba(224, 128, 56, 0.18) 0%,
|
||||
rgba(31, 78, 61, 0.10) 50%,
|
||||
rgba(0,0,0,0.18) 100%);
|
||||
mix-blend-mode: overlay;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
.v3 .vinyl-label::after {
|
||||
z-index: 4;
|
||||
}
|
||||
.v3 .vinyl-label .vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at 50% 45%,
|
||||
transparent 35%,
|
||||
rgba(0,0,0,0.45) 100%);
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
VARIANT 4 — Sleeve-to-disc reveal animation
|
||||
(Hover the card to see the disc slide out)
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
.v4 .sleeve-stage {
|
||||
position: relative;
|
||||
width: 90%;
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.v4 .sleeve {
|
||||
position: absolute;
|
||||
left: 14%;
|
||||
top: 12%;
|
||||
width: 72%;
|
||||
aspect-ratio: 1;
|
||||
background: var(--bg-card-2);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(0,0,0,0.4),
|
||||
-2px 6px 18px rgba(0,0,0,0.5);
|
||||
z-index: 4;
|
||||
overflow: hidden;
|
||||
}
|
||||
.v4 .sleeve::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.2' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.10 0 0 0 0 0.08 0 0 0 0 0.06 0 0 0 0.5 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
mix-blend-mode: multiply;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
.v4 .sleeve-art {
|
||||
width: 100%; height: 100%;
|
||||
filter: contrast(0.92) saturate(0.7) brightness(0.92);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.v4 .vinyl-slot {
|
||||
position: absolute;
|
||||
left: 14%;
|
||||
top: 12%;
|
||||
width: 72%;
|
||||
aspect-ratio: 1;
|
||||
z-index: 3;
|
||||
transition: transform 1.2s var(--ease-out);
|
||||
}
|
||||
.v4 .vinyl-slot .vinyl {
|
||||
width: 100%;
|
||||
animation-play-state: paused;
|
||||
transition: animation-play-state 0.4s;
|
||||
}
|
||||
.v4 .stage:hover .vinyl-slot {
|
||||
transform: translateX(46%);
|
||||
}
|
||||
.v4 .stage:hover .vinyl-slot .vinyl {
|
||||
animation-play-state: running;
|
||||
}
|
||||
.v4 .hover-hint {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
.v4 .stage:hover .hover-hint { opacity: 0.4; }
|
||||
|
||||
/* Note row at top of every variant */
|
||||
.note {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 14px;
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* ───────── Mobile ───── */
|
||||
@media (max-width: 720px) {
|
||||
body { padding: 36px 16px 60px; }
|
||||
.grid { gap: 36px 20px; grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="page-head">
|
||||
<div class="kicker">Studio Reference · Album Art Variants</div>
|
||||
<h1>Vinyl Cover Treatments</h1>
|
||||
<p class="subtitle">Five renderings of the same disc · Hover variant 04 for the sleeve reveal</p>
|
||||
<a class="return-link" href="/">← Return to player</a>
|
||||
</header>
|
||||
|
||||
<div class="grid">
|
||||
|
||||
<!-- ═════════ ORIGINAL ═════════ -->
|
||||
<article class="variant v0">
|
||||
<div class="stage">
|
||||
<span class="note">As shipping</span>
|
||||
<div class="vinyl">
|
||||
<div class="vinyl-label">
|
||||
<svg viewBox="0 0 400 400" class="album-art" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bgA" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1F4E3D"/>
|
||||
<stop offset="55%" stop-color="#143E2F"/>
|
||||
<stop offset="100%" stop-color="#0a2519"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="vigA" cx="50%" cy="50%" r="70%">
|
||||
<stop offset="55%" stop-color="rgba(0,0,0,0)"/>
|
||||
<stop offset="100%" stop-color="rgba(0,0,0,0.55)"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="400" height="400" fill="url(#bgA)"/>
|
||||
<g stroke="#e08038" stroke-width="1" fill="none" opacity="0.55">
|
||||
<circle cx="200" cy="200" r="60"/>
|
||||
<circle cx="200" cy="200" r="100"/>
|
||||
<circle cx="200" cy="200" r="140"/>
|
||||
<circle cx="200" cy="200" r="180"/>
|
||||
</g>
|
||||
<text x="200" y="195" text-anchor="middle" font-family="serif" font-style="italic" fill="#f2ebdc" font-size="34">Reference</text>
|
||||
<text x="200" y="225" text-anchor="middle" font-family="monospace" fill="#e08038" font-size="11" letter-spacing="4">VOL · I</text>
|
||||
<text x="200" y="368" text-anchor="middle" font-family="monospace" fill="#9c937f" font-size="9" letter-spacing="6" opacity="0.6">STUDIO PRESSING</text>
|
||||
<rect width="400" height="400" fill="url(#vigA)"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="armGrad0" x1="0" x2="1">
|
||||
<stop offset="0" stop-color="#3a3528"/>
|
||||
<stop offset="0.5" stop-color="#9C937F"/>
|
||||
<stop offset="1" stop-color="#5C5447"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="176" cy="24" r="14" fill="#1a1611" stroke="#3A3528" stroke-width="1"/>
|
||||
<circle cx="176" cy="24" r="6" fill="#3A3528"/>
|
||||
<circle cx="176" cy="24" r="2" fill="#E08038"/>
|
||||
<line x1="176" y1="24" x2="64" y2="136" stroke="url(#armGrad0)" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<rect x="180" y="14" width="14" height="20" fill="#26211A" stroke="#3A3528"/>
|
||||
<rect x="56" y="128" width="22" height="18" rx="2" fill="#26211A" stroke="#3A3528" transform="rotate(-45 67 137)"/>
|
||||
<circle cx="62" cy="138" r="3" fill="#E08038" opacity="0.8"/>
|
||||
<circle cx="62" cy="138" r="6" fill="none" stroke="#E08038" stroke-width="0.5" opacity="0.4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="label-row">
|
||||
<span class="label-num">00</span>
|
||||
<span class="label-name">Original</span>
|
||||
<span class="label-tag tag-css">control</span>
|
||||
</div>
|
||||
<p class="descr">Current shipping vinyl: pressed grooves, copper-bordered label rim, full album art on the label. Reference baseline for everything below.</p>
|
||||
</article>
|
||||
|
||||
<!-- ═════════ VARIANT 1 — SLEEVE FRAME ═════════ -->
|
||||
<article class="variant v1">
|
||||
<div class="stage">
|
||||
<span class="note">CSS only</span>
|
||||
<div class="sleeve-stage">
|
||||
<div class="sleeve">
|
||||
<div class="sleeve-art">
|
||||
<svg viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bgB" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1F4E3D"/>
|
||||
<stop offset="55%" stop-color="#143E2F"/>
|
||||
<stop offset="100%" stop-color="#0a2519"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="400" fill="url(#bgB)"/>
|
||||
<g stroke="#e08038" stroke-width="1" fill="none" opacity="0.55">
|
||||
<circle cx="200" cy="200" r="60"/>
|
||||
<circle cx="200" cy="200" r="100"/>
|
||||
<circle cx="200" cy="200" r="140"/>
|
||||
<circle cx="200" cy="200" r="180"/>
|
||||
</g>
|
||||
<text x="200" y="195" text-anchor="middle" font-family="serif" font-style="italic" fill="#f2ebdc" font-size="34">Reference</text>
|
||||
<text x="200" y="225" text-anchor="middle" font-family="monospace" fill="#e08038" font-size="11" letter-spacing="4">VOL · I</text>
|
||||
<text x="200" y="368" text-anchor="middle" font-family="monospace" fill="#9c937f" font-size="9" letter-spacing="6" opacity="0.6">STUDIO PRESSING</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="sleeve-corner"></div>
|
||||
</div>
|
||||
<div class="vinyl-wrap">
|
||||
<div class="vinyl">
|
||||
<div class="vinyl-label"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
|
||||
<use href="#armGrad0"/>
|
||||
<circle cx="176" cy="24" r="14" fill="#1a1611" stroke="#3A3528" stroke-width="1"/>
|
||||
<circle cx="176" cy="24" r="6" fill="#3A3528"/>
|
||||
<circle cx="176" cy="24" r="2" fill="#E08038"/>
|
||||
<line x1="176" y1="24" x2="80" y2="120" stroke="#9C937F" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<rect x="180" y="14" width="14" height="20" fill="#26211A" stroke="#3A3528"/>
|
||||
<rect x="72" y="112" width="22" height="18" rx="2" fill="#26211A" stroke="#3A3528" transform="rotate(-45 83 121)"/>
|
||||
<circle cx="78" cy="122" r="3" fill="#E08038" opacity="0.8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="label-row">
|
||||
<span class="label-num">01</span>
|
||||
<span class="label-name">Sleeve Frame</span>
|
||||
<span class="label-tag tag-css">CSS only</span>
|
||||
</div>
|
||||
<p class="descr">Vinyl peeks out of a square cardstock <strong>sleeve</strong> — paper grain, ring-wear circle, worn-corner notch. The album art lives on the sleeve; the disc gets a plain typographic label. Reads instantly as "record on a turntable", not "spinning disc."</p>
|
||||
</article>
|
||||
|
||||
<!-- ═════════ VARIANT 2 — SHEEN + GRAIN + DEAD-WAX ═════════ -->
|
||||
<article class="variant v2">
|
||||
<div class="stage">
|
||||
<span class="note">CSS only · highest ROI</span>
|
||||
<div class="vinyl">
|
||||
<div class="dead-wax">
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<path id="dwPath" d="M 50,50 m -36,0 a 36,36 0 1,1 72,0 a 36,36 0 1,1 -72,0"/>
|
||||
</defs>
|
||||
<text font-family="monospace" font-size="2.4" fill="#3a3528" letter-spacing="0.45" opacity="0.85">
|
||||
<textPath href="#dwPath">· STUDIO REFERENCE PRESSING · A-SIDE · MASTER LACQUER 24-S · DOLG.AD MASTERED · ½ SPEED</textPath>
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="vinyl-label">
|
||||
<svg viewBox="0 0 400 400" class="album-art" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bgC" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1F4E3D"/>
|
||||
<stop offset="55%" stop-color="#143E2F"/>
|
||||
<stop offset="100%" stop-color="#0a2519"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="400" fill="url(#bgC)"/>
|
||||
<g stroke="#e08038" stroke-width="1" fill="none" opacity="0.55">
|
||||
<circle cx="200" cy="200" r="60"/>
|
||||
<circle cx="200" cy="200" r="100"/>
|
||||
<circle cx="200" cy="200" r="140"/>
|
||||
<circle cx="200" cy="200" r="180"/>
|
||||
</g>
|
||||
<text x="200" y="195" text-anchor="middle" font-family="serif" font-style="italic" fill="#f2ebdc" font-size="34">Reference</text>
|
||||
<text x="200" y="225" text-anchor="middle" font-family="monospace" fill="#e08038" font-size="11" letter-spacing="4">VOL · I</text>
|
||||
<text x="200" y="368" text-anchor="middle" font-family="monospace" fill="#9c937f" font-size="9" letter-spacing="6" opacity="0.6">STUDIO PRESSING</text>
|
||||
</svg>
|
||||
<div class="label-grain"></div>
|
||||
</div>
|
||||
<div class="sheen"></div>
|
||||
</div>
|
||||
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
|
||||
<circle cx="176" cy="24" r="14" fill="#1a1611" stroke="#3A3528" stroke-width="1"/>
|
||||
<circle cx="176" cy="24" r="6" fill="#3A3528"/>
|
||||
<circle cx="176" cy="24" r="2" fill="#E08038"/>
|
||||
<line x1="176" y1="24" x2="64" y2="136" stroke="#9C937F" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<rect x="180" y="14" width="14" height="20" fill="#26211A" stroke="#3A3528"/>
|
||||
<rect x="56" y="128" width="22" height="18" rx="2" fill="#26211A" stroke="#3A3528" transform="rotate(-45 67 137)"/>
|
||||
<circle cx="62" cy="138" r="3" fill="#E08038" opacity="0.8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="label-row">
|
||||
<span class="label-num">02</span>
|
||||
<span class="label-name">Sheen, Grain & Dead-Wax</span>
|
||||
<span class="label-tag tag-css">CSS only</span>
|
||||
</div>
|
||||
<p class="descr">Three layers added to the existing vinyl: a <strong>fixed reflection sweep</strong> (doesn't rotate with the disc — the studio-light look), <strong>paper grain</strong> on the label so the print sits in cardstock, and a <strong>dead-wax engraving</strong> of the master‑lacquer code spinning with the disc. Off-center spindle by 1.5%. Highest visual ROI for the smallest amount of new code.</p>
|
||||
</article>
|
||||
|
||||
<!-- ═════════ VARIANT 3 — TONE-GRADED ═════════ -->
|
||||
<article class="variant v3">
|
||||
<div class="stage">
|
||||
<span class="note">CSS only</span>
|
||||
<div class="vinyl">
|
||||
<div class="vinyl-label">
|
||||
<svg viewBox="0 0 400 400" class="album-art" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bgD" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1F4E3D"/>
|
||||
<stop offset="55%" stop-color="#143E2F"/>
|
||||
<stop offset="100%" stop-color="#0a2519"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="400" fill="url(#bgD)"/>
|
||||
<g stroke="#e08038" stroke-width="1" fill="none" opacity="0.55">
|
||||
<circle cx="200" cy="200" r="60"/>
|
||||
<circle cx="200" cy="200" r="100"/>
|
||||
<circle cx="200" cy="200" r="140"/>
|
||||
<circle cx="200" cy="200" r="180"/>
|
||||
</g>
|
||||
<text x="200" y="195" text-anchor="middle" font-family="serif" font-style="italic" fill="#f2ebdc" font-size="34">Reference</text>
|
||||
<text x="200" y="225" text-anchor="middle" font-family="monospace" fill="#e08038" font-size="11" letter-spacing="4">VOL · I</text>
|
||||
<text x="200" y="368" text-anchor="middle" font-family="monospace" fill="#9c937f" font-size="9" letter-spacing="6" opacity="0.6">STUDIO PRESSING</text>
|
||||
</svg>
|
||||
<div class="vignette"></div>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
|
||||
<circle cx="176" cy="24" r="14" fill="#1a1611" stroke="#3A3528" stroke-width="1"/>
|
||||
<circle cx="176" cy="24" r="6" fill="#3A3528"/>
|
||||
<circle cx="176" cy="24" r="2" fill="#E08038"/>
|
||||
<line x1="176" y1="24" x2="64" y2="136" stroke="#9C937F" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<rect x="180" y="14" width="14" height="20" fill="#26211A" stroke="#3A3528"/>
|
||||
<rect x="56" y="128" width="22" height="18" rx="2" fill="#26211A" stroke="#3A3528" transform="rotate(-45 67 137)"/>
|
||||
<circle cx="62" cy="138" r="3" fill="#E08038" opacity="0.8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="label-row">
|
||||
<span class="label-num">03</span>
|
||||
<span class="label-name">Tone-Graded Cover</span>
|
||||
<span class="label-tag tag-css">CSS only</span>
|
||||
</div>
|
||||
<p class="descr">Same disc, but the album art on the label is <strong>color-graded</strong> — duotone copper/emerald, deeper saturation drop, vignette around the label rim. Effect: every album cover ends up looking like it came from the same pressing plant, matching the Studio Reference chrome.</p>
|
||||
</article>
|
||||
|
||||
<!-- ═════════ VARIANT 4 — SLEEVE-TO-DISC REVEAL ═════════ -->
|
||||
<article class="variant v4">
|
||||
<div class="stage">
|
||||
<span class="note">CSS hover · JS in production</span>
|
||||
<div class="sleeve-stage">
|
||||
<div class="sleeve">
|
||||
<div class="sleeve-art">
|
||||
<svg viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bgE" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1F4E3D"/>
|
||||
<stop offset="55%" stop-color="#143E2F"/>
|
||||
<stop offset="100%" stop-color="#0a2519"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="400" fill="url(#bgE)"/>
|
||||
<g stroke="#e08038" stroke-width="1" fill="none" opacity="0.55">
|
||||
<circle cx="200" cy="200" r="60"/>
|
||||
<circle cx="200" cy="200" r="100"/>
|
||||
<circle cx="200" cy="200" r="140"/>
|
||||
<circle cx="200" cy="200" r="180"/>
|
||||
</g>
|
||||
<text x="200" y="195" text-anchor="middle" font-family="serif" font-style="italic" fill="#f2ebdc" font-size="34">Reference</text>
|
||||
<text x="200" y="225" text-anchor="middle" font-family="monospace" fill="#e08038" font-size="11" letter-spacing="4">VOL · I</text>
|
||||
<text x="200" y="368" text-anchor="middle" font-family="monospace" fill="#9c937f" font-size="9" letter-spacing="6" opacity="0.6">STUDIO PRESSING</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vinyl-slot">
|
||||
<div class="vinyl">
|
||||
<div class="vinyl-label"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="hover-hint">Hover to play</span>
|
||||
</div>
|
||||
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
|
||||
<circle cx="176" cy="24" r="14" fill="#1a1611" stroke="#3A3528" stroke-width="1"/>
|
||||
<circle cx="176" cy="24" r="6" fill="#3A3528"/>
|
||||
<circle cx="176" cy="24" r="2" fill="#E08038"/>
|
||||
<line x1="176" y1="24" x2="64" y2="136" stroke="#9C937F" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<rect x="180" y="14" width="14" height="20" fill="#26211A" stroke="#3A3528"/>
|
||||
<rect x="56" y="128" width="22" height="18" rx="2" fill="#26211A" stroke="#3A3528" transform="rotate(-45 67 137)"/>
|
||||
<circle cx="62" cy="138" r="3" fill="#E08038" opacity="0.8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="label-row">
|
||||
<span class="label-num">04</span>
|
||||
<span class="label-name">Sleeve-to-Disc Reveal</span>
|
||||
<span class="label-tag tag-needs-js">needs JS</span>
|
||||
</div>
|
||||
<p class="descr"><strong>Hover this card</strong> — the disc slides out of the sleeve and starts spinning. In production, this would be wired to the play/pause state: paused = tucked-in sleeve view, playing = disc revealed and spinning. Most evocative, also the most code (animation choreography + state coupling).</p>
|
||||
</article>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+121
-40
@@ -3,6 +3,7 @@
|
||||
import ctypes
|
||||
import io
|
||||
import logging
|
||||
import sys
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
@@ -30,62 +31,136 @@ _IDYES = 6
|
||||
|
||||
|
||||
def _confirm(title: str, message: str) -> bool:
|
||||
"""Show a Yes/No dialog using native Windows MessageBox."""
|
||||
result = ctypes.windll.user32.MessageBoxW(
|
||||
0, message, title, _MB_YESNO | _MB_ICONQUESTION | _MB_TOPMOST | _MB_SETFOREGROUND
|
||||
)
|
||||
return result == _IDYES
|
||||
"""Show a Yes/No dialog before a destructive tray action.
|
||||
|
||||
Uses the native Windows MessageBox on win32; on Linux/macOS we don't
|
||||
have a no-dependency GUI dialog available (Tk pulls in tkinter, gtk
|
||||
pulls in PyGObject), so we log + auto-confirm — the tray menu items
|
||||
themselves require a deliberate click already.
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
result = ctypes.windll.user32.MessageBoxW(
|
||||
0,
|
||||
message,
|
||||
title,
|
||||
_MB_YESNO | _MB_ICONQUESTION | _MB_TOPMOST | _MB_SETFOREGROUND,
|
||||
)
|
||||
return result == _IDYES
|
||||
|
||||
logger.info("Tray confirm (auto-yes on non-Windows): %s — %s", title, message)
|
||||
return True
|
||||
|
||||
|
||||
def _create_icon_image(size: int = 64) -> Image.Image:
|
||||
"""Create a tray icon: green circle with white play triangle."""
|
||||
# Frame size we ask the multi-resolution ICO for. Most Windows tray surfaces
|
||||
# show 16x16 in the notification area and 32x32 in jump lists / Alt+Tab; 64
|
||||
# gives pystray enough headroom for both without forcing it to upscale.
|
||||
_TRAY_ICON_SIZE = 64
|
||||
|
||||
# Palette mirrors media_server/static/icons/icon.svg ("Beacon" design).
|
||||
_BG_DARK = (11, 61, 59, 255) # #0B3D3B
|
||||
_BG_LIGHT = (26, 107, 94, 255) # #1A6B5E
|
||||
_FG_PARCHMENT = (245, 241, 232, 255) # #F5F1E8
|
||||
|
||||
|
||||
def _create_icon_image(size: int = _TRAY_ICON_SIZE) -> Image.Image:
|
||||
"""Procedural fallback when no icon file is available.
|
||||
|
||||
Matches the "Beacon" palette (deep teal squircle + warm parchment play
|
||||
triangle) so a missing icon.ico does not regress us back to the old
|
||||
Spotify-green circle.
|
||||
"""
|
||||
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Green circle background
|
||||
padding = 2
|
||||
draw.ellipse(
|
||||
[padding, padding, size - padding, size - padding],
|
||||
fill=(29, 185, 84, 255),
|
||||
# Squircle background. Vertical gradient approximates the diagonal one
|
||||
# in the real SVG well enough for a 64px fallback.
|
||||
radius = int(size * 0.225)
|
||||
for y in range(size):
|
||||
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
|
||||
|
||||
|
||||
def _load_icon_image() -> Image.Image:
|
||||
"""Load the ICO/SVG app icon, falling back to a generated image."""
|
||||
def _select_frame(image: Image.Image, target: int) -> Image.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"
|
||||
|
||||
# Try .ico first (best for Windows tray)
|
||||
ico_path = icons_dir / "icon.ico"
|
||||
if ico_path.exists():
|
||||
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:
|
||||
pass
|
||||
|
||||
# Try SVG via cairosvg
|
||||
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()
|
||||
return _create_icon_image(size)
|
||||
|
||||
|
||||
class TrayManager:
|
||||
@@ -101,6 +176,9 @@ class TrayManager:
|
||||
|
||||
self._port = port
|
||||
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(
|
||||
pystray.MenuItem("Show UI", self._show_ui, default=True),
|
||||
@@ -123,13 +201,16 @@ class TrayManager:
|
||||
if not _confirm("Media Server", "Restart the server?"):
|
||||
return
|
||||
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._on_exit()
|
||||
self._icon.stop()
|
||||
|
||||
@property
|
||||
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:
|
||||
if not _confirm("Media Server", "Shut down the server?"):
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "media-server-frontend",
|
||||
"version": "0.1.5",
|
||||
"version": "0.4.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "media-server-frontend",
|
||||
"version": "0.1.5",
|
||||
"version": "0.4.0",
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.27.4"
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "media-server-frontend",
|
||||
"version": "0.1.5",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"description": "Frontend build tooling for media server WebUI",
|
||||
"scripts": {
|
||||
|
||||
+28
-9
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "media-server"
|
||||
version = "0.1.5"
|
||||
version = "0.4.0"
|
||||
description = "REST API server for controlling system-wide media playback"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
@@ -32,27 +32,46 @@ dependencies = [
|
||||
"pyyaml>=6.0",
|
||||
"mutagen>=1.47.0",
|
||||
"pillow>=10.0.0",
|
||||
"soundcard>=0.4.0",
|
||||
"numpy>=1.24.0,<2.0",
|
||||
# screen-brightness-control + monitorcontrol are cross-platform
|
||||
# (Windows / Linux i2c-dev). Kept in base deps so a plain
|
||||
# `pip install media-server` yields a working /api/display on any
|
||||
# platform that has the underlying hardware support.
|
||||
"screen-brightness-control>=0.20.0",
|
||||
"monitorcontrol>=3.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
windows = [
|
||||
"winsdk>=1.0.0b10",
|
||||
"pywin32>=306",
|
||||
"comtypes>=1.2.0",
|
||||
"pycaw>=20230407",
|
||||
"screen-brightness-control>=0.20.0",
|
||||
"monitorcontrol>=3.0.0",
|
||||
"pywin32>=306; sys_platform == 'win32'",
|
||||
"comtypes>=1.2.0; sys_platform == 'win32'",
|
||||
"pycaw>=20230407; sys_platform == 'win32'",
|
||||
"wmi>=1.5.1; sys_platform == 'win32'",
|
||||
"pystray>=0.19.0",
|
||||
]
|
||||
visualizer = [
|
||||
"soundcard>=0.4.0",
|
||||
"numpy>=1.24.0,<2.0",
|
||||
linux = [
|
||||
# MPRIS / D-Bus media control. Requires libdbus-1-dev, libglib2.0-dev,
|
||||
# and pkg-config installed on the host before pip install.
|
||||
"dbus-python>=1.3.2; sys_platform == 'linux'",
|
||||
"PyGObject>=3.46; sys_platform == 'linux'",
|
||||
# X11 foreground-window probe (Wayland sessions degrade gracefully).
|
||||
"python-xlib>=0.33; sys_platform == 'linux'",
|
||||
]
|
||||
macos = [
|
||||
# PyObjC for the foreground-window + active-app probes.
|
||||
"pyobjc-framework-Cocoa>=10.0; sys_platform == 'darwin'",
|
||||
"pyobjc-framework-Quartz>=10.0; sys_platform == 'darwin'",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"pytest-asyncio>=0.21",
|
||||
"httpx>=0.24",
|
||||
"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]
|
||||
|
||||
@@ -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()
|
||||
+94
-26
@@ -1,35 +1,103 @@
|
||||
# Restart the Media Server
|
||||
# Stop any running instance
|
||||
$procs = Get-Process -Name 'media-server' -ErrorAction SilentlyContinue
|
||||
foreach ($p in $procs) {
|
||||
Write-Host "Stopping server (PID $($p.Id))..."
|
||||
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($procs) { Start-Sleep -Seconds 2 }
|
||||
# Restart the Media Server.
|
||||
#
|
||||
# Robust against the two ways the server gets started:
|
||||
# - Installer build: %LOCALAPPDATA%\Media Server\media-server.bat
|
||||
# (runs as python.exe -m media_server.main)
|
||||
# - Dev editable install: media-server console script on PATH
|
||||
# (runs as media-server.exe)
|
||||
#
|
||||
# The old version of this script only killed processes named 'media-server',
|
||||
# which silently missed the installer-bundled process (named 'python').
|
||||
# This version kills whatever currently owns the listen port, so it doesn't
|
||||
# matter how the previous instance was launched.
|
||||
|
||||
# Merge registry PATH with current PATH so newly-installed tools are visible
|
||||
$regUser = [Environment]::GetEnvironmentVariable('PATH', 'User')
|
||||
if ($regUser) {
|
||||
$currentDirs = $env:PATH -split ';' | ForEach-Object { $_.TrimEnd('\') }
|
||||
foreach ($dir in ($regUser -split ';')) {
|
||||
if ($dir -and ($currentDirs -notcontains $dir.TrimEnd('\'))) {
|
||||
$env:PATH = "$env:PATH;$dir"
|
||||
}
|
||||
param(
|
||||
[ValidateSet('auto', 'dev', 'installer')]
|
||||
[string]$Mode = 'auto',
|
||||
[int]$Port = 8765
|
||||
)
|
||||
|
||||
$InstallerLauncher = Join-Path $env:LOCALAPPDATA 'Media Server\media-server.bat'
|
||||
$InstallerDir = Join-Path $env:LOCALAPPDATA 'Media Server'
|
||||
|
||||
# --- Resolve launch mode -----------------------------------------------------
|
||||
if ($Mode -eq 'auto') {
|
||||
if (Test-Path $InstallerLauncher) {
|
||||
$Mode = 'installer'
|
||||
} else {
|
||||
$Mode = 'dev'
|
||||
}
|
||||
}
|
||||
|
||||
# Start server detached
|
||||
Write-Host "Starting server..."
|
||||
Start-Process -FilePath 'media-server' `
|
||||
-WorkingDirectory 'c:\Users\Alexei\Documents\haos-integration-media-player\media-server' `
|
||||
-WindowStyle Hidden
|
||||
# --- Stop whatever is listening on the port ---------------------------------
|
||||
$listenerPids = @()
|
||||
try {
|
||||
$conns = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue
|
||||
if ($conns) {
|
||||
$listenerPids = $conns | Select-Object -ExpandProperty OwningProcess -Unique
|
||||
}
|
||||
} catch {
|
||||
# Get-NetTCPConnection unavailable (rare); fall back to netstat parsing
|
||||
$listenerPids = & netstat -ano | Select-String ":$Port\s+.*LISTENING" | ForEach-Object {
|
||||
($_ -split '\s+')[-1]
|
||||
} | Sort-Object -Unique
|
||||
}
|
||||
|
||||
foreach ($targetPid in $listenerPids) {
|
||||
$proc = Get-Process -Id $targetPid -ErrorAction SilentlyContinue
|
||||
if ($proc) {
|
||||
Write-Host "Stopping listener PID $($proc.Id) ($($proc.ProcessName))..."
|
||||
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
# Also kill any orphan media-server.exe instances that didn't bind the port.
|
||||
$orphans = Get-Process -Name 'media-server' -ErrorAction SilentlyContinue
|
||||
foreach ($p in $orphans) {
|
||||
Write-Host "Stopping orphan media-server PID $($p.Id)..."
|
||||
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
if ($listenerPids -or $orphans) {
|
||||
# Allow the OS to release the listen socket from TIME_WAIT.
|
||||
Start-Sleep -Seconds 3
|
||||
}
|
||||
|
||||
# --- Start the chosen flavour ------------------------------------------------
|
||||
if ($Mode -eq 'installer') {
|
||||
if (-not (Test-Path $InstallerLauncher)) {
|
||||
Write-Error "Installer launcher not found: $InstallerLauncher"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Starting installer build: $InstallerLauncher"
|
||||
Start-Process -FilePath $InstallerLauncher `
|
||||
-WorkingDirectory $InstallerDir `
|
||||
-WindowStyle Hidden
|
||||
} else {
|
||||
# Merge registry PATH so newly-installed dev tools are visible.
|
||||
$regUser = [Environment]::GetEnvironmentVariable('PATH', 'User')
|
||||
if ($regUser) {
|
||||
$currentDirs = $env:PATH -split ';' | ForEach-Object { $_.TrimEnd('\') }
|
||||
foreach ($dir in ($regUser -split ';')) {
|
||||
if ($dir -and ($currentDirs -notcontains $dir.TrimEnd('\'))) {
|
||||
$env:PATH = "$env:PATH;$dir"
|
||||
}
|
||||
}
|
||||
}
|
||||
Write-Host "Starting dev install (PATH media-server)..."
|
||||
Start-Process -FilePath 'media-server' `
|
||||
-WorkingDirectory 'c:\Users\Alexei\Documents\haos-integration-media-player\media-server' `
|
||||
-WindowStyle Hidden
|
||||
}
|
||||
|
||||
Start-Sleep -Seconds 3
|
||||
|
||||
# Verify it's running
|
||||
$check = Get-Process -Name 'media-server' -ErrorAction SilentlyContinue
|
||||
if ($check) {
|
||||
Write-Host "Server started (PID $($check[0].Id))"
|
||||
# --- Verify it's listening ---------------------------------------------------
|
||||
$verify = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue
|
||||
if ($verify) {
|
||||
$vpid = $verify[0].OwningProcess
|
||||
$vproc = Get-Process -Id $vpid -ErrorAction SilentlyContinue
|
||||
Write-Host "Server listening on port $Port (PID $vpid, $($vproc.ProcessName))"
|
||||
} else {
|
||||
Write-Host "WARNING: Server does not appear to be running!"
|
||||
Write-Warning "Server is not listening on port $Port yet - check logs."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Tests for AudioAnalyzer.
|
||||
|
||||
Covers the pure-Python pieces that don't need real audio hardware:
|
||||
- Logarithmic FFT bin edge layout
|
||||
- Slow-AGC envelope follower (attack vs release behaviour)
|
||||
- Lifecycle reset of the AGC reference on start()
|
||||
|
||||
Tests are skipped when numpy isn't installed in the host environment
|
||||
so they don't block CI on a minimal interpreter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from media_server.services.audio_analyzer import AudioAnalyzer, _load_numpy
|
||||
|
||||
np = _load_numpy()
|
||||
needs_numpy = pytest.mark.skipif(np is None, reason="numpy not available")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def analyzer() -> AudioAnalyzer:
|
||||
return AudioAnalyzer(num_bins=16, sample_rate=44100, chunk_size=1024)
|
||||
|
||||
|
||||
# ── _compute_bin_edges ────────────────────────────────────────────
|
||||
|
||||
|
||||
@needs_numpy
|
||||
def test_bin_edges_count_matches_num_bins_plus_one(analyzer: AudioAnalyzer) -> None:
|
||||
edges = analyzer._compute_bin_edges()
|
||||
assert len(edges) == analyzer.num_bins + 1
|
||||
|
||||
|
||||
@needs_numpy
|
||||
def test_bin_edges_are_monotonic_non_decreasing(analyzer: AudioAnalyzer) -> None:
|
||||
edges = analyzer._compute_bin_edges()
|
||||
assert all(edges[i] <= edges[i + 1] for i in range(len(edges) - 1))
|
||||
|
||||
|
||||
@needs_numpy
|
||||
def test_bin_edges_stay_within_fft_size(analyzer: AudioAnalyzer) -> None:
|
||||
edges = analyzer._compute_bin_edges()
|
||||
fft_size = analyzer.chunk_size // 2 + 1
|
||||
assert max(edges) <= fft_size - 1
|
||||
assert min(edges) >= 0
|
||||
|
||||
|
||||
# ── AGC envelope follower (the new behaviour) ─────────────────────
|
||||
|
||||
|
||||
def _step_envelope(analyzer: AudioAnalyzer, peak: float) -> float:
|
||||
"""Run one frame of the AGC update with a known peak value.
|
||||
|
||||
Mirrors the math inside _capture_loop without spinning up a real
|
||||
capture thread or requiring numpy: pure Python on a single float.
|
||||
"""
|
||||
if peak > analyzer._spectrum_ref:
|
||||
analyzer._spectrum_ref += (peak - analyzer._spectrum_ref) * 0.05
|
||||
else:
|
||||
analyzer._spectrum_ref += (peak - analyzer._spectrum_ref) * 0.005
|
||||
return analyzer._spectrum_ref
|
||||
|
||||
|
||||
def test_agc_initial_reference_is_quiet(analyzer: AudioAnalyzer) -> None:
|
||||
assert analyzer._spectrum_ref == pytest.approx(0.01)
|
||||
|
||||
|
||||
def test_agc_attacks_quickly_toward_loud_signal(analyzer: AudioAnalyzer) -> None:
|
||||
# Drive 30 frames of a loud signal; reference should climb sharply.
|
||||
for _ in range(30):
|
||||
_step_envelope(analyzer, peak=1.0)
|
||||
# 30 frames of attack=0.05 brings (1 - 0.99^30) ≈ 0.78 of the way to 1.0.
|
||||
assert analyzer._spectrum_ref > 0.5
|
||||
assert analyzer._spectrum_ref < 1.0
|
||||
|
||||
|
||||
def test_agc_releases_slowly_toward_quiet_signal(analyzer: AudioAnalyzer) -> None:
|
||||
analyzer._spectrum_ref = 1.0
|
||||
for _ in range(30):
|
||||
_step_envelope(analyzer, peak=0.0)
|
||||
# Release coefficient is 0.005 — after 30 frames we should have shed
|
||||
# only ~14% of the headroom, not snap back to silent.
|
||||
assert analyzer._spectrum_ref > 0.7
|
||||
assert analyzer._spectrum_ref < 1.0
|
||||
|
||||
|
||||
def test_agc_is_asymmetric_attack_faster_than_release(analyzer: AudioAnalyzer) -> None:
|
||||
a = AudioAnalyzer()
|
||||
b = AudioAnalyzer()
|
||||
a._spectrum_ref = 0.5
|
||||
b._spectrum_ref = 0.5
|
||||
# One attack frame toward 1.0
|
||||
_step_envelope(a, peak=1.0)
|
||||
# One release frame toward 0.0 (same magnitude of error: 0.5)
|
||||
_step_envelope(b, peak=0.0)
|
||||
attack_delta = a._spectrum_ref - 0.5
|
||||
release_delta = 0.5 - b._spectrum_ref
|
||||
# Attack coefficient (0.05) is 10× the release coefficient (0.005).
|
||||
assert attack_delta == pytest.approx(release_delta * 10, rel=1e-6)
|
||||
|
||||
|
||||
# ── start() lifecycle reset ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_start_resets_spectrum_ref_when_unavailable(
|
||||
monkeypatch: pytest.MonkeyPatch, analyzer: AudioAnalyzer
|
||||
) -> None:
|
||||
"""Even when start() returns False (no hardware), the AGC state
|
||||
should remain at the documented quiet baseline."""
|
||||
# Force unavailable so start() short-circuits without spawning a thread.
|
||||
monkeypatch.setattr(
|
||||
AudioAnalyzer, "available", property(lambda self: False)
|
||||
)
|
||||
analyzer._spectrum_ref = 0.95 # leftover from prior session
|
||||
started = analyzer.start()
|
||||
assert started is False
|
||||
# start() returned early before the reset — by design (no capture
|
||||
# means no need to renormalize). Document the contract.
|
||||
assert analyzer._spectrum_ref == 0.95
|
||||
|
||||
|
||||
def test_start_resets_spectrum_ref_when_available(
|
||||
monkeypatch: pytest.MonkeyPatch, analyzer: AudioAnalyzer
|
||||
) -> None:
|
||||
"""When capture actually starts, leftover AGC state from a prior
|
||||
session must be cleared so the first transients don't clip."""
|
||||
monkeypatch.setattr(
|
||||
AudioAnalyzer, "available", property(lambda self: True)
|
||||
)
|
||||
# Stub out the thread so we don't actually spin up a capture loop.
|
||||
monkeypatch.setattr(
|
||||
"media_server.services.audio_analyzer.threading.Thread",
|
||||
lambda *a, **kw: type("T", (), {"start": lambda self: None})(),
|
||||
)
|
||||
analyzer._spectrum_ref = 0.95 # leftover from prior session
|
||||
try:
|
||||
started = analyzer.start()
|
||||
assert started is True
|
||||
assert analyzer._spectrum_ref == pytest.approx(0.01)
|
||||
finally:
|
||||
analyzer._running = False
|
||||
|
||||
|
||||
# ── get_frequency_data thread-safe contract ───────────────────────
|
||||
|
||||
|
||||
def test_get_frequency_data_returns_none_before_capture(
|
||||
analyzer: AudioAnalyzer,
|
||||
) -> None:
|
||||
assert analyzer.get_frequency_data() is None
|
||||
@@ -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"])
|
||||
@@ -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")
|
||||
@@ -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)}"
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Smoke tests for the foreground tracker.
|
||||
|
||||
The OS-specific probe code is hard to mock end-to-end inside a CI container,
|
||||
so these tests focus on the platform-agnostic surface: the dataclass shape,
|
||||
TTL caching, and graceful fallback when the platform probe raises. The
|
||||
Windows/Linux/macOS probes themselves are exercised through manual runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from media_server.services import foreground_service as fg
|
||||
|
||||
|
||||
def setup_function(_):
|
||||
fg.reset_cache()
|
||||
|
||||
|
||||
def test_unavailable_default_shape():
|
||||
info = fg.ForegroundInfo(available=False)
|
||||
d = info.to_dict()
|
||||
assert d["available"] is False
|
||||
assert d["pid"] is None
|
||||
assert d["process_name"] is None
|
||||
assert d["is_fullscreen"] is False
|
||||
assert "platform" in d
|
||||
|
||||
|
||||
def test_cache_returns_same_instance(monkeypatch):
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_probe():
|
||||
calls["n"] += 1
|
||||
return fg.ForegroundInfo(available=True, pid=42, process_name="x.exe")
|
||||
|
||||
monkeypatch.setattr(fg, "_probe", fake_probe)
|
||||
|
||||
a = fg.get_foreground_info()
|
||||
b = fg.get_foreground_info()
|
||||
assert a is b
|
||||
assert calls["n"] == 1
|
||||
|
||||
|
||||
def test_cache_force_refresh(monkeypatch):
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_probe():
|
||||
calls["n"] += 1
|
||||
return fg.ForegroundInfo(available=True, pid=calls["n"])
|
||||
|
||||
monkeypatch.setattr(fg, "_probe", fake_probe)
|
||||
|
||||
fg.get_foreground_info()
|
||||
fg.get_foreground_info(force_refresh=True)
|
||||
assert calls["n"] == 2
|
||||
|
||||
|
||||
def test_cache_ttl_expiry(monkeypatch):
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_probe():
|
||||
calls["n"] += 1
|
||||
return fg.ForegroundInfo(available=True, pid=calls["n"])
|
||||
|
||||
monkeypatch.setattr(fg, "_probe", fake_probe)
|
||||
monkeypatch.setattr(fg, "_CACHE_TTL", 0.0)
|
||||
# Re-bind the cache's TTL by exercising it twice with TTL 0.
|
||||
fg.get_foreground_info()
|
||||
fg.get_foreground_info()
|
||||
assert calls["n"] == 2
|
||||
|
||||
|
||||
def test_probe_crash_returns_unavailable(monkeypatch):
|
||||
def boom():
|
||||
raise RuntimeError("kaboom")
|
||||
|
||||
# Force every platform branch to call our crashing probe.
|
||||
monkeypatch.setattr(fg, "_probe_windows", boom)
|
||||
monkeypatch.setattr(fg, "_probe_linux", boom)
|
||||
monkeypatch.setattr(fg, "_probe_macos", boom)
|
||||
|
||||
info = fg._probe()
|
||||
assert info.available is False
|
||||
assert info.error and "kaboom" in info.error
|
||||
@@ -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}")
|
||||
@@ -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]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user