Compare commits
30 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 |
@@ -60,6 +60,16 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Install native deps for dbus-python + PyGObject
|
||||||
|
run: |
|
||||||
|
# PyGObject >= 3.52 builds against girepository-2.0 (merged into
|
||||||
|
# GLib 2.80), not the old standalone girepository-1.0. ubuntu-latest
|
||||||
|
# (24.04) ships it as libgirepository-2.0-dev.
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends \
|
||||||
|
libdbus-1-dev libglib2.0-dev pkg-config \
|
||||||
|
libcairo2-dev libgirepository-2.0-dev
|
||||||
|
|
||||||
- name: Build Linux distribution
|
- name: Build Linux distribution
|
||||||
run: |
|
run: |
|
||||||
chmod +x build-dist-linux.sh
|
chmod +x build-dist-linux.sh
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ jobs:
|
|||||||
| Windows (portable) | \`MediaServer-{tag}-win-x64.zip\` |
|
| Windows (portable) | \`MediaServer-{tag}-win-x64.zip\` |
|
||||||
| Linux | \`MediaServer-{tag}-linux-x64.tar.gz\` |
|
| Linux | \`MediaServer-{tag}-linux-x64.tar.gz\` |
|
||||||
''').strip())
|
''').strip())
|
||||||
|
# TODO(macos-runner): re-add the macOS rows below once a macOS
|
||||||
|
# runner is connected to Gitea and the build-macos job is re-enabled.
|
||||||
|
# | macOS (Apple Silicon) | \`MediaServer-{tag}-macos-arm64.tar.gz\` |
|
||||||
|
# | macOS (Intel) | \`MediaServer-{tag}-macos-x86_64.tar.gz\`
|
||||||
|
|
||||||
print(json.dumps('\n\n'.join(sections)))
|
print(json.dumps('\n\n'.join(sections)))
|
||||||
")
|
")
|
||||||
@@ -187,6 +191,16 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Install native deps for dbus-python + PyGObject
|
||||||
|
run: |
|
||||||
|
# PyGObject >= 3.52 builds against girepository-2.0 (merged into
|
||||||
|
# GLib 2.80), not the old standalone girepository-1.0. ubuntu-latest
|
||||||
|
# (24.04) ships it as libgirepository-2.0-dev.
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends \
|
||||||
|
libdbus-1-dev libglib2.0-dev pkg-config \
|
||||||
|
libcairo2-dev libgirepository-2.0-dev
|
||||||
|
|
||||||
- name: Build Linux distribution
|
- name: Build Linux distribution
|
||||||
run: |
|
run: |
|
||||||
chmod +x build-dist-linux.sh
|
chmod +x build-dist-linux.sh
|
||||||
@@ -226,3 +240,68 @@ jobs:
|
|||||||
-H "Authorization: token $DEPLOY_TOKEN" \
|
-H "Authorization: token $DEPLOY_TOKEN" \
|
||||||
-H "Content-Type: application/octet-stream" \
|
-H "Content-Type: application/octet-stream" \
|
||||||
--data-binary "@$FILE"
|
--data-binary "@$FILE"
|
||||||
|
|
||||||
|
# --- Build macOS tarball (best-effort; requires a macos runner) ---
|
||||||
|
# PyObjC wheels are macOS-only, so this job must run on a real Mac.
|
||||||
|
#
|
||||||
|
# TODO(macos-runner): Temporarily disabled via `if: false` because the
|
||||||
|
# Gitea instance currently has no macOS runner attached. To re-enable:
|
||||||
|
# 1. Connect a macOS runner to Gitea
|
||||||
|
# 2. Delete the `if: false` line below
|
||||||
|
# 3. Restore the macOS rows in the Downloads table generated by the
|
||||||
|
# create-release job (search for the matching TODO(macos-runner)).
|
||||||
|
build-macos:
|
||||||
|
needs: create-release
|
||||||
|
if: false
|
||||||
|
runs-on: macos-latest
|
||||||
|
continue-on-error: true
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
run: npm ci && npm run build
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Build macOS distribution
|
||||||
|
run: |
|
||||||
|
chmod +x build-dist-macos.sh
|
||||||
|
./build-dist-macos.sh "${{ gitea.ref_name }}"
|
||||||
|
|
||||||
|
- name: Upload assets to release
|
||||||
|
env:
|
||||||
|
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
|
||||||
|
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
||||||
|
|
||||||
|
FILE=$(ls build/MediaServer-*-macos-*.tar.gz | head -1)
|
||||||
|
NAME=$(basename "$FILE")
|
||||||
|
|
||||||
|
EXISTING=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
|
||||||
|
-H "Authorization: token $DEPLOY_TOKEN")
|
||||||
|
ASSET_ID=$(echo "$EXISTING" | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
assets = json.load(sys.stdin)
|
||||||
|
for a in assets:
|
||||||
|
if a['name'] == '$NAME':
|
||||||
|
print(a['id'])
|
||||||
|
break
|
||||||
|
" 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ -n "$ASSET_ID" ]; then
|
||||||
|
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$ASSET_ID" \
|
||||||
|
-H "Authorization: token $DEPLOY_TOKEN"
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -s -X POST \
|
||||||
|
"$BASE_URL/releases/$RELEASE_ID/assets?name=$NAME" \
|
||||||
|
-H "Authorization: token $DEPLOY_TOKEN" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary "@$FILE"
|
||||||
|
|||||||
@@ -34,3 +34,73 @@ jobs:
|
|||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: pytest --tb=short -q || test $? -eq 5
|
run: pytest --tb=short -q || test $? -eq 5
|
||||||
|
|
||||||
|
# Linux smoke test: install the linux extra in the same way build-dist-linux.sh
|
||||||
|
# does, then boot the server and hit /api/health. Catches dependency-resolution
|
||||||
|
# and import-time regressions for the Linux distribution path.
|
||||||
|
linux-smoke:
|
||||||
|
if: "!startsWith(github.event.head_commit.message, 'chore: release')"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
run: npm ci && npm run build
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Install Linux system deps for dbus-python + PyGObject
|
||||||
|
run: |
|
||||||
|
# PyGObject >= 3.52 builds against girepository-2.0 (merged into
|
||||||
|
# GLib 2.80), not the old standalone girepository-1.0. ubuntu-latest
|
||||||
|
# (24.04) ships it as libgirepository-2.0-dev.
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends \
|
||||||
|
libdbus-1-dev libglib2.0-dev pkg-config \
|
||||||
|
libcairo2-dev libgirepository-2.0-dev
|
||||||
|
|
||||||
|
- name: Install with linux extra
|
||||||
|
run: |
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install ".[linux]"
|
||||||
|
|
||||||
|
- name: Smoke — server boots and /api/health responds
|
||||||
|
run: |
|
||||||
|
# Headless Linux runners have no PulseAudio and no display
|
||||||
|
# server, so we disable the visualizer + update checker, and we
|
||||||
|
# use `dbus-run-session` to give LinuxMediaController a real
|
||||||
|
# session bus to talk to (otherwise dbus.SessionBus() would
|
||||||
|
# raise during startup). This isn't a full MPRIS integration
|
||||||
|
# test — it only proves the dispatcher selects the Linux
|
||||||
|
# controller, all imports resolve, and /api/health returns 200.
|
||||||
|
sudo apt-get install -y --no-install-recommends dbus-x11
|
||||||
|
export MEDIA_SERVER_VISUALIZER_ENABLED=false
|
||||||
|
export MEDIA_SERVER_UPDATE_CHECK_ENABLED=false
|
||||||
|
dbus-run-session -- bash -c '
|
||||||
|
# First run writes a default config (random token) and exits 0
|
||||||
|
# instead of serving, so the server is never left running in
|
||||||
|
# insecure no-auth mode. Run once to seed the config; the real
|
||||||
|
# launch below then finds it and actually boots. /api/health needs
|
||||||
|
# no auth, so the generated token is irrelevant here.
|
||||||
|
python -m media_server.main --no-tray --port 18765 || true
|
||||||
|
python -m media_server.main --no-tray --port 18765 &
|
||||||
|
SERVER_PID=$!
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -sf "http://127.0.0.1:18765/api/health" >/dev/null; then
|
||||||
|
echo "Health check passed"
|
||||||
|
kill $SERVER_PID
|
||||||
|
wait $SERVER_PID 2>/dev/null || true
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
echo "Server did not respond within 15s"
|
||||||
|
kill $SERVER_PID 2>/dev/null || true
|
||||||
|
exit 1
|
||||||
|
'
|
||||||
|
|||||||
@@ -53,3 +53,5 @@ Thumbs.db
|
|||||||
# Node.js / esbuild
|
# Node.js / esbuild
|
||||||
node_modules/
|
node_modules/
|
||||||
media_server/static/dist/
|
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.**
|
- **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`
|
- 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
|
## Installation
|
||||||
|
|
||||||
|
Dependencies are declared in `pyproject.toml`. Pick the extra that matches
|
||||||
|
your OS — the Python deps differ enough between Windows / Linux / macOS
|
||||||
|
that there's no single `pip install` line.
|
||||||
|
|
||||||
### Installing on Windows
|
### Installing on Windows
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -r requirements.txt
|
pip install ".[windows]"
|
||||||
```
|
```
|
||||||
|
|
||||||
Required packages: `winsdk`, `pywin32`, `pycaw`, `comtypes`
|
Pulls in `winsdk`, `pywin32`, `pycaw`, `comtypes`, `pystray`, etc.
|
||||||
|
|
||||||
### Installing on Linux
|
### Installing on Linux
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install system dependencies
|
# System packages required to build dbus-python + PyGObject from sdist.
|
||||||
sudo apt-get install python3-dbus python3-gi libdbus-1-dev libglib2.0-dev
|
sudo apt-get install -y python3-pip python3-venv \
|
||||||
|
libdbus-1-dev libglib2.0-dev pkg-config
|
||||||
|
|
||||||
pip install -r requirements.txt
|
pip install ".[linux]"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Installing on macOS
|
### Installing on macOS
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -r requirements.txt
|
pip install ".[macos]"
|
||||||
```
|
```
|
||||||
|
|
||||||
No additional dependencies - uses built-in `osascript`.
|
Pulls in `pyobjc-framework-Cocoa` + `pyobjc-framework-Quartz` for the
|
||||||
|
foreground-window probe; AppleScript-based media control uses the
|
||||||
|
built-in `osascript`.
|
||||||
|
|
||||||
### Installing on Android (Termux)
|
### Installing on Android (Termux)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# In Termux
|
# In Termux
|
||||||
pkg install python termux-api
|
pkg install python termux-api
|
||||||
pip install -r requirements.txt
|
pip install "."
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires Termux and Termux:API apps from F-Droid.
|
Requires Termux and Termux:API apps from F-Droid.
|
||||||
@@ -835,11 +842,19 @@ Install:
|
|||||||
sudo ./service/install_linux.sh install
|
sudo ./service/install_linux.sh install
|
||||||
```
|
```
|
||||||
|
|
||||||
Enable and start for your user:
|
**Enable user lingering** — required so `/run/user/$UID/bus` (the D-Bus
|
||||||
|
session socket needed for MPRIS) exists even when no graphical session
|
||||||
|
is active. Without this the server boots but every `/api/media/*` call
|
||||||
|
silently returns idle.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl enable media-server@$USER
|
sudo loginctl enable-linger $USER
|
||||||
sudo systemctl start media-server@$USER
|
```
|
||||||
|
|
||||||
|
Enable and start the templated unit for your user:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl enable --now media-server@$USER
|
||||||
```
|
```
|
||||||
|
|
||||||
View logs:
|
View logs:
|
||||||
@@ -848,6 +863,39 @@ View logs:
|
|||||||
journalctl -u media-server@$USER -f
|
journalctl -u media-server@$USER -f
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Troubleshooting:**
|
||||||
|
|
||||||
|
- *"`/api/media/status` always returns `idle`"* — check the service log for
|
||||||
|
`D-Bus session bus not available`. Most commonly: lingering isn't
|
||||||
|
enabled, or the unit is using the wrong `XDG_RUNTIME_DIR` (`%U` must
|
||||||
|
expand to the user's numeric UID).
|
||||||
|
- *"Visualizer permanently unavailable"* — PulseAudio/PipeWire must
|
||||||
|
expose monitor sources. `pactl list sources short | grep monitor`
|
||||||
|
should list at least one entry; if not, install `pipewire-pulse` and
|
||||||
|
restart your session.
|
||||||
|
- *"Volume control silently fails"* — `pactl` must be on `PATH` and the
|
||||||
|
user's PulseAudio/PipeWire server must be reachable
|
||||||
|
(`PULSE_RUNTIME_PATH=/run/user/$UID/pulse`).
|
||||||
|
- *"Foreground window is always `null`"* — expected under Wayland; the
|
||||||
|
compositor hides window info from unprivileged clients. X11 sessions
|
||||||
|
work normally.
|
||||||
|
|
||||||
|
### macOS (LaunchAgent)
|
||||||
|
|
||||||
|
The distribution tarball ships an installer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./install-launchagent.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This drops `~/Library/LaunchAgents/com.dolgolyov.media-server.plist`,
|
||||||
|
starts the service immediately, and re-launches it at every login. Logs
|
||||||
|
go to `~/Library/Logs/media-server/{stdout,stderr}.log`. To stop:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./uninstall-launchagent.sh
|
||||||
|
```
|
||||||
|
|
||||||
## Command Line Options
|
## Command Line Options
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
+32
-16
@@ -1,24 +1,42 @@
|
|||||||
## v0.2.1 (2026-04-25)
|
## v0.4.0 (2026-05-28)
|
||||||
|
|
||||||
A small polish release on top of the Studio Reference redesign — accent picker fix,
|
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.
|
||||||
visualizer performance work, and a couple of layout refinements for tablet and
|
|
||||||
small-desktop widths.
|
### Features
|
||||||
|
|
||||||
|
- **Linux support, production-ready.** New `linux` extra (`dbus-python`, `PyGObject`, `python-xlib`) plus a `build-dist-linux.sh` that emits a portable tarball and a systemd user unit. The unit sets `DBUS_SESSION_BUS_ADDRESS`, `XDG_RUNTIME_DIR`, and `ReadWritePaths` for `~/.config` / `~/.cache` so MPRIS works and audit-log / thumbnail writes aren't blocked by `ProtectHome`. The Linux MPRIS controller now connects to the session bus lazily — a missing or late bus no longer crashes lifespan startup, and the user is logged a one-line hint about `loginctl enable-linger`. ([ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c))
|
||||||
|
- **macOS support.** New `macos` extra (`pyobjc-framework-Cocoa`, `pyobjc-framework-Quartz`), a `build-dist-macos.sh` script, and a per-user LaunchAgent installer producing `MediaServer-vX.Y-macos-{arm64,x86_64}.tar.gz` artifacts. Spotify URL artwork is wired through `MediaController.get_album_art()`. ([ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c))
|
||||||
|
- **Cross-platform album artwork.** `MediaController.get_album_art()` is now abstract with Linux (`mpris:artUrl`, `file://` + `http(s)://`) and macOS (Spotify URL) implementations; the `/api/media/artwork` endpoint awaits the controller. ([ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c))
|
||||||
|
- **Per-OS unavailable reasons on `/api/media/visualizer/status`** so the Web UI can explain *why* the visualizer is off on Linux / macOS instead of just hiding it. ([ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c))
|
||||||
|
- **Startup preflight on Linux** warns when `DBUS_SESSION_BUS_ADDRESS` or `XDG_RUNTIME_DIR` is unset and informs the user when running under Wayland disables the foreground-window probe — so silent loss of features is now diagnosable from the log. ([ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c))
|
||||||
|
- **Redesigned app icon ("Beacon").** Replaces the generic Spotify-green circle with a refined squircle + deep-teal diagonal gradient (`#0B3D3B → #1A6B5E`) + warm parchment play triangle (`#F5F1E8`) with a drop shadow, top sheen, and ghosted echo-chevrons that hint at broadcast/stream. ([d798fed](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d798fed))
|
||||||
|
- **Multi-resolution Windows ICO.** `icon.ico` grew from a single 16×16 frame (208 B) to a 10-frame ICO (16/20/24/32/40/48/64/96/128/256 — ~37 KB) so Windows no longer upscales 16×16 into mush for the installer chrome, Start Menu, desktop shortcuts, Alt+Tab, and File Explorer tiles. ([d798fed](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d798fed))
|
||||||
|
- **System tray uses the new icon.** `tray.py` now picks a 64×64 frame from the multi-res ICO; the procedural fallback was reskinned to the same Beacon palette so a missing ICO no longer regresses the tray back to the old Spotify-green circle. ([d798fed](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d798fed))
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
- **Accent picker** now wired to the editorial copper palette + visual polish ([f4be2bd](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/f4be2bd))
|
|
||||||
|
|
||||||
### Performance
|
- **`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))
|
||||||
- **Visualizer** — significant CPU cuts on spectrum rendering and track switches ([51ec150](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/51ec150))
|
- **`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))
|
||||||
|
|
||||||
### UI Improvements
|
|
||||||
- Meaningful caps for tablet / small-desktop range + tighter footer ([25a492d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/25a492d))
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Development / Internal
|
### Development / Internal
|
||||||
|
|
||||||
#### CI/Build
|
#### Build & Packaging
|
||||||
- Skip test workflow on release commits ([08c3c80](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/08c3c80))
|
|
||||||
|
- **`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))
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -27,9 +45,7 @@ small-desktop widths.
|
|||||||
|
|
||||||
| Hash | Message | Author |
|
| Hash | Message | Author |
|
||||||
|------|---------|--------|
|
|------|---------|--------|
|
||||||
| [25a492d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/25a492d) | ui(player): meaningful caps for tablet/small-desktop range + tighter footer | 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 |
|
||||||
| [f4be2bd](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/f4be2bd) | fix(player): wire accent picker to editorial copper palette + visual polish | alexei.dolgolyov |
|
| [ddf4a6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddf4a6c) | feat: production-ready Linux & macOS support | alexei.dolgolyov |
|
||||||
| [51ec150](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/51ec150) | perf(visualizer): cut spectrum + track-switch CPU significantly | alexei.dolgolyov |
|
|
||||||
| [08c3c80](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/08c3c80) | ci: skip test workflow on release commits | alexei.dolgolyov |
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -16,12 +16,38 @@ BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-linux-x64"
|
|||||||
clean_dist "${DIST_DIR}" build
|
clean_dist "${DIST_DIR}" build
|
||||||
verify_frontend
|
verify_frontend
|
||||||
|
|
||||||
|
# --- Sanity check: native deps required for dbus-python + PyGObject ---
|
||||||
|
# These compile from sdist on Linux. We don't try to install them via the
|
||||||
|
# distro package manager here (the CI image must already have them); fail
|
||||||
|
# with a clear message instead of cryptic gcc/pkg-config errors deep
|
||||||
|
# inside pip.
|
||||||
|
need_pkg() {
|
||||||
|
local pkg="$1"
|
||||||
|
if ! command -v pkg-config >/dev/null 2>&1; then
|
||||||
|
echo "ERROR: pkg-config is required (apt: pkg-config / dnf: pkgconf-pkg-config)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! pkg-config --exists "$pkg" 2>/dev/null; then
|
||||||
|
echo "ERROR: $pkg headers missing — install libdbus-1-dev libglib2.0-dev (apt)" >&2
|
||||||
|
echo " or dbus-devel glib2-devel (dnf) before running this script." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
need_pkg dbus-1
|
||||||
|
need_pkg glib-2.0
|
||||||
|
|
||||||
# --- Create self-contained virtualenv ---
|
# --- Create self-contained virtualenv ---
|
||||||
echo "Creating virtualenv..."
|
echo "Creating virtualenv..."
|
||||||
python3 -m venv "${DIST_DIR}/venv"
|
python3 -m venv "${DIST_DIR}/venv"
|
||||||
source "${DIST_DIR}/venv/bin/activate"
|
source "${DIST_DIR}/venv/bin/activate"
|
||||||
pip install --quiet --upgrade pip
|
pip install --quiet --upgrade pip
|
||||||
|
# Install with the linux extra so dbus-python / PyGObject / python-xlib land
|
||||||
|
# in the venv. Falls back to base deps if the extra is unsupported (shouldn't
|
||||||
|
# happen post-0.3.2 but keep the fallback for older tags re-built here).
|
||||||
|
if ! pip install --quiet ".[linux]"; then
|
||||||
|
echo "WARN: '.[linux]' extra unavailable; installing base deps only" >&2
|
||||||
pip install --quiet "."
|
pip install --quiet "."
|
||||||
|
fi
|
||||||
|
|
||||||
# Remove the installed package (app source is on PYTHONPATH via launcher)
|
# Remove the installed package (app source is on PYTHONPATH via launcher)
|
||||||
rm -rf "${DIST_DIR}"/venv/lib/python*/site-packages/media_server*
|
rm -rf "${DIST_DIR}"/venv/lib/python*/site-packages/media_server*
|
||||||
@@ -47,6 +73,11 @@ LAUNCHER
|
|||||||
chmod +x "${DIST_DIR}/media-server.sh"
|
chmod +x "${DIST_DIR}/media-server.sh"
|
||||||
|
|
||||||
# --- Create systemd service installer ---
|
# --- Create systemd service installer ---
|
||||||
|
# Emits a non-templated unit that runs as the invoking user with the env
|
||||||
|
# bits MPRIS / PulseAudio need: DBUS_SESSION_BUS_ADDRESS and XDG_RUNTIME_DIR
|
||||||
|
# must be set or DBus.SessionBus() either fails or talks to the wrong bus.
|
||||||
|
# We also relax ProtectHome to read-write for ~/.config/media-server +
|
||||||
|
# ~/.cache/media-server so audit.log and thumbnail cache writes succeed.
|
||||||
cat > "${DIST_DIR}/install-service.sh" << 'SERVICE'
|
cat > "${DIST_DIR}/install-service.sh" << 'SERVICE'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -61,6 +92,9 @@ if [ "$EUID" -ne 0 ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
REAL_USER="${SUDO_USER:-$USER}"
|
REAL_USER="${SUDO_USER:-$USER}"
|
||||||
|
USER_UID="$(id -u "$REAL_USER")"
|
||||||
|
USER_HOME="$(getent passwd "$REAL_USER" | cut -d: -f6)"
|
||||||
|
RUNTIME_DIR="/run/user/${USER_UID}"
|
||||||
|
|
||||||
cat > "$SERVICE_FILE" << EOF
|
cat > "$SERVICE_FILE" << EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
@@ -74,17 +108,36 @@ WorkingDirectory=${SCRIPT_DIR}
|
|||||||
ExecStart=${SCRIPT_DIR}/media-server.sh
|
ExecStart=${SCRIPT_DIR}/media-server.sh
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
||||||
|
# Required by MPRIS (dbus.SessionBus) and PulseAudio/PipeWire loopback.
|
||||||
Environment=PYTHONUNBUFFERED=1
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
Environment=HOME=${USER_HOME}
|
||||||
|
Environment=XDG_RUNTIME_DIR=${RUNTIME_DIR}
|
||||||
|
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=${RUNTIME_DIR}/bus
|
||||||
|
|
||||||
|
# Light sandboxing — keep ~/.config/media-server + ~/.cache/media-server
|
||||||
|
# writable so audit.log / thumbnails / config CRUD continue to work.
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=read-only
|
||||||
|
ReadWritePaths=${USER_HOME}/.config/media-server ${USER_HOME}/.cache/media-server
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
# Pre-create state dirs so ReadWritePaths actually has something to grant.
|
||||||
|
sudo -u "$REAL_USER" mkdir -p \
|
||||||
|
"${USER_HOME}/.config/media-server" \
|
||||||
|
"${USER_HOME}/.cache/media-server"
|
||||||
|
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable "${SERVICE_NAME}"
|
systemctl enable "${SERVICE_NAME}"
|
||||||
systemctl start "${SERVICE_NAME}"
|
systemctl start "${SERVICE_NAME}"
|
||||||
echo "Service '${SERVICE_NAME}' installed and started."
|
echo "Service '${SERVICE_NAME}' installed and started."
|
||||||
echo "Check status: systemctl status ${SERVICE_NAME}"
|
echo "Check status: systemctl status ${SERVICE_NAME}"
|
||||||
|
echo "Tail logs: journalctl -u ${SERVICE_NAME} -f"
|
||||||
SERVICE
|
SERVICE
|
||||||
chmod +x "${DIST_DIR}/install-service.sh"
|
chmod +x "${DIST_DIR}/install-service.sh"
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
+95
-57
@@ -1,7 +1,13 @@
|
|||||||
# Media Server Configuration
|
# Media Server Configuration
|
||||||
# Copy this file to config.yaml and customize as needed.
|
# 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
|
# API Tokens - Multiple tokens with friendly labels
|
||||||
# This allows you to identify which client is making requests in the logs
|
# This allows you to identify which client is making requests in the logs
|
||||||
@@ -11,111 +17,143 @@
|
|||||||
# web_ui: "your-web-ui-token-here"
|
# web_ui: "your-web-ui-token-here"
|
||||||
|
|
||||||
# Server settings
|
# Server settings
|
||||||
host: "0.0.0.0"
|
host: "127.0.0.1"
|
||||||
port: 8765
|
port: 8765
|
||||||
|
# allow_lan_without_auth: true # uncomment + change host to 0.0.0.0 for LAN-open mode
|
||||||
|
|
||||||
|
# ─── 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:
|
scripts:
|
||||||
lock_screen:
|
# ── Windows ─────────────────────────────────────────────────────────
|
||||||
command: "rundll32.exe user32.dll,LockWorkStation"
|
# lock_screen:
|
||||||
label: "Lock Screen"
|
# command: "rundll32.exe user32.dll,LockWorkStation"
|
||||||
description: "Lock the workstation"
|
# label: "Lock Screen"
|
||||||
icon: "mdi:lock"
|
# description: "Lock the workstation"
|
||||||
timeout: 5
|
# icon: "mdi:lock"
|
||||||
shell: true
|
# timeout: 5
|
||||||
|
# shell: true
|
||||||
|
# sleep:
|
||||||
|
# command: "rundll32.exe powrprof.dll,SetSuspendState 0,1,0"
|
||||||
|
# label: "Sleep"
|
||||||
|
# icon: "mdi:sleep"
|
||||||
|
# timeout: 10
|
||||||
|
# shell: true
|
||||||
|
# shutdown:
|
||||||
|
# command: "shutdown /s /t 0"
|
||||||
|
# label: "Shutdown"
|
||||||
|
# icon: "mdi:power"
|
||||||
|
# timeout: 10
|
||||||
|
# shell: true
|
||||||
|
|
||||||
hibernate:
|
# ── Linux (systemd / xdg) ───────────────────────────────────────────
|
||||||
command: "shutdown /h"
|
# lock_screen:
|
||||||
label: "Hibernate"
|
# command: "loginctl lock-session" # or: xdg-screensaver lock
|
||||||
description: "Hibernate the PC"
|
# label: "Lock Screen"
|
||||||
icon: "mdi:power-sleep"
|
# icon: "mdi:lock"
|
||||||
|
# timeout: 5
|
||||||
|
# shell: true
|
||||||
|
# sleep:
|
||||||
|
# command: "systemctl suspend"
|
||||||
|
# label: "Sleep"
|
||||||
|
# icon: "mdi:sleep"
|
||||||
|
# timeout: 10
|
||||||
|
# shell: true
|
||||||
|
# shutdown:
|
||||||
|
# command: "systemctl poweroff"
|
||||||
|
# label: "Shutdown"
|
||||||
|
# icon: "mdi:power"
|
||||||
|
# timeout: 10
|
||||||
|
# shell: true
|
||||||
|
|
||||||
|
# ── macOS ───────────────────────────────────────────────────────────
|
||||||
|
# lock_screen:
|
||||||
|
# command: "pmset displaysleepnow"
|
||||||
|
# label: "Lock Screen"
|
||||||
|
# icon: "mdi:lock"
|
||||||
|
# timeout: 5
|
||||||
|
# shell: true
|
||||||
|
# sleep:
|
||||||
|
# command: "pmset sleepnow"
|
||||||
|
# label: "Sleep"
|
||||||
|
# icon: "mdi:sleep"
|
||||||
|
# timeout: 5
|
||||||
|
# shell: true
|
||||||
|
# shutdown:
|
||||||
|
# command: "osascript -e 'tell application \"System Events\" to shut down'"
|
||||||
|
# label: "Shutdown"
|
||||||
|
# icon: "mdi:power"
|
||||||
|
# timeout: 10
|
||||||
|
# shell: true
|
||||||
|
|
||||||
|
# Cross-platform smoke test (always defined so first-run users see the UI populate).
|
||||||
|
example_script:
|
||||||
|
command: "echo Hello from Media Server!"
|
||||||
|
description: "Example script - echoes a message"
|
||||||
timeout: 10
|
timeout: 10
|
||||||
shell: true
|
shell: true
|
||||||
|
|
||||||
sleep:
|
# Media folder management from Web UI (default: false)
|
||||||
command: "rundll32.exe powrprof.dll,SetSuspendState 0,1,0"
|
|
||||||
label: "Sleep"
|
|
||||||
description: "Put PC to sleep"
|
|
||||||
icon: "mdi:sleep"
|
|
||||||
timeout: 10
|
|
||||||
shell: true
|
|
||||||
|
|
||||||
shutdown:
|
|
||||||
command: "shutdown /s /t 0"
|
|
||||||
label: "Shutdown"
|
|
||||||
description: "Shutdown the PC immediately"
|
|
||||||
icon: "mdi:power"
|
|
||||||
timeout: 10
|
|
||||||
shell: true
|
|
||||||
|
|
||||||
restart:
|
|
||||||
command: "shutdown /r /t 0"
|
|
||||||
label: "Restart"
|
|
||||||
description: "Restart the PC immediately"
|
|
||||||
icon: "mdi:restart"
|
|
||||||
timeout: 10
|
|
||||||
shell: true
|
|
||||||
|
|
||||||
# Media folder management from Web UI (default: true)
|
|
||||||
# When enabled, media folders can be added, edited, and deleted from the Settings tab.
|
# When enabled, media folders can be added, edited, and deleted from the Settings tab.
|
||||||
# Set to false to disable folder management from the UI.
|
# media_folders_management: true
|
||||||
# media_folders_management: false
|
|
||||||
|
# ─── Callback scripts (executed by integration events) ──────────────────
|
||||||
|
#
|
||||||
|
# Callbacks are OS-agnostic — if you want side effects, fill in commands
|
||||||
|
# below. The defaults are no-op echos so first-run logs don't error out.
|
||||||
|
|
||||||
# Callback scripts (executed after media actions)
|
|
||||||
# All callbacks are optional - if not defined, the action runs without callback
|
|
||||||
callbacks:
|
callbacks:
|
||||||
# Media control callbacks (run after successful action)
|
|
||||||
on_play:
|
on_play:
|
||||||
command: "echo Play triggered"
|
command: "echo Play triggered"
|
||||||
timeout: 10
|
timeout: 10
|
||||||
shell: true
|
shell: true
|
||||||
|
|
||||||
on_pause:
|
on_pause:
|
||||||
command: "echo Pause triggered"
|
command: "echo Pause triggered"
|
||||||
timeout: 10
|
timeout: 10
|
||||||
shell: true
|
shell: true
|
||||||
|
|
||||||
on_stop:
|
on_stop:
|
||||||
command: "echo Stop triggered"
|
command: "echo Stop triggered"
|
||||||
timeout: 10
|
timeout: 10
|
||||||
shell: true
|
shell: true
|
||||||
|
|
||||||
on_next:
|
on_next:
|
||||||
command: "echo Next track"
|
command: "echo Next track"
|
||||||
timeout: 10
|
timeout: 10
|
||||||
shell: true
|
shell: true
|
||||||
|
|
||||||
on_previous:
|
on_previous:
|
||||||
command: "echo Previous track"
|
command: "echo Previous track"
|
||||||
timeout: 10
|
timeout: 10
|
||||||
shell: true
|
shell: true
|
||||||
|
|
||||||
on_volume:
|
on_volume:
|
||||||
command: "echo Volume changed"
|
command: "echo Volume changed"
|
||||||
timeout: 10
|
timeout: 10
|
||||||
shell: true
|
shell: true
|
||||||
|
|
||||||
on_mute:
|
on_mute:
|
||||||
command: "echo Mute toggled"
|
command: "echo Mute toggled"
|
||||||
timeout: 10
|
timeout: 10
|
||||||
shell: true
|
shell: true
|
||||||
|
|
||||||
on_seek:
|
on_seek:
|
||||||
command: "echo Seek triggered"
|
command: "echo Seek triggered"
|
||||||
timeout: 10
|
timeout: 10
|
||||||
shell: true
|
shell: true
|
||||||
|
|
||||||
# Turn on/off/toggle (callback-only actions, no default behavior)
|
# Turn on/off/toggle (callback-only actions, no default behavior).
|
||||||
|
# The Windows-only example used to ship as the default for `on_turn_off`,
|
||||||
|
# which silently failed on Linux/macOS. Defaults are now no-ops — pick a
|
||||||
|
# platform-appropriate command below.
|
||||||
on_turn_on:
|
on_turn_on:
|
||||||
command: "echo Turn on callback"
|
command: "echo Turn on callback"
|
||||||
timeout: 10
|
timeout: 10
|
||||||
shell: true
|
shell: true
|
||||||
|
|
||||||
on_turn_off:
|
on_turn_off:
|
||||||
command: "rundll32.exe user32.dll,LockWorkStation"
|
# Windows: "rundll32.exe user32.dll,LockWorkStation"
|
||||||
|
# Linux: "loginctl lock-session"
|
||||||
|
# macOS: "pmset displaysleepnow"
|
||||||
|
command: "echo Turn off callback"
|
||||||
timeout: 5
|
timeout: 5
|
||||||
shell: true
|
shell: true
|
||||||
|
|
||||||
on_toggle:
|
on_toggle:
|
||||||
command: "echo Toggle callback"
|
command: "echo Toggle callback"
|
||||||
timeout: 10
|
timeout: 10
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
"""Media Server - REST API for controlling system media playback."""
|
"""Media Server - REST API for controlling system media playback."""
|
||||||
|
|
||||||
|
import re
|
||||||
from importlib.metadata import PackageNotFoundError, version
|
from importlib.metadata import PackageNotFoundError, version
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
_VERSION_RE = re.compile(r'^version\s*=\s*"([^"]+)"', re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
def _detect_version() -> str:
|
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:
|
try:
|
||||||
return version("media-server")
|
return version("media-server")
|
||||||
except PackageNotFoundError:
|
except PackageNotFoundError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 2. VERSION file written by build scripts (production builds)
|
# 3. VERSION file written by build scripts (production builds).
|
||||||
# Located at install root, two levels up from this package
|
|
||||||
version_file = Path(__file__).resolve().parent.parent.parent / "VERSION"
|
version_file = Path(__file__).resolve().parent.parent.parent / "VERSION"
|
||||||
if version_file.is_file():
|
if version_file.is_file():
|
||||||
return version_file.read_text().strip()
|
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
|
# Context variable to store current request's token label
|
||||||
token_label_var: ContextVar[str] = ContextVar("token_label", default="unknown")
|
token_label_var: ContextVar[str] = ContextVar("token_label", default="unknown")
|
||||||
|
# Per-request correlation ID — generated in middleware if upstream didn't send one.
|
||||||
|
request_id_var: ContextVar[str] = ContextVar("request_id", default="-")
|
||||||
|
|
||||||
|
|
||||||
def auth_enabled() -> bool:
|
def auth_enabled() -> bool:
|
||||||
@@ -29,12 +31,42 @@ def get_token_label(token: str) -> Optional[str]:
|
|||||||
Returns:
|
Returns:
|
||||||
The label for the token, or None if invalid
|
The label for the token, or None if invalid
|
||||||
"""
|
"""
|
||||||
for label, stored_token in settings.api_tokens.items():
|
for label, spec in settings.api_tokens.items():
|
||||||
if secrets.compare_digest(stored_token, token):
|
if secrets.compare_digest(spec.token, token):
|
||||||
return label
|
return label
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def token_has_scope(label: str, required: str) -> bool:
|
||||||
|
"""Whether the token identified by `label` grants `required` scope."""
|
||||||
|
spec = settings.api_tokens.get(label)
|
||||||
|
if spec is None:
|
||||||
|
# Unknown label = no auth or anonymous; treat as full access only
|
||||||
|
# when auth is disabled entirely (matches existing behaviour).
|
||||||
|
return not auth_enabled()
|
||||||
|
return spec.grants(required)
|
||||||
|
|
||||||
|
|
||||||
|
def require_scope(scope: str):
|
||||||
|
"""Build a FastAPI dependency that enforces the given scope.
|
||||||
|
|
||||||
|
Use as ``Depends(require_scope("admin"))`` on management endpoints. When
|
||||||
|
auth is disabled the dependency is a no-op (anonymous access).
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _checker(label: str = Depends(verify_token)) -> str:
|
||||||
|
if not auth_enabled():
|
||||||
|
return label
|
||||||
|
if not token_has_scope(label, scope):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Token '{label}' lacks required scope: {scope}",
|
||||||
|
)
|
||||||
|
return label
|
||||||
|
|
||||||
|
return _checker
|
||||||
|
|
||||||
|
|
||||||
async def verify_token(
|
async def verify_token(
|
||||||
request: Request,
|
request: Request,
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
|||||||
+223
-30
@@ -1,13 +1,54 @@
|
|||||||
"""Configuration management for the media server."""
|
"""Configuration management for the media server."""
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Token scopes form a strict hierarchy: admin > control > read. Helper utility
|
||||||
|
# used by both auth.py and the validator below.
|
||||||
|
SCOPE_HIERARCHY: dict[str, frozenset[str]] = {
|
||||||
|
"read": frozenset({"read"}),
|
||||||
|
"control": frozenset({"read", "control"}),
|
||||||
|
"admin": frozenset({"read", "control", "admin"}),
|
||||||
|
}
|
||||||
|
ALL_SCOPES: frozenset[str] = frozenset(SCOPE_HIERARCHY.keys())
|
||||||
|
|
||||||
|
|
||||||
|
class TokenSpec(BaseModel):
|
||||||
|
"""Per-token authentication entry with explicit scopes."""
|
||||||
|
|
||||||
|
token: str = Field(..., min_length=8, description="Secret token value")
|
||||||
|
scopes: list[str] = Field(
|
||||||
|
default_factory=lambda: ["admin"],
|
||||||
|
description="Granted scopes (subset of read|control|admin).",
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("scopes")
|
||||||
|
@classmethod
|
||||||
|
def _validate_scopes(cls, v: list[str]) -> list[str]:
|
||||||
|
if not v:
|
||||||
|
raise ValueError("scopes must list at least one of read|control|admin")
|
||||||
|
unknown = set(v) - ALL_SCOPES
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(f"unknown scopes: {sorted(unknown)}; valid={sorted(ALL_SCOPES)}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
def grants(self, required: str) -> bool:
|
||||||
|
"""Whether this token grants the requested scope (with hierarchy expansion)."""
|
||||||
|
granted: set[str] = set()
|
||||||
|
for s in self.scopes:
|
||||||
|
granted |= SCOPE_HIERARCHY.get(s, frozenset({s}))
|
||||||
|
return required in granted
|
||||||
|
|
||||||
|
|
||||||
class MediaFolderConfig(BaseModel):
|
class MediaFolderConfig(BaseModel):
|
||||||
"""Configuration for a media folder."""
|
"""Configuration for a media folder."""
|
||||||
@@ -44,6 +85,13 @@ class ScriptParameterConfig(BaseModel):
|
|||||||
options: Optional[list[str]] = Field(
|
options: Optional[list[str]] = Field(
|
||||||
default=None, description="Allowed values (select type only)"
|
default=None, description="Allowed values (select type only)"
|
||||||
)
|
)
|
||||||
|
pattern: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Optional regex (Python flavour) that string-typed values must match."
|
||||||
|
" Use to harden parameters that flow into shell=true scripts."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ScriptConfig(BaseModel):
|
class ScriptConfig(BaseModel):
|
||||||
@@ -81,14 +129,106 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Server settings
|
# Server settings
|
||||||
host: str = Field(default="0.0.0.0", description="Server bind address")
|
host: str = Field(
|
||||||
port: int = Field(default=8765, description="Server port")
|
default="127.0.0.1",
|
||||||
|
description=(
|
||||||
# Authentication (empty = auth disabled, anyone can access the API)
|
"Server bind address. Use 127.0.0.1 for loopback-only (default, safest),"
|
||||||
api_tokens: dict[str, str] = Field(
|
" or 0.0.0.0 to expose on the LAN (requires api_tokens to be set)."
|
||||||
default_factory=dict,
|
),
|
||||||
description="Named API tokens for access control (label: token pairs). Empty = no auth.",
|
|
||||||
)
|
)
|
||||||
|
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
|
# Media controller settings
|
||||||
poll_interval: float = Field(
|
poll_interval: float = Field(
|
||||||
@@ -125,7 +265,7 @@ class Settings(BaseSettings):
|
|||||||
description="Media folders available for browsing in the media browser",
|
description="Media folders available for browsing in the media browser",
|
||||||
)
|
)
|
||||||
media_folders_management: bool = Field(
|
media_folders_management: bool = Field(
|
||||||
default=True,
|
default=False,
|
||||||
description="Allow adding, editing, and deleting media folders from the Web UI",
|
description="Allow adding, editing, and deleting media folders from the Web UI",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -218,21 +358,28 @@ def get_config_dir() -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def generate_default_config(path: Optional[Path] = None) -> 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:
|
if path is None:
|
||||||
path = get_config_dir() / "config.yaml"
|
path = get_config_dir() / "config.yaml"
|
||||||
|
|
||||||
|
default_token = secrets.token_urlsafe(32)
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
"host": "0.0.0.0",
|
"host": "127.0.0.1",
|
||||||
"port": 8765,
|
"port": 8765,
|
||||||
# "api_tokens": {
|
# Default token grants "admin" scope (full access). To create a
|
||||||
# "default": "your-secret-token-here",
|
# 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,
|
"poll_interval": 1.0,
|
||||||
"log_level": "INFO",
|
"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": {
|
"scripts": {
|
||||||
"example_script": {
|
"example_script": {
|
||||||
"command": "echo Hello from Media Server!",
|
"command": "echo Hello from Media Server!",
|
||||||
@@ -240,26 +387,72 @@ def generate_default_config(path: Optional[Path] = None) -> Path:
|
|||||||
"timeout": 10,
|
"timeout": 10,
|
||||||
"shell": True,
|
"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)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
_write_yaml_atomic(path, config)
|
||||||
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
_restrict_config_perms(path)
|
||||||
|
|
||||||
|
logger.info("Generated default config at %s", path)
|
||||||
|
logger.info("API token (label=default): %s", default_token)
|
||||||
|
|
||||||
return path
|
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
|
# Global settings instance
|
||||||
settings = Settings.load_from_yaml()
|
settings = Settings.load_from_yaml()
|
||||||
|
|||||||
+149
-404
@@ -1,52 +1,50 @@
|
|||||||
"""Thread-safe configuration file manager for runtime script updates."""
|
"""Thread-safe configuration file manager for runtime updates."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
import yaml
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ConfigManager:
|
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):
|
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._lock = threading.Lock()
|
||||||
self._config_path = config_path or self._find_config_path()
|
self._config_path = config_path or self._find_config_path()
|
||||||
logger.info(f"ConfigManager initialized with path: {self._config_path}")
|
logger.info(f"ConfigManager initialized with path: {self._config_path}")
|
||||||
|
|
||||||
def _find_config_path(self) -> Path:
|
@staticmethod
|
||||||
"""Find the active config file path.
|
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:
|
if os.name == "nt":
|
||||||
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
|
|
||||||
appdata = os.environ.get("APPDATA", "")
|
appdata = os.environ.get("APPDATA", "")
|
||||||
if appdata:
|
if appdata:
|
||||||
search_paths.append(Path(appdata) / "media-server" / "config.yaml")
|
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.home() / ".config" / "media-server" / "config.yaml")
|
||||||
search_paths.append(Path("/etc/media-server/config.yaml"))
|
search_paths.append(Path("/etc/media-server/config.yaml"))
|
||||||
|
|
||||||
@@ -54,7 +52,6 @@ class ConfigManager:
|
|||||||
if search_path.exists():
|
if search_path.exists():
|
||||||
return search_path
|
return search_path
|
||||||
|
|
||||||
# If not found, use the default location
|
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
default_path = Path(os.environ.get("APPDATA", "")) / "media-server" / "config.yaml"
|
default_path = Path(os.environ.get("APPDATA", "")) / "media-server" / "config.yaml"
|
||||||
else:
|
else:
|
||||||
@@ -63,422 +60,170 @@ class ConfigManager:
|
|||||||
logger.warning(f"No config file found, using default path: {default_path}")
|
logger.warning(f"No config file found, using default path: {default_path}")
|
||||||
return default_path
|
return default_path
|
||||||
|
|
||||||
def add_script(self, name: str, config: ScriptConfig) -> None:
|
def _load(self) -> dict[str, Any]:
|
||||||
"""Add a new script to config.
|
"""Read the config YAML, returning an empty dict if the file is missing."""
|
||||||
|
|
||||||
Args:
|
|
||||||
name: Script name (must be unique).
|
|
||||||
config: Script configuration.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If script already exists.
|
|
||||||
IOError: If config file cannot be written.
|
|
||||||
"""
|
|
||||||
with self._lock:
|
|
||||||
# Read YAML
|
|
||||||
if not self._config_path.exists():
|
if not self._config_path.exists():
|
||||||
data = {}
|
return {}
|
||||||
else:
|
|
||||||
with open(self._config_path, "r", encoding="utf-8") as f:
|
with open(self._config_path, "r", encoding="utf-8") as f:
|
||||||
data = yaml.safe_load(f) or {}
|
return yaml.safe_load(f) or {}
|
||||||
|
|
||||||
# Check if script already exists
|
def _save(self, data: dict[str, Any]) -> None:
|
||||||
if "scripts" in data and name in data["scripts"]:
|
"""Atomically write the config YAML and lock down its permissions."""
|
||||||
raise ValueError(f"Script '{name}' already exists")
|
|
||||||
|
|
||||||
# Add script
|
|
||||||
if "scripts" not in data:
|
|
||||||
data["scripts"] = {}
|
|
||||||
data["scripts"][name] = config.model_dump(exclude_none=True)
|
|
||||||
|
|
||||||
# Write YAML
|
|
||||||
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with open(self._config_path, "w", encoding="utf-8") as f:
|
_write_yaml_atomic(self._config_path, data)
|
||||||
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
_restrict_config_perms(self._config_path)
|
||||||
|
|
||||||
# Update in-memory settings
|
# --- Generic per-section CRUD --------------------------------------
|
||||||
settings.scripts[name] = config
|
|
||||||
|
|
||||||
logger.info(f"Script '{name}' added to config")
|
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:
|
||||||
|
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")
|
||||||
|
|
||||||
|
if not isinstance(existing, dict):
|
||||||
|
existing = {}
|
||||||
|
existing[key] = value.model_dump(exclude_none=True)
|
||||||
|
data[section] = existing
|
||||||
|
|
||||||
|
self._save(data)
|
||||||
|
|
||||||
|
if in_memory_target is not None:
|
||||||
|
in_memory_target[key] = value
|
||||||
|
logger.info(f"{section[:-1].title()} '{key}' {verb} in 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
|
||||||
|
|
||||||
|
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:
|
def update_script(self, name: str, config: ScriptConfig) -> None:
|
||||||
"""Update an existing script.
|
self._upsert(
|
||||||
|
"scripts", name, config,
|
||||||
Args:
|
require_present=True,
|
||||||
name: Script name.
|
in_memory_target=settings.scripts,
|
||||||
config: New script configuration.
|
verb="updated",
|
||||||
|
)
|
||||||
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")
|
|
||||||
|
|
||||||
def delete_script(self, name: str) -> None:
|
def delete_script(self, name: str) -> None:
|
||||||
"""Delete a script from config.
|
self._delete("scripts", name, in_memory_target=settings.scripts)
|
||||||
|
|
||||||
Args:
|
# --- Callbacks -----------------------------------------------------
|
||||||
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")
|
|
||||||
|
|
||||||
def add_callback(self, name: str, config: CallbackConfig) -> None:
|
def add_callback(self, name: str, config: CallbackConfig) -> None:
|
||||||
"""Add a new callback to config.
|
self._upsert(
|
||||||
|
"callbacks", name, config,
|
||||||
Args:
|
require_absent=True,
|
||||||
name: Callback name (must be unique).
|
in_memory_target=settings.callbacks,
|
||||||
config: Callback configuration.
|
verb="added",
|
||||||
|
)
|
||||||
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")
|
|
||||||
|
|
||||||
def update_callback(self, name: str, config: CallbackConfig) -> None:
|
def update_callback(self, name: str, config: CallbackConfig) -> None:
|
||||||
"""Update an existing callback.
|
self._upsert(
|
||||||
|
"callbacks", name, config,
|
||||||
Args:
|
require_present=True,
|
||||||
name: Callback name.
|
in_memory_target=settings.callbacks,
|
||||||
config: New callback configuration.
|
verb="updated",
|
||||||
|
)
|
||||||
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")
|
|
||||||
|
|
||||||
def delete_callback(self, name: str) -> None:
|
def delete_callback(self, name: str) -> None:
|
||||||
"""Delete a callback from config.
|
self._delete("callbacks", name, in_memory_target=settings.callbacks)
|
||||||
|
|
||||||
Args:
|
# --- Media folders -------------------------------------------------
|
||||||
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")
|
|
||||||
|
|
||||||
def add_media_folder(self, folder_id: str, config: MediaFolderConfig) -> None:
|
def add_media_folder(self, folder_id: str, config: MediaFolderConfig) -> None:
|
||||||
"""Add a new media folder to config.
|
self._upsert(
|
||||||
|
"media_folders", folder_id, config,
|
||||||
Args:
|
require_absent=True,
|
||||||
folder_id: Folder ID (must be unique).
|
in_memory_target=settings.media_folders,
|
||||||
config: Media folder configuration.
|
verb="added",
|
||||||
|
)
|
||||||
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")
|
|
||||||
|
|
||||||
def update_media_folder(self, folder_id: str, config: MediaFolderConfig) -> None:
|
def update_media_folder(self, folder_id: str, config: MediaFolderConfig) -> None:
|
||||||
"""Update an existing media folder.
|
self._upsert(
|
||||||
|
"media_folders", folder_id, config,
|
||||||
Args:
|
require_present=True,
|
||||||
folder_id: Folder ID.
|
in_memory_target=settings.media_folders,
|
||||||
config: New media folder configuration.
|
verb="updated",
|
||||||
|
)
|
||||||
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")
|
|
||||||
|
|
||||||
def delete_media_folder(self, folder_id: str) -> None:
|
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:
|
# --- Links ---------------------------------------------------------
|
||||||
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")
|
|
||||||
|
|
||||||
def add_link(self, name: str, config: LinkConfig) -> None:
|
def add_link(self, name: str, config: LinkConfig) -> None:
|
||||||
"""Add a new link to config."""
|
self._upsert(
|
||||||
with self._lock:
|
"links", name, config,
|
||||||
if not self._config_path.exists():
|
require_absent=True,
|
||||||
data = {}
|
in_memory_target=settings.links,
|
||||||
else:
|
verb="added",
|
||||||
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")
|
|
||||||
|
|
||||||
def update_link(self, name: str, config: LinkConfig) -> None:
|
def update_link(self, name: str, config: LinkConfig) -> None:
|
||||||
"""Update an existing link."""
|
self._upsert(
|
||||||
with self._lock:
|
"links", name, config,
|
||||||
if not self._config_path.exists():
|
require_present=True,
|
||||||
raise ValueError(f"Config file not found: {self._config_path}")
|
in_memory_target=settings.links,
|
||||||
|
verb="updated",
|
||||||
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")
|
|
||||||
|
|
||||||
def delete_link(self, name: str) -> None:
|
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:
|
with self._lock:
|
||||||
if not self._config_path.exists():
|
data = self._load()
|
||||||
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 {}
|
|
||||||
|
|
||||||
if value is None:
|
if value is None:
|
||||||
data.pop(key, None)
|
data.pop(key, None)
|
||||||
else:
|
else:
|
||||||
data[key] = value
|
data[key] = value
|
||||||
|
self._save(data)
|
||||||
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
|
|
||||||
if hasattr(settings, key):
|
if hasattr(settings, key):
|
||||||
setattr(settings, key, value)
|
setattr(settings, key, value)
|
||||||
logger.info("Setting '%s' updated to: %s", key, value)
|
logger.info("Setting '%s' updated to: %s", key, value)
|
||||||
|
|
||||||
|
|
||||||
# Global config manager instance
|
|
||||||
config_manager = ConfigManager()
|
config_manager = ConfigManager()
|
||||||
|
|||||||
+307
-29
@@ -15,13 +15,14 @@ from fastapi.responses import FileResponse
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
from .auth import get_token_label, token_label_var
|
from .auth import get_token_label, request_id_var, token_label_var
|
||||||
from .config import generate_default_config, get_config_dir, settings
|
from .config import generate_default_config, get_config_dir, settings
|
||||||
from .routes import (
|
from .routes import (
|
||||||
audio_router,
|
audio_router,
|
||||||
browser_router,
|
browser_router,
|
||||||
callbacks_router,
|
callbacks_router,
|
||||||
display_router,
|
display_router,
|
||||||
|
foreground_router,
|
||||||
health_router,
|
health_router,
|
||||||
links_router,
|
links_router,
|
||||||
media_router,
|
media_router,
|
||||||
@@ -32,10 +33,34 @@ from .services.websocket_manager import ws_manager
|
|||||||
|
|
||||||
|
|
||||||
class TokenLabelFilter(logging.Filter):
|
class TokenLabelFilter(logging.Filter):
|
||||||
"""Add token label to log records."""
|
"""Add token label + request_id to log records."""
|
||||||
|
|
||||||
def filter(self, record):
|
def filter(self, record):
|
||||||
record.token_label = token_label_var.get("unknown")
|
record.token_label = token_label_var.get("unknown")
|
||||||
|
record.request_id = request_id_var.get("-")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class _StripTokenQueryFilter(logging.Filter):
|
||||||
|
"""Strip `token=...` from query strings before they hit the access log.
|
||||||
|
|
||||||
|
uvicorn's default access log format includes the full request line, so
|
||||||
|
`/api/media/artwork?token=SECRET` would otherwise be persisted verbatim
|
||||||
|
in stdout/journald/file sinks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re as _re
|
||||||
|
|
||||||
|
_TOKEN_RE = _re.compile(r"([?&])token=[^&\s\"']+")
|
||||||
|
|
||||||
|
def filter(self, record): # type: ignore[override]
|
||||||
|
if isinstance(record.args, tuple):
|
||||||
|
record.args = tuple(
|
||||||
|
self._TOKEN_RE.sub(r"\1token=REDACTED", a) if isinstance(a, str) else a
|
||||||
|
for a in record.args
|
||||||
|
)
|
||||||
|
if isinstance(record.msg, str) and "token=" in record.msg:
|
||||||
|
record.msg = self._TOKEN_RE.sub(r"\1token=REDACTED", record.msg)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -48,35 +73,82 @@ def setup_logging():
|
|||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=getattr(logging, settings.log_level.upper()),
|
level=getattr(logging, settings.log_level.upper()),
|
||||||
format="%(asctime)s - %(name)s - [%(token_label)s] - %(levelname)s - %(message)s",
|
format=(
|
||||||
|
"%(asctime)s - %(name)s - [%(token_label)s] [%(request_id)s]"
|
||||||
|
" - %(levelname)s - %(message)s"
|
||||||
|
),
|
||||||
handlers=[handler],
|
handlers=[handler],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Suppress noisy third-party loggers
|
# Suppress noisy third-party loggers
|
||||||
logging.getLogger("screen_brightness_control").setLevel(logging.ERROR)
|
logging.getLogger("screen_brightness_control").setLevel(logging.ERROR)
|
||||||
|
|
||||||
|
# Make sure the uvicorn access log never persists tokens leaked into the
|
||||||
|
# query string (the artwork + WS endpoints accept `?token=` for browser
|
||||||
|
# compatibility — see verify_token_or_query).
|
||||||
|
strip_filter = _StripTokenQueryFilter()
|
||||||
|
for name in ("uvicorn.access", "uvicorn"):
|
||||||
|
logging.getLogger(name).addFilter(strip_filter)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Application lifespan handler."""
|
"""Application lifespan handler.
|
||||||
|
|
||||||
|
All long-lived resources started during startup are kept in local refs and
|
||||||
|
torn down in a `finally:` so a partial-startup failure cannot orphan tasks
|
||||||
|
or thread pools.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
setup_logging()
|
setup_logging()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.info(f"Media Server starting on {settings.host}:{settings.port}")
|
logger.info(f"Media Server starting on {settings.host}:{settings.port}")
|
||||||
|
|
||||||
# Log authentication status
|
# Log authentication status — never log full or partial token material.
|
||||||
if settings.api_tokens:
|
if settings.api_tokens:
|
||||||
for label, token in settings.api_tokens.items():
|
labels = ", ".join(settings.api_tokens.keys())
|
||||||
logger.info(f"API Token [{label}]: {token[:8]}...")
|
logger.info(f"Authentication enabled. Tokens configured: [{labels}]")
|
||||||
else:
|
else:
|
||||||
logger.warning("No API tokens configured — authentication is DISABLED")
|
logger.warning("No API tokens configured — authentication is DISABLED")
|
||||||
|
|
||||||
|
# Linux preflight: most MPRIS / PulseAudio failures are environmental
|
||||||
|
# (no DBUS_SESSION_BUS_ADDRESS, missing XDG_RUNTIME_DIR, systemd service
|
||||||
|
# started before logind). Surface that early so the failure mode is a
|
||||||
|
# warning at boot instead of silent "/api/media/status returns idle".
|
||||||
|
import os
|
||||||
|
import platform as _platform
|
||||||
|
if _platform.system() == "Linux":
|
||||||
|
missing = [
|
||||||
|
v for v in ("DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR")
|
||||||
|
if not os.environ.get(v)
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
logger.warning(
|
||||||
|
"Linux preflight: %s not set — MPRIS / PulseAudio may be unavailable."
|
||||||
|
" Under systemd, run `loginctl enable-linger <user>` and ensure the"
|
||||||
|
" service unit sets DBUS_SESSION_BUS_ADDRESS + XDG_RUNTIME_DIR.",
|
||||||
|
", ".join(missing),
|
||||||
|
)
|
||||||
|
if os.environ.get("WAYLAND_DISPLAY"):
|
||||||
|
logger.info(
|
||||||
|
"Wayland session detected — foreground-window probe is intentionally"
|
||||||
|
" disabled (Wayland hides window info from unprivileged clients)."
|
||||||
|
)
|
||||||
|
|
||||||
|
update_checker = None
|
||||||
|
cleanup_task: asyncio.Task | None = None
|
||||||
|
analyzer = None
|
||||||
|
status_monitor_started = False
|
||||||
|
|
||||||
|
try:
|
||||||
# Start WebSocket status monitor
|
# Start WebSocket status monitor
|
||||||
controller = get_media_controller()
|
controller = get_media_controller()
|
||||||
await ws_manager.start_status_monitor(controller.get_status)
|
await ws_manager.start_status_monitor(controller.get_status)
|
||||||
|
status_monitor_started = True
|
||||||
logger.info("WebSocket status monitor started")
|
logger.info("WebSocket status monitor started")
|
||||||
|
|
||||||
# Start update checker
|
# Start update checker
|
||||||
update_checker = None
|
|
||||||
if settings.update_check_enabled:
|
if settings.update_check_enabled:
|
||||||
from .services.gitea_release_provider import GiteaReleaseProvider
|
from .services.gitea_release_provider import GiteaReleaseProvider
|
||||||
from .services.update_checker import UpdateChecker
|
from .services.update_checker import UpdateChecker
|
||||||
@@ -87,8 +159,24 @@ async def lifespan(app: FastAPI):
|
|||||||
# Store globally so health endpoint can access cached result
|
# Store globally so health endpoint can access cached result
|
||||||
app.state.update_checker = update_checker
|
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)
|
# Register audio visualizer (capture starts on-demand when clients subscribe)
|
||||||
analyzer = None
|
|
||||||
if settings.visualizer_enabled:
|
if settings.visualizer_enabled:
|
||||||
from .services.audio_analyzer import get_audio_analyzer
|
from .services.audio_analyzer import get_audio_analyzer
|
||||||
|
|
||||||
@@ -104,24 +192,67 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info("Audio visualizer unavailable (install soundcard + numpy)")
|
logger.info("Audio visualizer unavailable (install soundcard + numpy)")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
finally:
|
||||||
# Stop update checker
|
# Stop update checker
|
||||||
if update_checker is not None:
|
if update_checker is not None:
|
||||||
|
try:
|
||||||
await update_checker.stop()
|
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
|
# Stop audio visualizer
|
||||||
|
try:
|
||||||
await ws_manager.stop_audio_monitor()
|
await ws_manager.stop_audio_monitor()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error stopping audio monitor")
|
||||||
if analyzer and analyzer.running:
|
if analyzer and analyzer.running:
|
||||||
|
try:
|
||||||
analyzer.stop()
|
analyzer.stop()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error stopping audio analyzer")
|
||||||
|
|
||||||
# Stop WebSocket status monitor
|
# Stop WebSocket status monitor
|
||||||
|
if status_monitor_started:
|
||||||
|
try:
|
||||||
await ws_manager.stop_status_monitor()
|
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
|
# Clean up platform-specific resources
|
||||||
import platform as _platform
|
import platform as _platform
|
||||||
if _platform.system() == "Windows":
|
if _platform.system() == "Windows":
|
||||||
|
try:
|
||||||
from .services.windows_media import shutdown_executor
|
from .services.windows_media import shutdown_executor
|
||||||
shutdown_executor()
|
shutdown_executor()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error shutting down windows_media executor")
|
||||||
|
|
||||||
logger.info("Media Server shutting down")
|
logger.info("Media Server shutting down")
|
||||||
|
|
||||||
@@ -138,42 +269,128 @@ def create_app() -> FastAPI:
|
|||||||
# Compress responses > 1KB
|
# Compress responses > 1KB
|
||||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||||
|
|
||||||
# Add CORS middleware for cross-origin requests
|
# CORS — restrict to same-origin by default; users that integrate the API
|
||||||
# Token auth is via Authorization header, not cookies, so credentials are not needed
|
# 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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=cors_origins,
|
||||||
allow_credentials=False,
|
allow_credentials=False,
|
||||||
allow_methods=["*"],
|
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||||
allow_headers=["*"],
|
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")
|
@app.middleware("http")
|
||||||
async def token_logging_middleware(request: Request, call_next):
|
async def token_logging_middleware(request: Request, call_next):
|
||||||
"""Extract token label and set in context for logging."""
|
"""Extract token label, set in context, and rate-limit failed auths."""
|
||||||
if not settings.api_tokens:
|
if not settings.api_tokens:
|
||||||
token_label_var.set("anonymous")
|
token_label_var.set("anonymous")
|
||||||
else:
|
else:
|
||||||
token_label = "unknown"
|
token_label = "unknown"
|
||||||
|
token_present = False
|
||||||
|
token_valid = False
|
||||||
|
|
||||||
# Try Authorization header
|
# Try Authorization header
|
||||||
auth_header = request.headers.get("authorization", "")
|
auth_header = request.headers.get("authorization", "")
|
||||||
if auth_header.startswith("Bearer "):
|
if auth_header.startswith("Bearer "):
|
||||||
|
token_present = True
|
||||||
token = auth_header[7:]
|
token = auth_header[7:]
|
||||||
label = get_token_label(token)
|
label = get_token_label(token)
|
||||||
if label:
|
if label:
|
||||||
token_label = label
|
token_label = label
|
||||||
|
token_valid = True
|
||||||
|
|
||||||
# Try query parameter (for artwork endpoint)
|
# Try query parameter (for artwork endpoint)
|
||||||
elif "token" in request.query_params:
|
elif "token" in request.query_params:
|
||||||
|
token_present = True
|
||||||
token = request.query_params["token"]
|
token = request.query_params["token"]
|
||||||
label = get_token_label(token)
|
label = get_token_label(token)
|
||||||
if label:
|
if label:
|
||||||
token_label = label
|
token_label = label
|
||||||
|
token_valid = True
|
||||||
|
|
||||||
token_label_var.set(token_label)
|
token_label_var.set(token_label)
|
||||||
|
|
||||||
|
# Brute-force gate: a peer that produces a wrong/missing token gets
|
||||||
|
# 5 failures per minute before being throttled. Static-asset
|
||||||
|
# requests (GET /static/*, /, /sw.js) and the docs endpoint are
|
||||||
|
# exempt — they're served unauthenticated by design.
|
||||||
|
if token_present and not token_valid:
|
||||||
|
path = request.url.path
|
||||||
|
if not (
|
||||||
|
path == "/" or path == "/sw.js"
|
||||||
|
or path.startswith("/static/")
|
||||||
|
or path.startswith("/docs") or path.startswith("/openapi")
|
||||||
|
or path.startswith("/redoc")
|
||||||
|
):
|
||||||
|
allowed, retry_after = ratelimit_check("auth", get_peer(request))
|
||||||
|
if not allowed:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=429,
|
||||||
|
content={"detail": "Too many authentication failures"},
|
||||||
|
headers={"Retry-After": str(int(retry_after or 60))},
|
||||||
|
)
|
||||||
|
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -182,6 +399,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(browser_router)
|
app.include_router(browser_router)
|
||||||
app.include_router(callbacks_router)
|
app.include_router(callbacks_router)
|
||||||
app.include_router(display_router)
|
app.include_router(display_router)
|
||||||
|
app.include_router(foreground_router)
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
app.include_router(links_router)
|
app.include_router(links_router)
|
||||||
app.include_router(media_router)
|
app.include_router(media_router)
|
||||||
@@ -205,6 +423,11 @@ def create_app() -> FastAPI:
|
|||||||
async def serve_ui():
|
async def serve_ui():
|
||||||
"""Serve the Web UI."""
|
"""Serve the Web UI."""
|
||||||
return FileResponse(static_dir / "index.html")
|
return FileResponse(static_dir / "index.html")
|
||||||
|
else:
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
"static_dir not found at %s — Web UI disabled (API only)",
|
||||||
|
static_dir,
|
||||||
|
)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
@@ -247,36 +470,94 @@ def main():
|
|||||||
if args.generate_config:
|
if args.generate_config:
|
||||||
config_path = generate_default_config()
|
config_path = generate_default_config()
|
||||||
print(f"Configuration file generated at: {config_path}")
|
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
|
return
|
||||||
|
|
||||||
if args.show_token:
|
if args.show_token:
|
||||||
print(f"Config directory: {get_config_dir()}")
|
print(f"Config directory: {get_config_dir()}")
|
||||||
if settings.api_tokens:
|
if settings.api_tokens:
|
||||||
print("\nAPI Tokens:")
|
print("\nAPI Tokens:")
|
||||||
for label, token in settings.api_tokens.items():
|
for label, spec in settings.api_tokens.items():
|
||||||
print(f" {label:20} {token}")
|
scope_str = ",".join(spec.scopes)
|
||||||
|
print(f" {label:20} {spec.token} [scopes: {scope_str}]")
|
||||||
else:
|
else:
|
||||||
print("\nAuthentication is DISABLED (no tokens configured)")
|
print("\nAuthentication is DISABLED (no tokens configured)")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# 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
|
# Check if port is available before starting
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
try:
|
try:
|
||||||
sock.bind((args.host if args.host != "0.0.0.0" else "127.0.0.1", args.port))
|
sock.bind((args.host if args.host != "0.0.0.0" else "127.0.0.1", args.port))
|
||||||
except OSError:
|
except OSError:
|
||||||
print(
|
_fatal(
|
||||||
f"ERROR: Port {args.port} is already in use. "
|
f"ERROR: Port {args.port} is already in use. "
|
||||||
f"Another instance of Media Server may be running.\n"
|
f"Another instance of Media Server may be running.\n"
|
||||||
f"Stop the other process or use --port to pick a different port.",
|
f"Stop the other process or use --port to pick a different port."
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
from .tray import PYSTRAY_AVAILABLE, TrayManager
|
from .tray import PYSTRAY_AVAILABLE, TrayManager
|
||||||
|
|
||||||
use_tray = PYSTRAY_AVAILABLE and not args.no_tray
|
use_tray = PYSTRAY_AVAILABLE and not args.no_tray
|
||||||
|
|
||||||
|
# Validate TLS pair consistency before either path so we don't fail late.
|
||||||
|
if bool(settings.ssl_certfile) ^ bool(settings.ssl_keyfile):
|
||||||
|
_fatal(
|
||||||
|
"ERROR: ssl_certfile and ssl_keyfile must both be set, or both unset."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _uvicorn_kwargs() -> dict:
|
||||||
|
kw: dict = {
|
||||||
|
"host": args.host,
|
||||||
|
"port": args.port,
|
||||||
|
"log_level": settings.log_level.lower(),
|
||||||
|
"proxy_headers": settings.proxy_headers,
|
||||||
|
"forwarded_allow_ips": settings.forwarded_allow_ips,
|
||||||
|
}
|
||||||
|
if settings.ssl_certfile and settings.ssl_keyfile:
|
||||||
|
kw["ssl_certfile"] = settings.ssl_certfile
|
||||||
|
kw["ssl_keyfile"] = settings.ssl_keyfile
|
||||||
|
if settings.ssl_keyfile_password:
|
||||||
|
kw["ssl_keyfile_password"] = settings.ssl_keyfile_password
|
||||||
|
return kw
|
||||||
|
|
||||||
if use_tray:
|
if use_tray:
|
||||||
import asyncio
|
import asyncio
|
||||||
import threading
|
import threading
|
||||||
@@ -284,9 +565,7 @@ def main():
|
|||||||
# Run uvicorn in a background thread so tray owns the main thread message loop
|
# Run uvicorn in a background thread so tray owns the main thread message loop
|
||||||
uv_config = uvicorn.Config(
|
uv_config = uvicorn.Config(
|
||||||
"media_server.main:app",
|
"media_server.main:app",
|
||||||
host=args.host,
|
**_uvicorn_kwargs(),
|
||||||
port=args.port,
|
|
||||||
log_level=settings.log_level.lower(),
|
|
||||||
)
|
)
|
||||||
server = uvicorn.Server(uv_config)
|
server = uvicorn.Server(uv_config)
|
||||||
|
|
||||||
@@ -324,9 +603,8 @@ def main():
|
|||||||
else:
|
else:
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
"media_server.main:app",
|
"media_server.main:app",
|
||||||
host=args.host,
|
|
||||||
port=args.port,
|
|
||||||
reload=False,
|
reload=False,
|
||||||
|
**_uvicorn_kwargs(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from .audio import router as audio_router
|
|||||||
from .browser import router as browser_router
|
from .browser import router as browser_router
|
||||||
from .callbacks import router as callbacks_router
|
from .callbacks import router as callbacks_router
|
||||||
from .display import router as display_router
|
from .display import router as display_router
|
||||||
|
from .foreground import router as foreground_router
|
||||||
from .health import router as health_router
|
from .health import router as health_router
|
||||||
from .links import router as links_router
|
from .links import router as links_router
|
||||||
from .media import router as media_router
|
from .media import router as media_router
|
||||||
@@ -14,6 +15,7 @@ __all__ = [
|
|||||||
"browser_router",
|
"browser_router",
|
||||||
"callbacks_router",
|
"callbacks_router",
|
||||||
"display_router",
|
"display_router",
|
||||||
|
"foreground_router",
|
||||||
"health_router",
|
"health_router",
|
||||||
"links_router",
|
"links_router",
|
||||||
"media_router",
|
"media_router",
|
||||||
|
|||||||
+106
-78
@@ -23,14 +23,33 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/browser", tags=["browser"])
|
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:
|
def _require_folder_management() -> None:
|
||||||
"""Raise 403 if media folder management is disabled in config."""
|
"""Raise 403 if media folder management is disabled OR caller lacks admin scope."""
|
||||||
if not settings.media_folders_management:
|
if not settings.media_folders_management:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=403,
|
status_code=403,
|
||||||
detail="Media folder management is disabled. Set media_folders_management: true in config.yaml to enable.",
|
detail="Media folder management is disabled. Set media_folders_management: true in config.yaml to enable.",
|
||||||
)
|
)
|
||||||
|
from ..auth import auth_enabled, token_has_scope, token_label_var
|
||||||
|
if auth_enabled():
|
||||||
|
label = token_label_var.get("unknown")
|
||||||
|
if not token_has_scope(label, "admin"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=f"Token '{label}' lacks required scope: admin",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _broadcast_after_open(controller, label: str, max_wait: float = 2.0) -> None:
|
async def _broadcast_after_open(controller, label: str, max_wait: float = 2.0) -> None:
|
||||||
@@ -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.
|
Fires as a background task so the HTTP response returns immediately.
|
||||||
"""
|
"""
|
||||||
|
status = None
|
||||||
try:
|
try:
|
||||||
interval = 0.3
|
interval = 0.3
|
||||||
elapsed = 0.0
|
elapsed = 0.0
|
||||||
while elapsed < max_wait:
|
while elapsed < max_wait:
|
||||||
await asyncio.sleep(interval)
|
await asyncio.sleep(interval)
|
||||||
elapsed += interval
|
elapsed += interval
|
||||||
|
try:
|
||||||
status = await controller.get_status()
|
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"):
|
if status.state in ("playing", "paused"):
|
||||||
break
|
break
|
||||||
|
|
||||||
|
if status is None:
|
||||||
|
return
|
||||||
status_dict = status.model_dump()
|
status_dict = status.model_dump()
|
||||||
await ws_manager.broadcast({"type": "status", "data": status_dict})
|
await ws_manager.broadcast({"type": "status", "data": status_dict})
|
||||||
logger.info(f"Broadcasted status update after opening: {label}")
|
logger.info(f"Broadcasted status update after opening: {label}")
|
||||||
@@ -74,9 +100,14 @@ class FolderUpdateRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class PlayRequest(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):
|
class PlayFolderRequest(BaseModel):
|
||||||
@@ -128,8 +159,10 @@ async def create_folder(
|
|||||||
"""
|
"""
|
||||||
_require_folder_management()
|
_require_folder_management()
|
||||||
try:
|
try:
|
||||||
# Validate folder_id format (alphanumeric and underscore only)
|
# Validate folder_id format (alphanumeric and underscore only).
|
||||||
if not request.folder_id.replace("_", "").isalnum():
|
# 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(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail="Folder ID must contain only alphanumeric characters and underscores",
|
detail="Folder ID must contain only alphanumeric characters and underscores",
|
||||||
@@ -277,13 +310,15 @@ async def browse(
|
|||||||
# URL decode the path
|
# URL decode the path
|
||||||
decoded_path = unquote(path)
|
decoded_path = unquote(path)
|
||||||
|
|
||||||
# Browse directory
|
# Browse directory in a thread — iterdir() + stat() can block on
|
||||||
result = BrowserService.browse_directory(
|
# network shares for many seconds; never run on the event loop.
|
||||||
folder_id=folder_id,
|
result = await asyncio.to_thread(
|
||||||
path=decoded_path,
|
BrowserService.browse_directory,
|
||||||
offset=offset,
|
folder_id,
|
||||||
limit=limit,
|
decoded_path,
|
||||||
nocache=nocache,
|
offset,
|
||||||
|
limit,
|
||||||
|
nocache,
|
||||||
)
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -307,41 +342,40 @@ async def browse(
|
|||||||
# Metadata Endpoint
|
# Metadata Endpoint
|
||||||
@router.get("/metadata")
|
@router.get("/metadata")
|
||||||
async def 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),
|
_: str = Depends(verify_token),
|
||||||
):
|
):
|
||||||
"""Get metadata for a media file.
|
"""Get metadata for a media file inside a configured media folder.
|
||||||
|
|
||||||
Args:
|
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:
|
Returns:
|
||||||
Media file metadata.
|
Media file metadata.
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If file not found or metadata extraction fails.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# URL decode the path
|
|
||||||
decoded_path = unquote(path)
|
decoded_path = unquote(path)
|
||||||
file_path = Path(decoded_path)
|
file_path = BrowserService.validate_path(folder_id, decoded_path)
|
||||||
|
|
||||||
if not file_path.exists():
|
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
|
||||||
|
|
||||||
if not file_path.is_file():
|
if not file_path.is_file():
|
||||||
raise HTTPException(status_code=400, detail="Path is not a 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_running_loop()
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
metadata = await loop.run_in_executor(
|
metadata = await loop.run_in_executor(
|
||||||
None,
|
None,
|
||||||
MetadataService.extract_metadata,
|
MetadataService.extract_metadata,
|
||||||
file_path,
|
file_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
return metadata
|
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:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -352,59 +386,47 @@ async def get_metadata(
|
|||||||
# Thumbnail Endpoint
|
# Thumbnail Endpoint
|
||||||
@router.get("/thumbnail")
|
@router.get("/thumbnail")
|
||||||
async def 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"'),
|
size: str = Query(default="medium", description='Thumbnail size: "small" or "medium"'),
|
||||||
_: str = Depends(verify_token),
|
_: str = Depends(verify_token),
|
||||||
):
|
):
|
||||||
"""Get thumbnail for a media file.
|
"""Get thumbnail for a media file inside a configured media folder."""
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
# URL decode the path
|
|
||||||
decoded_path = unquote(path)
|
decoded_path = unquote(path)
|
||||||
file_path = Path(decoded_path)
|
file_path = BrowserService.validate_path(folder_id, decoded_path)
|
||||||
|
|
||||||
if not file_path.exists():
|
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
|
||||||
|
|
||||||
if not file_path.is_file():
|
if not file_path.is_file():
|
||||||
raise HTTPException(status_code=400, detail="Path is not a 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"):
|
if size not in ("small", "medium"):
|
||||||
size = "medium"
|
size = "medium"
|
||||||
|
|
||||||
# Get thumbnail
|
|
||||||
thumbnail_data = await ThumbnailService.get_thumbnail(file_path, size)
|
thumbnail_data = await ThumbnailService.get_thumbnail(file_path, size)
|
||||||
|
|
||||||
if thumbnail_data is None:
|
if thumbnail_data is None:
|
||||||
return Response(status_code=204)
|
return Response(status_code=204)
|
||||||
|
|
||||||
# Calculate ETag (hash of path + mtime)
|
|
||||||
import hashlib
|
import hashlib
|
||||||
stat = file_path.stat()
|
stat = file_path.stat()
|
||||||
etag_data = f"{file_path}:{stat.st_mtime}:{size}".encode()
|
etag_data = f"{file_path}:{stat.st_mtime}:{size}".encode()
|
||||||
etag = hashlib.md5(etag_data).hexdigest()
|
etag = hashlib.md5(etag_data).hexdigest()
|
||||||
|
|
||||||
# Return image with caching headers
|
|
||||||
return Response(
|
return Response(
|
||||||
content=thumbnail_data,
|
content=thumbnail_data,
|
||||||
media_type="image/jpeg",
|
media_type="image/jpeg",
|
||||||
headers={
|
headers={
|
||||||
"ETag": f'"{etag}"',
|
"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:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -420,44 +442,37 @@ async def play_file(
|
|||||||
):
|
):
|
||||||
"""Open a media file with the default system player.
|
"""Open a media file with the default system player.
|
||||||
|
|
||||||
Args:
|
Requires both ``folder_id`` and a folder-relative ``path``; the resolved
|
||||||
request: Play request with file path.
|
file must live inside the configured media folder and be a recognized
|
||||||
|
media file. This prevents arbitrary OS-handler invocation (e.g.,
|
||||||
Returns:
|
``os.startfile`` on Windows ``.lnk``/UNC paths).
|
||||||
Success message.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If file not found or playback fails.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
file_path = Path(request.path)
|
decoded_path = unquote(request.path)
|
||||||
|
file_path = BrowserService.validate_path(request.folder_id, decoded_path)
|
||||||
# Validate file exists
|
|
||||||
if not file_path.exists():
|
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
|
||||||
|
|
||||||
if not file_path.is_file():
|
if not file_path.is_file():
|
||||||
raise HTTPException(status_code=400, detail="Path is not a 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):
|
if not BrowserService.is_media_file(file_path):
|
||||||
raise HTTPException(status_code=400, detail="File is not a media file")
|
raise HTTPException(status_code=400, detail="File is not a media file")
|
||||||
|
|
||||||
# Get media controller and open file
|
|
||||||
controller = get_media_controller()
|
controller = get_media_controller()
|
||||||
success = await controller.open_file(str(file_path))
|
success = await controller.open_file(str(file_path))
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
raise HTTPException(status_code=500, detail="Failed to open file")
|
raise HTTPException(status_code=500, detail="Failed to open file")
|
||||||
|
|
||||||
# Poll until player registers with media session API (up to 2s)
|
_spawn_background(_broadcast_after_open(controller, file_path.name))
|
||||||
asyncio.create_task(_broadcast_after_open(controller, file_path.name))
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Playing {file_path.name}",
|
"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:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -489,26 +504,38 @@ async def play_folder(
|
|||||||
if not full_path.is_dir():
|
if not full_path.is_dir():
|
||||||
raise HTTPException(status_code=400, detail="Path is not a directory")
|
raise HTTPException(status_code=400, detail="Path is not a directory")
|
||||||
|
|
||||||
# Collect all media files sorted by name
|
def _scan(directory: Path) -> list[Path]:
|
||||||
media_files = sorted(
|
return sorted(
|
||||||
[f for f in full_path.iterdir() if f.is_file() and BrowserService.is_media_file(f)],
|
(
|
||||||
|
f for f in directory.iterdir()
|
||||||
|
if f.is_file() and BrowserService.is_media_file(f)
|
||||||
|
),
|
||||||
key=lambda f: f.name.lower(),
|
key=lambda f: f.name.lower(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
media_files = await asyncio.to_thread(_scan, full_path)
|
||||||
|
|
||||||
if not media_files:
|
if not media_files:
|
||||||
raise HTTPException(status_code=404, detail="No media files found in this folder")
|
raise HTTPException(status_code=404, detail="No media files found in this folder")
|
||||||
|
|
||||||
# Generate M3U playlist with absolute paths and EXTINF entries
|
# Generate M3U playlist with absolute paths and EXTINF entries.
|
||||||
# Written to local temp dir to avoid extra SMB file handle on network shares
|
# Use NamedTemporaryFile to get a fresh per-call path — prevents
|
||||||
# Uses utf-8-sig (BOM) so players detect encoding properly
|
# symlink-clobber races between concurrent /play-folder requests
|
||||||
|
# and any local user pre-creating a fixed temp filename.
|
||||||
lines = ["#EXTM3U"]
|
lines = ["#EXTM3U"]
|
||||||
for f in media_files:
|
for f in media_files:
|
||||||
lines.append(f"#EXTINF:-1,{f.stem}")
|
lines.append(f"#EXTINF:-1,{f.stem}")
|
||||||
lines.append(str(f))
|
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"
|
with tempfile.NamedTemporaryFile(
|
||||||
playlist_path.write_text(m3u_content, encoding="utf-8-sig")
|
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
|
# Open playlist with default player
|
||||||
controller = get_media_controller()
|
controller = get_media_controller()
|
||||||
@@ -517,8 +544,9 @@ async def play_folder(
|
|||||||
if not success:
|
if not success:
|
||||||
raise HTTPException(status_code=500, detail="Failed to open playlist")
|
raise HTTPException(status_code=500, detail="Failed to open playlist")
|
||||||
|
|
||||||
# Poll until player registers with media session API (up to 2s)
|
_spawn_background(
|
||||||
asyncio.create_task(_broadcast_after_open(controller, f"playlist ({len(media_files)} files)"))
|
_broadcast_after_open(controller, f"playlist ({len(media_files)} files)")
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
|
|||||||
@@ -3,16 +3,19 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from ..auth import verify_token
|
from ..auth import verify_token
|
||||||
from ..config import CallbackConfig, settings
|
from ..config import CallbackConfig, settings
|
||||||
from ..config_manager import config_manager
|
from ..config_manager import config_manager
|
||||||
|
from ..services.rate_limit import check as ratelimit_check
|
||||||
|
from ..services.rate_limit import get_peer
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/callbacks", tags=["callbacks"])
|
router = APIRouter(prefix="/api/callbacks", tags=["callbacks"])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -21,6 +24,31 @@ logger = logging.getLogger(__name__)
|
|||||||
_callback_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="callback")
|
_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):
|
class CallbackInfo(BaseModel):
|
||||||
"""Information about a configured callback."""
|
"""Information about a configured callback."""
|
||||||
|
|
||||||
@@ -105,6 +133,7 @@ async def list_callbacks(_: str = Depends(verify_token)) -> list[CallbackInfo]:
|
|||||||
@router.post("/execute/{callback_name}")
|
@router.post("/execute/{callback_name}")
|
||||||
async def execute_callback(
|
async def execute_callback(
|
||||||
callback_name: str,
|
callback_name: str,
|
||||||
|
http_request: Request,
|
||||||
_: str = Depends(verify_token),
|
_: str = Depends(verify_token),
|
||||||
) -> CallbackExecuteResponse:
|
) -> CallbackExecuteResponse:
|
||||||
"""Execute a callback for debugging purposes.
|
"""Execute a callback for debugging purposes.
|
||||||
@@ -115,6 +144,16 @@ async def execute_callback(
|
|||||||
Returns:
|
Returns:
|
||||||
Execution result including stdout, stderr, and exit code
|
Execution result including stdout, stderr, and exit code
|
||||||
"""
|
"""
|
||||||
|
# Rate-limit callback execution per peer (10/min) — callbacks also run
|
||||||
|
# subprocesses and need the same protection as scripts.
|
||||||
|
allowed, retry_after = ratelimit_check("execute", get_peer(http_request))
|
||||||
|
if not allowed:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="Too many callback executions, slow down",
|
||||||
|
headers={"Retry-After": str(int(retry_after or 60))},
|
||||||
|
)
|
||||||
|
|
||||||
# Validate callback name
|
# Validate callback name
|
||||||
_validate_callback_name(callback_name)
|
_validate_callback_name(callback_name)
|
||||||
|
|
||||||
@@ -129,9 +168,11 @@ async def execute_callback(
|
|||||||
|
|
||||||
logger.info(f"Executing callback for debugging: {callback_name}")
|
logger.info(f"Executing callback for debugging: {callback_name}")
|
||||||
|
|
||||||
|
from ..services.audit_log import record_script_execution
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Execute in dedicated thread pool to not block the default executor
|
# Execute in dedicated thread pool to not block the default executor
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_running_loop()
|
||||||
result = await loop.run_in_executor(
|
result = await loop.run_in_executor(
|
||||||
_callback_executor,
|
_callback_executor,
|
||||||
lambda: _run_callback(
|
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(
|
return CallbackExecuteResponse(
|
||||||
success=result["exit_code"] == 0,
|
success=result["exit_code"] == 0,
|
||||||
callback=callback_name,
|
callback=callback_name,
|
||||||
@@ -153,6 +203,13 @@ async def execute_callback(
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Callback execution error: {e}")
|
logger.error(f"Callback execution error: {e}")
|
||||||
|
record_script_execution(
|
||||||
|
kind="callback",
|
||||||
|
name=callback_name,
|
||||||
|
exit_code=None,
|
||||||
|
duration=None,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
return CallbackExecuteResponse(
|
return CallbackExecuteResponse(
|
||||||
success=False,
|
success=False,
|
||||||
callback=callback_name,
|
callback=callback_name,
|
||||||
@@ -178,6 +235,11 @@ def _run_callback(
|
|||||||
Dict with exit_code, stdout, stderr, execution_time
|
Dict with exit_code, stdout, stderr, execution_time
|
||||||
"""
|
"""
|
||||||
start_time = time.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:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
command,
|
command,
|
||||||
@@ -186,6 +248,7 @@ def _run_callback(
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
|
**popen_kwargs,
|
||||||
)
|
)
|
||||||
execution_time = time.time() - start_time
|
execution_time = time.time() - start_time
|
||||||
return {
|
return {
|
||||||
@@ -230,7 +293,7 @@ async def create_callback(
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: If callback already exists or name is invalid.
|
HTTPException: If callback already exists or name is invalid.
|
||||||
"""
|
"""
|
||||||
# Validate name
|
_require_callbacks_management()
|
||||||
_validate_callback_name(callback_name)
|
_validate_callback_name(callback_name)
|
||||||
|
|
||||||
# Check if callback already exists
|
# Check if callback already exists
|
||||||
@@ -278,7 +341,7 @@ async def update_callback(
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: If callback does not exist.
|
HTTPException: If callback does not exist.
|
||||||
"""
|
"""
|
||||||
# Validate name
|
_require_callbacks_management()
|
||||||
_validate_callback_name(callback_name)
|
_validate_callback_name(callback_name)
|
||||||
|
|
||||||
# Check if callback exists
|
# Check if callback exists
|
||||||
@@ -324,7 +387,7 @@ async def delete_callback(
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: If callback does not exist.
|
HTTPException: If callback does not exist.
|
||||||
"""
|
"""
|
||||||
# Validate name
|
_require_callbacks_management()
|
||||||
_validate_callback_name(callback_name)
|
_validate_callback_name(callback_name)
|
||||||
|
|
||||||
# Check if callback exists
|
# 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
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
@@ -9,6 +10,10 @@ from ..auth import verify_token
|
|||||||
from ..services.display_service import (
|
from ..services.display_service import (
|
||||||
list_monitors,
|
list_monitors,
|
||||||
set_brightness,
|
set_brightness,
|
||||||
|
set_color_preset,
|
||||||
|
set_contrast,
|
||||||
|
set_input_source,
|
||||||
|
set_picture_mode,
|
||||||
set_power,
|
set_power,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,12 +30,37 @@ class PowerRequest(BaseModel):
|
|||||||
on: bool
|
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")
|
@router.get("/monitors")
|
||||||
async def 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[dict]:
|
||||||
"""List all connected monitors with brightness and power info."""
|
"""List all connected monitors with their reported DDC/CI capabilities."""
|
||||||
monitors = list_monitors(force_refresh=refresh)
|
monitors = await asyncio.to_thread(
|
||||||
|
list_monitors, force_refresh=refresh, rediscover=rediscover
|
||||||
|
)
|
||||||
logger.debug("Found %d monitors", len(monitors))
|
logger.debug("Found %d monitors", len(monitors))
|
||||||
return [m.to_dict() for m in 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)
|
monitor_id: int, request: BrightnessRequest, _: str = Depends(verify_token)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Set brightness for a specific monitor."""
|
"""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:
|
if success:
|
||||||
logger.info("Set monitor %d brightness to %d", monitor_id, request.brightness)
|
logger.info("Set monitor %d brightness to %d", monitor_id, request.brightness)
|
||||||
return {"success": success}
|
return {"success": success}
|
||||||
@@ -52,7 +82,51 @@ async def set_monitor_power(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Turn a monitor on or off."""
|
"""Turn a monitor on or off."""
|
||||||
action = "on" if request.on else "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:
|
if success:
|
||||||
logger.info("Set monitor %d power %s", monitor_id, action)
|
logger.info("Set monitor %d power %s", monitor_id, action)
|
||||||
return {"success": success}
|
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 logging
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
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 ..auth import verify_token
|
||||||
from ..config import LinkConfig, settings
|
from ..config import LinkConfig, settings
|
||||||
@@ -15,6 +16,44 @@ from ..services.websocket_manager import ws_manager
|
|||||||
router = APIRouter(prefix="/api/links", tags=["links"])
|
router = APIRouter(prefix="/api/links", tags=["links"])
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
class LinkInfo(BaseModel):
|
||||||
"""Information about a configured link."""
|
"""Information about a configured link."""
|
||||||
@@ -29,22 +68,25 @@ class LinkInfo(BaseModel):
|
|||||||
class LinkCreateRequest(BaseModel):
|
class LinkCreateRequest(BaseModel):
|
||||||
"""Request model for creating or updating a link."""
|
"""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')")
|
icon: str = Field(default="mdi:link", description="MDI icon name (e.g., 'mdi:led-strip-variant')")
|
||||||
label: str = Field(default="", description="Tooltip text")
|
label: str = Field(default="", description="Tooltip text", max_length=128)
|
||||||
description: str = Field(default="", description="Optional description")
|
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:
|
def _validate_link_name(name: str) -> None:
|
||||||
"""Validate link name.
|
"""Validate link name."""
|
||||||
|
if not re.match(r"^[a-zA-Z0-9_]+$", name):
|
||||||
Args:
|
|
||||||
name: Link name to validate.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If name is invalid.
|
|
||||||
"""
|
|
||||||
if not re.match(r'^[a-zA-Z0-9_]+$', name):
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Link name must contain only letters, numbers, and underscores",
|
detail="Link name must contain only letters, numbers, and underscores",
|
||||||
@@ -90,6 +132,7 @@ async def create_link(
|
|||||||
Returns:
|
Returns:
|
||||||
Success response with link name.
|
Success response with link name.
|
||||||
"""
|
"""
|
||||||
|
_require_links_management()
|
||||||
_validate_link_name(link_name)
|
_validate_link_name(link_name)
|
||||||
|
|
||||||
if link_name in settings.links:
|
if link_name in settings.links:
|
||||||
@@ -129,6 +172,7 @@ async def update_link(
|
|||||||
Returns:
|
Returns:
|
||||||
Success response with link name.
|
Success response with link name.
|
||||||
"""
|
"""
|
||||||
|
_require_links_management()
|
||||||
_validate_link_name(link_name)
|
_validate_link_name(link_name)
|
||||||
|
|
||||||
if link_name not in settings.links:
|
if link_name not in settings.links:
|
||||||
@@ -166,6 +210,7 @@ async def delete_link(
|
|||||||
Returns:
|
Returns:
|
||||||
Success response with link name.
|
Success response with link name.
|
||||||
"""
|
"""
|
||||||
|
_require_links_management()
|
||||||
_validate_link_name(link_name)
|
_validate_link_name(link_name)
|
||||||
|
|
||||||
if link_name not in settings.links:
|
if link_name not in settings.links:
|
||||||
|
|||||||
+176
-28
@@ -3,13 +3,22 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect, status
|
from fastapi import (
|
||||||
|
APIRouter,
|
||||||
|
Depends,
|
||||||
|
HTTPException,
|
||||||
|
Query,
|
||||||
|
Request,
|
||||||
|
WebSocket,
|
||||||
|
WebSocketDisconnect,
|
||||||
|
status,
|
||||||
|
)
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
|
|
||||||
from ..auth import verify_token, verify_token_or_query
|
from ..auth import verify_token, verify_token_or_query
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
from ..models import MediaStatus, SeekRequest, VolumeRequest
|
from ..models import MediaStatus, SeekRequest, VolumeRequest
|
||||||
from ..services import get_current_album_art, get_media_controller
|
from ..services import get_current_album_art_async, get_media_controller
|
||||||
from ..services.websocket_manager import ws_manager
|
from ..services.websocket_manager import ws_manager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -17,19 +26,28 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/api/media", tags=["media"])
|
router = APIRouter(prefix="/api/media", tags=["media"])
|
||||||
|
|
||||||
|
|
||||||
|
# Strong refs to background tasks so the asyncio GC can't drop them before
|
||||||
|
# they run. Mirrors the pattern used in routes/browser.py.
|
||||||
|
_background_callback_tasks: set[asyncio.Task] = set()
|
||||||
|
|
||||||
|
|
||||||
def _run_callback(callback_name: str) -> None:
|
def _run_callback(callback_name: str) -> None:
|
||||||
"""Fire-and-forget a callback if configured. Failures are logged but don't block."""
|
"""Fire-and-forget a callback if configured. Failures are logged but don't block."""
|
||||||
if not settings.callbacks or callback_name not in settings.callbacks:
|
if not settings.callbacks or callback_name not in settings.callbacks:
|
||||||
return
|
return
|
||||||
|
|
||||||
async def _execute():
|
async def _execute():
|
||||||
|
# Use the dedicated callback executor (not the default loop pool) so a
|
||||||
|
# misbehaving callback can't starve the rest of the app's sync tasks.
|
||||||
|
from ..services.audit_log import record_script_execution
|
||||||
|
from .callbacks import _callback_executor
|
||||||
from .scripts import _run_script
|
from .scripts import _run_script
|
||||||
|
|
||||||
try:
|
try:
|
||||||
callback = settings.callbacks[callback_name]
|
callback = settings.callbacks[callback_name]
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_running_loop()
|
||||||
result = await loop.run_in_executor(
|
result = await loop.run_in_executor(
|
||||||
None,
|
_callback_executor,
|
||||||
lambda: _run_script(
|
lambda: _run_script(
|
||||||
command=callback.command,
|
command=callback.command,
|
||||||
timeout=callback.timeout,
|
timeout=callback.timeout,
|
||||||
@@ -37,6 +55,14 @@ def _run_callback(callback_name: str) -> None:
|
|||||||
working_dir=callback.working_dir,
|
working_dir=callback.working_dir,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
record_script_execution(
|
||||||
|
kind="event-callback",
|
||||||
|
name=callback_name,
|
||||||
|
exit_code=result["exit_code"],
|
||||||
|
duration=result.get("execution_time"),
|
||||||
|
stdout=result.get("stdout"),
|
||||||
|
stderr=result.get("stderr"),
|
||||||
|
)
|
||||||
if result["exit_code"] != 0:
|
if result["exit_code"] != 0:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Callback %s failed with exit code %s: %s",
|
"Callback %s failed with exit code %s: %s",
|
||||||
@@ -46,8 +72,18 @@ def _run_callback(callback_name: str) -> None:
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Callback %s error: %s", callback_name, e)
|
logger.error("Callback %s error: %s", callback_name, e)
|
||||||
|
from ..services.audit_log import record_script_execution as _rec
|
||||||
|
_rec(
|
||||||
|
kind="event-callback",
|
||||||
|
name=callback_name,
|
||||||
|
exit_code=None,
|
||||||
|
duration=None,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
asyncio.create_task(_execute())
|
task = asyncio.create_task(_execute())
|
||||||
|
_background_callback_tasks.add(task)
|
||||||
|
task.add_done_callback(_background_callback_tasks.discard)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status", response_model=MediaStatus)
|
@router.get("/status", response_model=MediaStatus)
|
||||||
@@ -242,41 +278,91 @@ async def toggle(_: str = Depends(verify_token)) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/artwork")
|
@router.get("/artwork")
|
||||||
async def get_artwork(_: str = Depends(verify_token_or_query)) -> Response:
|
async def get_artwork(
|
||||||
|
request: Request,
|
||||||
|
_: str = Depends(verify_token_or_query),
|
||||||
|
) -> Response:
|
||||||
"""Get the current album artwork.
|
"""Get the current album artwork.
|
||||||
|
|
||||||
Returns:
|
Returns the bytes with a content-derived ETag so the browser can serve a
|
||||||
The album art image as PNG/JPEG
|
304 when the same track is re-requested.
|
||||||
"""
|
"""
|
||||||
art_bytes = get_current_album_art()
|
art_bytes = await get_current_album_art_async()
|
||||||
if art_bytes is None:
|
if art_bytes is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail="No album artwork available",
|
detail="No album artwork available",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Try to detect image type from magic bytes
|
# Detect image type from magic bytes
|
||||||
content_type = "image/png" # Default
|
|
||||||
if art_bytes[:3] == b"\xff\xd8\xff":
|
if art_bytes[:3] == b"\xff\xd8\xff":
|
||||||
content_type = "image/jpeg"
|
content_type = "image/jpeg"
|
||||||
elif art_bytes[:8] == b"\x89PNG\r\n\x1a\n":
|
elif art_bytes[:8] == b"\x89PNG\r\n\x1a\n":
|
||||||
content_type = "image/png"
|
content_type = "image/png"
|
||||||
elif art_bytes[:4] == b"RIFF" and art_bytes[8:12] == b"WEBP":
|
elif art_bytes[:4] == b"RIFF" and len(art_bytes) > 12 and art_bytes[8:12] == b"WEBP":
|
||||||
content_type = "image/webp"
|
content_type = "image/webp"
|
||||||
|
elif art_bytes[:2] == b"BM":
|
||||||
|
content_type = "image/bmp"
|
||||||
|
else:
|
||||||
|
content_type = "application/octet-stream"
|
||||||
|
|
||||||
return Response(content=art_bytes, media_type=content_type)
|
# Content-derived ETag (blake2b-128 — non-crypto cache key, ruff S324-safe)
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
etag = '"' + hashlib.blake2b(art_bytes, digest_size=16).hexdigest() + '"'
|
||||||
|
|
||||||
|
if request.headers.get("if-none-match") == etag:
|
||||||
|
return Response(status_code=status.HTTP_304_NOT_MODIFIED, headers={"ETag": etag})
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=art_bytes,
|
||||||
|
media_type=content_type,
|
||||||
|
headers={
|
||||||
|
"ETag": etag,
|
||||||
|
"Cache-Control": "private, max-age=0, must-revalidate",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/visualizer/status")
|
@router.get("/visualizer/status")
|
||||||
async def visualizer_status(_: str = Depends(verify_token)) -> dict:
|
async def visualizer_status(_: str = Depends(verify_token)) -> dict:
|
||||||
"""Check if audio visualizer is available and running."""
|
"""Check if audio visualizer is available and running.
|
||||||
|
|
||||||
|
``available`` is True only when both numpy + soundcard import.
|
||||||
|
``unavailable_reason`` carries a short human-readable hint when False
|
||||||
|
or when a usable loopback device was not found — useful on Linux where
|
||||||
|
PulseAudio/PipeWire may not expose monitor sources to a headless
|
||||||
|
systemd-as-user service.
|
||||||
|
"""
|
||||||
|
import platform as _platform
|
||||||
|
|
||||||
from ..services.audio_analyzer import get_audio_analyzer
|
from ..services.audio_analyzer import get_audio_analyzer
|
||||||
|
|
||||||
analyzer = get_audio_analyzer()
|
analyzer = get_audio_analyzer()
|
||||||
|
reason: str | None = None
|
||||||
|
if not analyzer.available:
|
||||||
|
reason = "soundcard or numpy is not installed"
|
||||||
|
elif getattr(analyzer, "_unavailable", False):
|
||||||
|
if _platform.system() == "Linux":
|
||||||
|
reason = (
|
||||||
|
"No loopback audio device found. On Linux this requires a "
|
||||||
|
"running PulseAudio/PipeWire session with monitor sources "
|
||||||
|
"(systemd user service: ensure DBUS_SESSION_BUS_ADDRESS + "
|
||||||
|
"XDG_RUNTIME_DIR are set)."
|
||||||
|
)
|
||||||
|
elif _platform.system() == "Darwin":
|
||||||
|
reason = (
|
||||||
|
"No loopback audio device found. macOS does not expose system "
|
||||||
|
"loopback by default — install BlackHole or Soundflower."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
reason = "No loopback audio device found"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"available": analyzer.available,
|
"available": analyzer.available,
|
||||||
"running": analyzer.running,
|
"running": analyzer.running,
|
||||||
"current_device": analyzer.current_device,
|
"current_device": analyzer.current_device,
|
||||||
|
"unavailable_reason": reason,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -285,7 +371,7 @@ async def visualizer_devices(_: str = Depends(verify_token)) -> list[dict[str, s
|
|||||||
"""List available loopback audio devices for the visualizer."""
|
"""List available loopback audio devices for the visualizer."""
|
||||||
from ..services.audio_analyzer import AudioAnalyzer
|
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)
|
return await loop.run_in_executor(None, AudioAnalyzer.list_loopback_devices)
|
||||||
|
|
||||||
|
|
||||||
@@ -323,12 +409,17 @@ async def set_visualizer_device(
|
|||||||
@router.websocket("/ws")
|
@router.websocket("/ws")
|
||||||
async def websocket_endpoint(
|
async def websocket_endpoint(
|
||||||
websocket: WebSocket,
|
websocket: WebSocket,
|
||||||
token: str | None = Query(None, description="API authentication token"),
|
token: str | None = Query(None, description="API authentication token (legacy)"),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""WebSocket endpoint for real-time media status updates.
|
"""WebSocket endpoint for real-time media status updates.
|
||||||
|
|
||||||
Authentication is done via query parameter since WebSocket
|
Authentication is accepted from two sources, in priority order:
|
||||||
doesn't support custom headers in the browser.
|
1. ``Sec-WebSocket-Protocol`` subprotocol of the form
|
||||||
|
``media-server.token.<TOKEN>``. This is the preferred path because
|
||||||
|
the token never lands in the URL, request logs, or browser history.
|
||||||
|
The browser WebSocket API supports custom subprotocols natively.
|
||||||
|
2. ``?token=<TOKEN>`` query parameter (legacy, kept for back-compat
|
||||||
|
with older clients and the HA integration).
|
||||||
|
|
||||||
Messages sent to client:
|
Messages sent to client:
|
||||||
- {"type": "status", "data": {...}} - Initial status on connect
|
- {"type": "status", "data": {...}} - Initial status on connect
|
||||||
@@ -339,11 +430,54 @@ async def websocket_endpoint(
|
|||||||
- {"type": "ping"} - Keepalive, server responds with {"type": "pong"}
|
- {"type": "ping"} - Keepalive, server responds with {"type": "pong"}
|
||||||
- {"type": "get_status"} - Request current status
|
- {"type": "get_status"} - Request current status
|
||||||
"""
|
"""
|
||||||
|
# Pull token from subprotocol if present. WebSocket spec lets either side
|
||||||
|
# negotiate exactly one subprotocol back; we accept the token one and
|
||||||
|
# answer with the same string so browsers consider the negotiation
|
||||||
|
# successful.
|
||||||
|
subprotocol_token: str | None = None
|
||||||
|
accept_subprotocol: str | None = None
|
||||||
|
raw_protocols = websocket.headers.get("sec-websocket-protocol", "")
|
||||||
|
for proto in (p.strip() for p in raw_protocols.split(",") if p.strip()):
|
||||||
|
if proto.startswith("media-server.token."):
|
||||||
|
subprotocol_token = proto[len("media-server.token."):]
|
||||||
|
accept_subprotocol = proto
|
||||||
|
break
|
||||||
|
effective_token = subprotocol_token or token
|
||||||
|
# Origin check — block CSWSH from third-party LAN pages. Accept the same
|
||||||
|
# set of origins as CORS plus the default localhost loopback, AND any
|
||||||
|
# same-origin connection (where Origin matches the request's Host header).
|
||||||
|
# Same-origin is inherently safe from CSWSH because CSWSH is a *cross*-
|
||||||
|
# origin attack — without this, binding to 0.0.0.0 and accessing the UI
|
||||||
|
# via a LAN IP would have its WebSocket rejected by the browser-sent
|
||||||
|
# Origin, which the static allowlist can't anticipate.
|
||||||
|
allowed_origins = set(
|
||||||
|
settings.cors_origins
|
||||||
|
or [
|
||||||
|
f"http://localhost:{settings.port}",
|
||||||
|
f"http://127.0.0.1:{settings.port}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
origin = websocket.headers.get("origin")
|
||||||
|
# Same-origin connections from native apps may omit Origin entirely; only
|
||||||
|
# reject when an Origin is present AND not in the allow-list.
|
||||||
|
if origin is not None and origin not in allowed_origins:
|
||||||
|
host_header = websocket.headers.get("host", "")
|
||||||
|
# Origin uses http/https; match against both scheme variants of Host
|
||||||
|
# so HTTPS deployments without an explicit cors_origins still work.
|
||||||
|
same_origin_candidates = (
|
||||||
|
{f"http://{host_header}", f"https://{host_header}"}
|
||||||
|
if host_header
|
||||||
|
else set()
|
||||||
|
)
|
||||||
|
if origin not in same_origin_candidates:
|
||||||
|
await websocket.close(code=4003, reason="Origin not allowed")
|
||||||
|
return
|
||||||
|
|
||||||
# Verify token
|
# Verify token
|
||||||
from ..auth import auth_enabled, get_token_label, token_label_var
|
from ..auth import auth_enabled, get_token_label, token_label_var
|
||||||
|
|
||||||
if auth_enabled():
|
if auth_enabled():
|
||||||
label = get_token_label(token) if token else None
|
label = get_token_label(effective_token) if effective_token else None
|
||||||
if label is None:
|
if label is None:
|
||||||
await websocket.close(code=4001, reason="Invalid authentication token")
|
await websocket.close(code=4001, reason="Invalid authentication token")
|
||||||
return
|
return
|
||||||
@@ -351,6 +485,13 @@ async def websocket_endpoint(
|
|||||||
else:
|
else:
|
||||||
token_label_var.set("anonymous")
|
token_label_var.set("anonymous")
|
||||||
|
|
||||||
|
# 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)
|
await ws_manager.connect(websocket)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -358,9 +499,11 @@ async def websocket_endpoint(
|
|||||||
# Wait for messages from client (for keepalive/ping)
|
# Wait for messages from client (for keepalive/ping)
|
||||||
data = await websocket.receive_json()
|
data = await websocket.receive_json()
|
||||||
|
|
||||||
if data.get("type") == "ping":
|
msg_type = data.get("type") if isinstance(data, dict) else None
|
||||||
|
|
||||||
|
if msg_type == "ping":
|
||||||
await websocket.send_json({"type": "pong"})
|
await websocket.send_json({"type": "pong"})
|
||||||
elif data.get("type") == "get_status":
|
elif msg_type == "get_status":
|
||||||
# Allow manual status request
|
# Allow manual status request
|
||||||
controller = get_media_controller()
|
controller = get_media_controller()
|
||||||
status_data = await controller.get_status()
|
status_data = await controller.get_status()
|
||||||
@@ -368,15 +511,20 @@ async def websocket_endpoint(
|
|||||||
"type": "status",
|
"type": "status",
|
||||||
"data": status_data.model_dump(),
|
"data": status_data.model_dump(),
|
||||||
})
|
})
|
||||||
elif data.get("type") == "volume":
|
elif msg_type == "volume":
|
||||||
# Low-latency volume control via WebSocket
|
# Low-latency volume control via WebSocket. Coerce, clamp, and
|
||||||
volume = data.get("volume")
|
# never drop the socket on a single bad message — that would
|
||||||
if volume is not None:
|
# turn the WS into a one-shot DoS for any holder of a token.
|
||||||
|
try:
|
||||||
|
volume = int(data.get("volume"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
volume = max(0, min(100, volume))
|
||||||
controller = get_media_controller()
|
controller = get_media_controller()
|
||||||
await controller.set_volume(int(volume))
|
await controller.set_volume(volume)
|
||||||
elif data.get("type") == "enable_visualizer":
|
elif msg_type == "enable_visualizer":
|
||||||
await ws_manager.subscribe_visualizer(websocket)
|
await ws_manager.subscribe_visualizer(websocket)
|
||||||
elif data.get("type") == "disable_visualizer":
|
elif msg_type == "disable_visualizer":
|
||||||
await ws_manager.unsubscribe_visualizer(websocket)
|
await ws_manager.unsubscribe_visualizer(websocket)
|
||||||
|
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
|
|||||||
@@ -2,18 +2,22 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from ..auth import verify_token
|
from ..auth import verify_token
|
||||||
from ..config import ScriptConfig, ScriptParameterConfig, settings
|
from ..config import ScriptConfig, ScriptParameterConfig, settings
|
||||||
from ..config_manager import config_manager
|
from ..config_manager import config_manager
|
||||||
|
from ..services.rate_limit import check as ratelimit_check
|
||||||
|
from ..services.rate_limit import get_peer
|
||||||
from ..services.websocket_manager import ws_manager
|
from ..services.websocket_manager import ws_manager
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/scripts", tags=["scripts"])
|
router = APIRouter(prefix="/api/scripts", tags=["scripts"])
|
||||||
@@ -23,6 +27,36 @@ _script_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="script"
|
|||||||
logger = logging.getLogger(__name__)
|
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):
|
class ScriptExecuteRequest(BaseModel):
|
||||||
"""Request model for script execution with optional parameters."""
|
"""Request model for script execution with optional parameters."""
|
||||||
|
|
||||||
@@ -197,6 +231,28 @@ def _validate_params(
|
|||||||
# string — just convert to str
|
# string — just convert to str
|
||||||
value = str(value)
|
value = str(value)
|
||||||
|
|
||||||
|
# Optional regex constraint, validated against the *string form* of the
|
||||||
|
# value. This is the only practical defence for string parameters that
|
||||||
|
# flow into shell=true scripts via env vars (Windows cmd.exe expands
|
||||||
|
# `%VAR%` after argument parsing, so embedded `&`/`|`/`%` would inject
|
||||||
|
# commands). Authors of shell scripts should ALWAYS define a pattern.
|
||||||
|
if pdef.pattern:
|
||||||
|
try:
|
||||||
|
if not re.fullmatch(pdef.pattern, str(value)):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=(
|
||||||
|
f"Parameter '{pname}' value {value!r} does not match"
|
||||||
|
f" required pattern: {pdef.pattern}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except re.error as e:
|
||||||
|
# Bad pattern in config — fail closed.
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Parameter '{pname}' has invalid pattern: {e}",
|
||||||
|
) from e
|
||||||
|
|
||||||
env_vars[f"SCRIPT_PARAM_{pname.upper()}"] = str(value)
|
env_vars[f"SCRIPT_PARAM_{pname.upper()}"] = str(value)
|
||||||
|
|
||||||
return env_vars
|
return env_vars
|
||||||
@@ -205,6 +261,7 @@ def _validate_params(
|
|||||||
@router.post("/execute/{script_name}")
|
@router.post("/execute/{script_name}")
|
||||||
async def execute_script(
|
async def execute_script(
|
||||||
script_name: str,
|
script_name: str,
|
||||||
|
http_request: Request,
|
||||||
request: ScriptExecuteRequest | None = None,
|
request: ScriptExecuteRequest | None = None,
|
||||||
_: str = Depends(verify_token),
|
_: str = Depends(verify_token),
|
||||||
) -> ScriptExecuteResponse:
|
) -> ScriptExecuteResponse:
|
||||||
@@ -217,6 +274,16 @@ async def execute_script(
|
|||||||
Returns:
|
Returns:
|
||||||
Execution result including stdout, stderr, and exit code
|
Execution result including stdout, stderr, and exit code
|
||||||
"""
|
"""
|
||||||
|
# Rate-limit script execution per peer so a leaked token can't be used to
|
||||||
|
# spam the shell-exec endpoint.
|
||||||
|
allowed, retry_after = ratelimit_check("execute", get_peer(http_request))
|
||||||
|
if not allowed:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="Too many script executions, slow down",
|
||||||
|
headers={"Retry-After": str(int(retry_after or 60))},
|
||||||
|
)
|
||||||
|
|
||||||
# Check if script exists
|
# Check if script exists
|
||||||
if script_name not in settings.scripts:
|
if script_name not in settings.scripts:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -231,9 +298,11 @@ async def execute_script(
|
|||||||
|
|
||||||
logger.info(f"Executing script: {script_name}")
|
logger.info(f"Executing script: {script_name}")
|
||||||
|
|
||||||
|
from ..services.audit_log import record_script_execution
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Execute in dedicated thread pool to not block the default executor
|
# Execute in dedicated thread pool to not block the default executor
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_running_loop()
|
||||||
result = await loop.run_in_executor(
|
result = await loop.run_in_executor(
|
||||||
_script_executor,
|
_script_executor,
|
||||||
lambda: _run_script(
|
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(
|
return ScriptExecuteResponse(
|
||||||
success=result["exit_code"] == 0,
|
success=result["exit_code"] == 0,
|
||||||
script=script_name,
|
script=script_name,
|
||||||
@@ -256,6 +334,13 @@ async def execute_script(
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Script execution error: {e}")
|
logger.error(f"Script execution error: {e}")
|
||||||
|
record_script_execution(
|
||||||
|
kind="script",
|
||||||
|
name=script_name,
|
||||||
|
exit_code=None,
|
||||||
|
duration=None,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
return ScriptExecuteResponse(
|
return ScriptExecuteResponse(
|
||||||
success=False,
|
success=False,
|
||||||
script=script_name,
|
script=script_name,
|
||||||
@@ -285,17 +370,38 @@ def _run_script(
|
|||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
env = None
|
env = None
|
||||||
if extra_env:
|
if extra_env:
|
||||||
import os
|
|
||||||
env = {**os.environ, **extra_env}
|
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:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
command,
|
run_command,
|
||||||
shell=shell,
|
shell=shell,
|
||||||
cwd=working_dir,
|
cwd=working_dir,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
env=env,
|
env=env,
|
||||||
|
**popen_kwargs,
|
||||||
)
|
)
|
||||||
execution_time = time.time() - start_time
|
execution_time = time.time() - start_time
|
||||||
return {
|
return {
|
||||||
@@ -455,7 +561,7 @@ async def create_script(
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: If script already exists or name is invalid.
|
HTTPException: If script already exists or name is invalid.
|
||||||
"""
|
"""
|
||||||
# Validate name
|
_require_scripts_management()
|
||||||
_validate_script_name(script_name)
|
_validate_script_name(script_name)
|
||||||
|
|
||||||
# Check if script already exists
|
# Check if script already exists
|
||||||
@@ -511,7 +617,7 @@ async def update_script(
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: If script does not exist.
|
HTTPException: If script does not exist.
|
||||||
"""
|
"""
|
||||||
# Validate name
|
_require_scripts_management()
|
||||||
_validate_script_name(script_name)
|
_validate_script_name(script_name)
|
||||||
|
|
||||||
# Check if script exists
|
# Check if script exists
|
||||||
@@ -565,7 +671,7 @@ async def delete_script(
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: If script does not exist.
|
HTTPException: If script does not exist.
|
||||||
"""
|
"""
|
||||||
# Validate name
|
_require_scripts_management()
|
||||||
_validate_script_name(script_name)
|
_validate_script_name(script_name)
|
||||||
|
|
||||||
# Check if script exists
|
# Check if script exists
|
||||||
|
|||||||
@@ -57,25 +57,38 @@ install_service() {
|
|||||||
# Create installation directory
|
# Create installation directory
|
||||||
mkdir -p "$INSTALL_DIR"
|
mkdir -p "$INSTALL_DIR"
|
||||||
|
|
||||||
# Copy source files
|
# Resolve the source-tree root (two levels up from this script:
|
||||||
cp -r "$(dirname "$0")/../"* "$INSTALL_DIR/"
|
# media_server/service/install_linux.sh → repo root).
|
||||||
|
SOURCE_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
|
||||||
|
# Copy the source tree (pyproject.toml + media_server/ package)
|
||||||
|
cp -r "$SOURCE_ROOT/." "$INSTALL_DIR/"
|
||||||
|
|
||||||
# Create virtual environment
|
# Create virtual environment
|
||||||
echo_info "Creating Python virtual environment..."
|
echo_info "Creating Python virtual environment..."
|
||||||
python3 -m venv "$INSTALL_DIR/venv"
|
python3 -m venv "$INSTALL_DIR/venv"
|
||||||
|
|
||||||
# Install Python dependencies
|
# Install Python dependencies from pyproject.toml (with linux extra).
|
||||||
|
# cd into the install dir so pip resolves `.[linux]` against the local
|
||||||
|
# pyproject — passing a path-with-extras (`$INSTALL_DIR[linux]`) trips
|
||||||
|
# the requirement-spec parser on some pip versions.
|
||||||
echo_info "Installing Python dependencies..."
|
echo_info "Installing Python dependencies..."
|
||||||
"$INSTALL_DIR/venv/bin/pip" install --upgrade pip
|
"$INSTALL_DIR/venv/bin/pip" install --upgrade pip
|
||||||
"$INSTALL_DIR/venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt"
|
(cd "$INSTALL_DIR" && "$INSTALL_DIR/venv/bin/pip" install ".[linux]")
|
||||||
|
|
||||||
# Install systemd service file
|
# Install systemd service file (templated unit)
|
||||||
echo_info "Installing systemd service..."
|
echo_info "Installing systemd service..."
|
||||||
cp "$INSTALL_DIR/service/media-server.service" "$SERVICE_FILE"
|
cp "$INSTALL_DIR/media_server/service/media-server.service" "$SERVICE_FILE"
|
||||||
|
|
||||||
# Reload systemd
|
# Reload systemd
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
# Pre-create writable state dirs so the unit's ReadWritePaths grant
|
||||||
|
# actually has something to grant.
|
||||||
|
sudo -u "$SUDO_USER" mkdir -p \
|
||||||
|
"/home/$SUDO_USER/.config/media-server" \
|
||||||
|
"/home/$SUDO_USER/.cache/media-server"
|
||||||
|
|
||||||
# Generate config if not exists
|
# Generate config if not exists
|
||||||
if [[ ! -f "/home/$SUDO_USER/.config/media-server/config.yaml" ]]; then
|
if [[ ! -f "/home/$SUDO_USER/.config/media-server/config.yaml" ]]; then
|
||||||
echo_info "Generating configuration file..."
|
echo_info "Generating configuration file..."
|
||||||
|
|||||||
@@ -3,34 +3,38 @@ Description=Media Server - REST API for controlling system media playback
|
|||||||
After=network.target sound.target
|
After=network.target sound.target
|
||||||
Wants=sound.target
|
Wants=sound.target
|
||||||
|
|
||||||
|
# Templated unit. Enable as:
|
||||||
|
# sudo systemctl enable --now media-server@$USER
|
||||||
|
# %i is the user name supplied after the '@'; %U is the matching numeric UID.
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=%i
|
User=%i
|
||||||
Group=%i
|
Group=%i
|
||||||
|
|
||||||
# Environment variables (optional - can also use config file)
|
# Working directory (override via drop-in if you install elsewhere)
|
||||||
# Environment=MEDIA_SERVER_HOST=0.0.0.0
|
|
||||||
# Environment=MEDIA_SERVER_PORT=8765
|
|
||||||
# Environment=MEDIA_SERVER_API_TOKEN=your-secret-token
|
|
||||||
|
|
||||||
# Working directory
|
|
||||||
WorkingDirectory=/opt/media-server
|
WorkingDirectory=/opt/media-server
|
||||||
|
|
||||||
# Start command - adjust path to your Python environment
|
# Start command — adjust to match where you installed the venv. --no-tray
|
||||||
ExecStart=/opt/media-server/venv/bin/python -m media_server.main
|
# avoids pulling pystray into a headless service environment.
|
||||||
|
ExecStart=/opt/media-server/venv/bin/python -m media_server.main --no-tray
|
||||||
|
|
||||||
# Restart policy
|
Restart=on-failure
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
RestartSec=10
|
||||||
|
|
||||||
# Security hardening
|
# Required for MPRIS (dbus.SessionBus) and PulseAudio/PipeWire loopback.
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
Environment=XDG_RUNTIME_DIR=/run/user/%U
|
||||||
|
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%U/bus
|
||||||
|
|
||||||
|
# Light sandboxing. ProtectHome=read-only by itself blocks the app's own
|
||||||
|
# audit.log / thumbnail cache writes — ReadWritePaths re-opens just the
|
||||||
|
# two state dirs the server owns.
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ProtectHome=read-only
|
ProtectHome=read-only
|
||||||
PrivateTmp=true
|
ReadWritePaths=/home/%i/.config/media-server /home/%i/.cache/media-server
|
||||||
|
|
||||||
# Required for D-Bus access (MPRIS)
|
|
||||||
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%U/bus
|
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@@ -65,7 +65,12 @@ def get_media_controller() -> "MediaController":
|
|||||||
|
|
||||||
|
|
||||||
def get_current_album_art() -> bytes | None:
|
def get_current_album_art() -> bytes | None:
|
||||||
"""Get the current album art bytes (Windows only for now)."""
|
"""Get the current album art bytes (synchronous, Windows-cached path).
|
||||||
|
|
||||||
|
Windows pre-populates a module-level cache via the WinRT polling thread,
|
||||||
|
so this stays sync. For Linux/macOS the controller fetches on demand —
|
||||||
|
use ``get_current_album_art_async()`` from FastAPI handlers instead.
|
||||||
|
"""
|
||||||
system = platform.system()
|
system = platform.system()
|
||||||
if system == "Windows":
|
if system == "Windows":
|
||||||
from .windows_media import get_current_album_art as _get_art
|
from .windows_media import get_current_album_art as _get_art
|
||||||
@@ -73,6 +78,22 @@ def get_current_album_art() -> bytes | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_album_art_async() -> bytes | None:
|
||||||
|
"""Cross-platform album art fetch. Awaits the controller's impl.
|
||||||
|
|
||||||
|
Falls back to the sync Windows cache when running on Windows so we don't
|
||||||
|
pay an extra coroutine hop for the existing path.
|
||||||
|
"""
|
||||||
|
system = platform.system()
|
||||||
|
if system == "Windows":
|
||||||
|
return get_current_album_art()
|
||||||
|
controller = get_media_controller()
|
||||||
|
try:
|
||||||
|
return await controller.get_album_art()
|
||||||
|
except Exception: # noqa: BLE001 — art is best-effort; never break the route
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_audio_devices() -> list[dict[str, str]]:
|
def get_audio_devices() -> list[dict[str, str]]:
|
||||||
"""Get list of available audio output devices (Windows only for now)."""
|
"""Get list of available audio output devices (Windows only for now)."""
|
||||||
system = platform.system()
|
system = platform.system()
|
||||||
@@ -82,4 +103,9 @@ def get_audio_devices() -> list[dict[str, str]]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["get_media_controller", "get_current_album_art", "get_audio_devices"]
|
__all__ = [
|
||||||
|
"get_media_controller",
|
||||||
|
"get_current_album_art",
|
||||||
|
"get_current_album_art_async",
|
||||||
|
"get_audio_devices",
|
||||||
|
]
|
||||||
|
|||||||
@@ -72,6 +72,11 @@ class AudioAnalyzer:
|
|||||||
self._lifecycle_lock = threading.Lock()
|
self._lifecycle_lock = threading.Lock()
|
||||||
self._data: dict | None = None
|
self._data: dict | None = None
|
||||||
self._current_device_name: str | 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.
|
# Generation counter — bumped each time _data is refreshed.
|
||||||
# Lets the broadcast loop dedupe without comparing dict identity
|
# Lets the broadcast loop dedupe without comparing dict identity
|
||||||
# (which is fragile because we always allocate a new dict).
|
# (which is fragile because we always allocate a new dict).
|
||||||
@@ -123,6 +128,10 @@ class AudioAnalyzer:
|
|||||||
return True
|
return True
|
||||||
if not self.available:
|
if not self.available:
|
||||||
return False
|
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
|
# Reset AGC envelope so a long silent gap between sessions
|
||||||
# doesn't make the first new transients clip at the ceiling.
|
# doesn't make the first new transients clip at the ceiling.
|
||||||
@@ -235,6 +244,9 @@ class AudioAnalyzer:
|
|||||||
|
|
||||||
self.device_name = device_name
|
self.device_name = device_name
|
||||||
self._current_device_name = None
|
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:
|
if was_running:
|
||||||
return self.start()
|
return self.start()
|
||||||
@@ -269,6 +281,7 @@ class AudioAnalyzer:
|
|||||||
if device is None:
|
if device is None:
|
||||||
logger.warning("No loopback audio device found - visualizer disabled")
|
logger.warning("No loopback audio device found - visualizer disabled")
|
||||||
self._running = False
|
self._running = False
|
||||||
|
self._unavailable = True
|
||||||
return
|
return
|
||||||
|
|
||||||
interval = 1.0 / self.target_fps
|
interval = 1.0 / self.target_fps
|
||||||
|
|||||||
@@ -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():
|
if not base_path.is_dir():
|
||||||
raise ValueError(f"Media folder path is not a directory: {base_path}")
|
raise ValueError(f"Media folder path is not a directory: {base_path}")
|
||||||
|
|
||||||
# Handle relative vs absolute paths
|
# Reject absolute paths, drive letters, UNC paths, and NUL bytes outright.
|
||||||
if requested_path.startswith("/") or requested_path.startswith("\\"):
|
# Only true folder-relative paths are accepted.
|
||||||
# Relative to folder root (remove leading slash)
|
if "\x00" in requested_path:
|
||||||
requested_path = requested_path.lstrip("/\\")
|
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
|
# Build and resolve full path
|
||||||
if requested_path:
|
if cleaned:
|
||||||
full_path = (base_path / requested_path).resolve()
|
full_path = (base_path / cleaned).resolve()
|
||||||
else:
|
else:
|
||||||
full_path = base_path
|
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
|
||||||
import ctypes.wintypes
|
import ctypes.wintypes
|
||||||
@@ -6,10 +6,33 @@ import logging
|
|||||||
import platform
|
import platform
|
||||||
import struct
|
import struct
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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
|
_sbc = None
|
||||||
_monitorcontrol = None
|
_monitorcontrol = None
|
||||||
|
|
||||||
@@ -32,7 +55,7 @@ def _load_monitorcontrol():
|
|||||||
import monitorcontrol
|
import monitorcontrol
|
||||||
_monitorcontrol = monitorcontrol
|
_monitorcontrol = monitorcontrol
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning("monitorcontrol not installed - display power control unavailable")
|
logger.warning("monitorcontrol not installed - DDC/CI control unavailable")
|
||||||
return _monitorcontrol
|
return _monitorcontrol
|
||||||
|
|
||||||
|
|
||||||
@@ -64,6 +87,18 @@ class MonitorInfo:
|
|||||||
manufacturer: str = ""
|
manufacturer: str = ""
|
||||||
resolution: str | None = None
|
resolution: str | None = None
|
||||||
is_primary: bool = False
|
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:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
@@ -76,6 +111,18 @@ class MonitorInfo:
|
|||||||
"manufacturer": self.manufacturer,
|
"manufacturer": self.manufacturer,
|
||||||
"resolution": self.resolution,
|
"resolution": self.resolution,
|
||||||
"is_primary": self.is_primary,
|
"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
|
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
|
_monitor_cache: list[MonitorInfo] | None = None
|
||||||
_cache_time: float = 0
|
_cache_time: float = 0
|
||||||
_CACHE_TTL = 5.0 # seconds
|
_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
|
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
|
return _monitor_cache
|
||||||
|
|
||||||
sbc = _load_sbc()
|
sbc = _load_sbc()
|
||||||
@@ -159,7 +373,12 @@ def list_monitors(force_refresh: bool = False) -> list[MonitorInfo]:
|
|||||||
info_list = sbc.list_monitors_info()
|
info_list = sbc.list_monitors_info()
|
||||||
brightnesses = sbc.get_brightness()
|
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()
|
mc = _load_monitorcontrol()
|
||||||
ddc_monitors = []
|
ddc_monitors = []
|
||||||
if mc:
|
if mc:
|
||||||
@@ -168,6 +387,9 @@ def list_monitors(force_refresh: bool = False) -> list[MonitorInfo]:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
seen_keys: set[tuple] = set()
|
||||||
for i, info in enumerate(info_list):
|
for i, info in enumerate(info_list):
|
||||||
name = info.get("name", f"Monitor {i}")
|
name = info.get("name", f"Monitor {i}")
|
||||||
model = info.get("model", "")
|
model = info.get("model", "")
|
||||||
@@ -181,26 +403,66 @@ def list_monitors(force_refresh: bool = False) -> list[MonitorInfo]:
|
|||||||
edid = info.get("edid", "")
|
edid = info.get("edid", "")
|
||||||
resolution = _parse_edid_resolution(edid) if edid else None
|
resolution = _parse_edid_resolution(edid) if edid else None
|
||||||
|
|
||||||
# Read power state via DDC/CI
|
# Stable cache key — EDID hash is unique per physical monitor.
|
||||||
power_on = True
|
# 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):
|
if power_supported and i < len(ddc_monitors):
|
||||||
try:
|
try:
|
||||||
with ddc_monitors[i] as mon:
|
with ddc_monitors[i] as mon:
|
||||||
power_mode = mon.get_power_mode()
|
if cache_key not in _static_cache:
|
||||||
power_on = power_mode == mc.PowerMode.on
|
_static_cache[cache_key] = _probe_static_open(mon, mc, i)
|
||||||
except Exception:
|
static = _static_cache[cache_key]
|
||||||
pass
|
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(
|
monitors.append(MonitorInfo(
|
||||||
id=i,
|
id=i,
|
||||||
name=name,
|
name=name,
|
||||||
brightness=brightness,
|
brightness=brightness,
|
||||||
power_supported=power_supported,
|
power_supported=power_supported,
|
||||||
power_on=power_on,
|
power_on=dynamic.get("power_on", True),
|
||||||
model=model,
|
model=model,
|
||||||
manufacturer=manufacturer,
|
manufacturer=manufacturer,
|
||||||
resolution=resolution,
|
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:
|
except Exception as e:
|
||||||
logger.error("Failed to enumerate monitors: %s", 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))
|
value = max(0, min(100, value))
|
||||||
try:
|
try:
|
||||||
sbc.set_brightness(value, display=monitor_id)
|
sbc.set_brightness(value, display=monitor_id)
|
||||||
# Invalidate cache
|
_invalidate_cache()
|
||||||
global _monitor_cache
|
|
||||||
_monitor_cache = None
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to set brightness for monitor %d: %s", monitor_id, 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:
|
else:
|
||||||
monitor.set_power_mode(mc.PowerMode.off_soft)
|
monitor.set_power_mode(mc.PowerMode.off_soft)
|
||||||
|
|
||||||
# Invalidate cache
|
_invalidate_cache()
|
||||||
global _monitor_cache
|
|
||||||
_monitor_cache = None
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to set power for monitor %d: %s", monitor_id, e)
|
logger.error("Failed to set power for monitor %d: %s", monitor_id, e)
|
||||||
return False
|
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 json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -15,6 +16,11 @@ _DEFAULT_BASE_URL = "https://git.dolgolyov-family.by"
|
|||||||
_DEFAULT_OWNER = "alexei.dolgolyov"
|
_DEFAULT_OWNER = "alexei.dolgolyov"
|
||||||
_DEFAULT_REPO = "media-player-server"
|
_DEFAULT_REPO = "media-player-server"
|
||||||
|
|
||||||
|
# Restrictive tag whitelist — prevents a hostile Gitea response (or MITM) from
|
||||||
|
# injecting `..`, slashes, or URL-altering characters into the release URL we
|
||||||
|
# broadcast to clients. SemVer + pre-release suffix only.
|
||||||
|
_TAG_RE = re.compile(r"^v?\d+\.\d+\.\d+(?:[\w.\-+]{0,32})?$")
|
||||||
|
|
||||||
|
|
||||||
class GiteaReleaseProvider(ReleaseProvider):
|
class GiteaReleaseProvider(ReleaseProvider):
|
||||||
"""Fetches the latest release from a Gitea repository."""
|
"""Fetches the latest release from a Gitea repository."""
|
||||||
@@ -53,6 +59,9 @@ class GiteaReleaseProvider(ReleaseProvider):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
tag = release.get("tag_name", "")
|
tag = release.get("tag_name", "")
|
||||||
|
if not isinstance(tag, str) or not _TAG_RE.match(tag):
|
||||||
|
logger.warning("Rejecting malformed release tag from upstream: %r", tag)
|
||||||
|
continue
|
||||||
version = tag.lstrip("v")
|
version = tag.lstrip("v")
|
||||||
if not version:
|
if not version:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -2,14 +2,23 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import threading
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
from ..models import MediaState, MediaStatus
|
from ..models import MediaState, MediaStatus
|
||||||
from .media_controller import MediaController
|
from .media_controller import MediaController
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Cap remote artwork downloads so a hostile / huge Spotify image can't
|
||||||
|
# blow up RAM. Real album art rarely exceeds ~2 MB; 8 MB is a comfortable
|
||||||
|
# upper bound that also covers loss-less PNGs.
|
||||||
|
_MAX_ART_BYTES = 8 * 1024 * 1024
|
||||||
|
_ART_FETCH_TIMEOUT = 5.0 # seconds
|
||||||
|
|
||||||
# Linux-specific imports
|
# Linux-specific imports
|
||||||
try:
|
try:
|
||||||
import dbus
|
import dbus
|
||||||
@@ -35,13 +44,54 @@ class LinuxMediaController(MediaController):
|
|||||||
"Linux media control requires dbus-python package. "
|
"Linux media control requires dbus-python package. "
|
||||||
"Install with: sudo apt-get install python3-dbus"
|
"Install with: sudo apt-get install python3-dbus"
|
||||||
)
|
)
|
||||||
|
# The session-bus connection is deferred until first use. Connecting
|
||||||
|
# in __init__ raised during app startup whenever the user's session
|
||||||
|
# bus wasn't ready yet — common under systemd (service starts
|
||||||
|
# before logind set up /run/user/<uid>/bus), under SSH-without-X11,
|
||||||
|
# and in headless CI. Failing here killed the whole lifespan; now
|
||||||
|
# MPRIS calls return "idle" until the bus appears, and other
|
||||||
|
# endpoints (health, scripts, browser, …) keep working.
|
||||||
|
self._bus_lock = threading.Lock()
|
||||||
|
self._bus = None # type: ignore[assignment]
|
||||||
|
self._bus_init_logged = False
|
||||||
|
# Cached art bytes keyed by the mpris:artUrl currently in flight.
|
||||||
|
# Lock guards the swap from the status thread vs the artwork handler.
|
||||||
|
self._art_lock = threading.Lock()
|
||||||
|
self._art_url: Optional[str] = None
|
||||||
|
self._art_bytes: Optional[bytes] = None
|
||||||
|
|
||||||
|
def _get_bus(self):
|
||||||
|
"""Lazily connect to the session bus; returns None if unavailable."""
|
||||||
|
if self._bus is not None:
|
||||||
|
return self._bus
|
||||||
|
with self._bus_lock:
|
||||||
|
if self._bus is not None:
|
||||||
|
return self._bus
|
||||||
|
try:
|
||||||
DBusGMainLoop(set_as_default=True)
|
DBusGMainLoop(set_as_default=True)
|
||||||
self._bus = dbus.SessionBus()
|
self._bus = dbus.SessionBus()
|
||||||
|
logger.info("Connected to D-Bus session bus")
|
||||||
|
return self._bus
|
||||||
|
except Exception as e:
|
||||||
|
# Log once at INFO to avoid log spam if every status poll fails.
|
||||||
|
if not self._bus_init_logged:
|
||||||
|
logger.info(
|
||||||
|
"D-Bus session bus not available (%s). "
|
||||||
|
"MPRIS calls will return 'idle' until DBUS_SESSION_BUS_ADDRESS"
|
||||||
|
" is set and the bus is reachable. Under systemd, ensure"
|
||||||
|
" `loginctl enable-linger <user>` is set.",
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
self._bus_init_logged = True
|
||||||
|
return None
|
||||||
|
|
||||||
def _get_active_player(self) -> Optional[str]:
|
def _get_active_player(self) -> Optional[str]:
|
||||||
"""Find an active MPRIS media player on the bus."""
|
"""Find an active MPRIS media player on the bus."""
|
||||||
|
bus = self._get_bus()
|
||||||
|
if bus is None:
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
bus_names = self._bus.list_names()
|
bus_names = bus.list_names()
|
||||||
mpris_players = [
|
mpris_players = [
|
||||||
name for name in bus_names if name.startswith(self.MPRIS_PREFIX)
|
name for name in bus_names if name.startswith(self.MPRIS_PREFIX)
|
||||||
]
|
]
|
||||||
@@ -151,22 +201,19 @@ class LinuxMediaController(MediaController):
|
|||||||
logger.error(f"Failed to toggle mute: {e}")
|
logger.error(f"Failed to toggle mute: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def get_status(self) -> MediaStatus:
|
def _sync_get_status(self) -> MediaStatus:
|
||||||
"""Get current media playback status."""
|
"""Synchronous status read (called from a worker thread)."""
|
||||||
status = MediaStatus()
|
status = MediaStatus()
|
||||||
|
|
||||||
# Get system volume
|
|
||||||
volume, muted = self._get_volume_pulseaudio()
|
volume, muted = self._get_volume_pulseaudio()
|
||||||
status.volume = volume
|
status.volume = volume
|
||||||
status.muted = muted
|
status.muted = muted
|
||||||
|
|
||||||
# Get active player
|
|
||||||
player_name = self._get_active_player()
|
player_name = self._get_active_player()
|
||||||
if player_name is None:
|
if player_name is None:
|
||||||
status.state = MediaState.IDLE
|
status.state = MediaState.IDLE
|
||||||
return status
|
return status
|
||||||
|
|
||||||
# Get playback status
|
|
||||||
playback_status = self._get_property(player_name, "PlaybackStatus")
|
playback_status = self._get_property(player_name, "PlaybackStatus")
|
||||||
if playback_status == "Playing":
|
if playback_status == "Playing":
|
||||||
status.state = MediaState.PLAYING
|
status.state = MediaState.PLAYING
|
||||||
@@ -177,114 +224,78 @@ class LinuxMediaController(MediaController):
|
|||||||
else:
|
else:
|
||||||
status.state = MediaState.IDLE
|
status.state = MediaState.IDLE
|
||||||
|
|
||||||
# Get metadata
|
|
||||||
metadata = self._get_property(player_name, "Metadata")
|
metadata = self._get_property(player_name, "Metadata")
|
||||||
if metadata:
|
if metadata:
|
||||||
status.title = str(metadata.get("xesam:title", "")) or None
|
status.title = str(metadata.get("xesam:title", "")) or None
|
||||||
|
|
||||||
artists = metadata.get("xesam:artist", [])
|
artists = metadata.get("xesam:artist", [])
|
||||||
if artists:
|
if artists:
|
||||||
status.artist = str(artists[0]) if isinstance(artists, list) else str(artists)
|
status.artist = str(artists[0]) if isinstance(artists, list) else str(artists)
|
||||||
|
|
||||||
status.album = str(metadata.get("xesam:album", "")) or None
|
status.album = str(metadata.get("xesam:album", "")) or None
|
||||||
status.album_art_url = str(metadata.get("mpris:artUrl", "")) or None
|
art_url = str(metadata.get("mpris:artUrl", "")) or None
|
||||||
|
status.album_art_url = art_url
|
||||||
# Duration in microseconds
|
# Invalidate cached bytes when the track changes. Real fetch
|
||||||
|
# happens lazily in get_album_art() so the status hot path
|
||||||
|
# never blocks on HTTP.
|
||||||
|
with self._art_lock:
|
||||||
|
if art_url != self._art_url:
|
||||||
|
self._art_url = art_url
|
||||||
|
self._art_bytes = None
|
||||||
length = metadata.get("mpris:length", 0)
|
length = metadata.get("mpris:length", 0)
|
||||||
if length:
|
if length:
|
||||||
status.duration = int(length) / 1_000_000
|
status.duration = int(length) / 1_000_000
|
||||||
|
|
||||||
# Get position (in microseconds)
|
|
||||||
position = self._get_property(player_name, "Position")
|
position = self._get_property(player_name, "Position")
|
||||||
if position is not None:
|
if position is not None:
|
||||||
status.position = int(position) / 1_000_000
|
status.position = int(position) / 1_000_000
|
||||||
|
|
||||||
# Get source name
|
|
||||||
status.source = player_name.replace(self.MPRIS_PREFIX, "")
|
status.source = player_name.replace(self.MPRIS_PREFIX, "")
|
||||||
|
|
||||||
return status
|
return status
|
||||||
|
|
||||||
async def play(self) -> bool:
|
async def get_status(self) -> MediaStatus:
|
||||||
"""Resume playback."""
|
"""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()
|
player_name = self._get_active_player()
|
||||||
if player_name is None:
|
if player_name is None:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
player = self._get_player_interface(player_name)
|
player = self._get_player_interface(player_name)
|
||||||
player.Play()
|
getattr(player, method_name)()
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to play: {e}")
|
logger.error(f"Failed to call player.{method_name}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def play(self) -> bool:
|
||||||
|
return await asyncio.to_thread(self._call_player, "Play")
|
||||||
|
|
||||||
async def pause(self) -> bool:
|
async def pause(self) -> bool:
|
||||||
"""Pause playback."""
|
return await asyncio.to_thread(self._call_player, "Pause")
|
||||||
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
|
|
||||||
|
|
||||||
async def stop(self) -> bool:
|
async def stop(self) -> bool:
|
||||||
"""Stop playback."""
|
return await asyncio.to_thread(self._call_player, "Stop")
|
||||||
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
|
|
||||||
|
|
||||||
async def next_track(self) -> bool:
|
async def next_track(self) -> bool:
|
||||||
"""Skip to next track."""
|
return await asyncio.to_thread(self._call_player, "Next")
|
||||||
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
|
|
||||||
|
|
||||||
async def previous_track(self) -> bool:
|
async def previous_track(self) -> bool:
|
||||||
"""Go to previous track."""
|
return await asyncio.to_thread(self._call_player, "Previous")
|
||||||
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
|
|
||||||
|
|
||||||
async def set_volume(self, volume: int) -> bool:
|
async def set_volume(self, volume: int) -> bool:
|
||||||
"""Set system volume."""
|
return await asyncio.to_thread(self._set_volume_pulseaudio, volume)
|
||||||
return self._set_volume_pulseaudio(volume)
|
|
||||||
|
|
||||||
async def toggle_mute(self) -> bool:
|
async def toggle_mute(self) -> bool:
|
||||||
"""Toggle mute state."""
|
return await asyncio.to_thread(self._toggle_mute_pulseaudio)
|
||||||
return self._toggle_mute_pulseaudio()
|
|
||||||
|
|
||||||
async def seek(self, position: float) -> bool:
|
def _sync_seek(self, position: float) -> bool:
|
||||||
"""Seek to position in seconds."""
|
|
||||||
player_name = self._get_active_player()
|
player_name = self._get_active_player()
|
||||||
if player_name is None:
|
if player_name is None:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
player = self._get_player_interface(player_name)
|
player = self._get_player_interface(player_name)
|
||||||
# MPRIS expects position in microseconds
|
|
||||||
player.SetPosition(
|
player.SetPosition(
|
||||||
self._get_property(player_name, "Metadata").get("mpris:trackid", "/"),
|
self._get_property(player_name, "Metadata").get("mpris:trackid", "/"),
|
||||||
int(position * 1_000_000),
|
int(position * 1_000_000),
|
||||||
@@ -294,6 +305,9 @@ class LinuxMediaController(MediaController):
|
|||||||
logger.error(f"Failed to seek: {e}")
|
logger.error(f"Failed to seek: {e}")
|
||||||
return False
|
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:
|
async def open_file(self, file_path: str) -> bool:
|
||||||
"""Open a media file with the default system player (Linux).
|
"""Open a media file with the default system player (Linux).
|
||||||
|
|
||||||
@@ -317,3 +331,61 @@ class LinuxMediaController(MediaController):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to open file {file_path}: {e}")
|
logger.error(f"Failed to open file {file_path}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _fetch_art_sync(self, url: str) -> Optional[bytes]:
|
||||||
|
"""Resolve an ``mpris:artUrl`` to raw bytes (file://, http(s)://).
|
||||||
|
|
||||||
|
Other schemes (data:, ftp:, …) are rejected — we only support the
|
||||||
|
two cases real-world MPRIS providers use. The HTTP path is capped
|
||||||
|
at _MAX_ART_BYTES and the file path is read with a size guard so a
|
||||||
|
symlink to /dev/zero can't OOM the server.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
scheme = parsed.scheme.lower()
|
||||||
|
if scheme == "file":
|
||||||
|
path = unquote(parsed.path)
|
||||||
|
try:
|
||||||
|
size = os.stat(path).st_size
|
||||||
|
if size <= 0 or size > _MAX_ART_BYTES:
|
||||||
|
return None
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
return f.read(_MAX_ART_BYTES)
|
||||||
|
except OSError as e:
|
||||||
|
logger.debug("Could not read local art %s: %s", path, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if scheme in ("http", "https"):
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "media-server/0.x"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=_ART_FETCH_TIMEOUT) as resp:
|
||||||
|
# Cap reads to defend against unbounded responses.
|
||||||
|
return resp.read(_MAX_ART_BYTES + 1)[:_MAX_ART_BYTES] or None
|
||||||
|
except (urllib.error.URLError, urllib.error.HTTPError, OSError) as e:
|
||||||
|
logger.debug("Could not fetch remote art %s: %s", url, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.debug("Unsupported art URL scheme: %s", scheme)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_album_art(self) -> Optional[bytes]:
|
||||||
|
"""Return cached MPRIS art, fetching on first access per track."""
|
||||||
|
with self._art_lock:
|
||||||
|
url = self._art_url
|
||||||
|
cached = self._art_bytes
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
data = await asyncio.to_thread(self._fetch_art_sync, url)
|
||||||
|
# Store even on None so we don't re-hammer a 404 every second.
|
||||||
|
with self._art_lock:
|
||||||
|
if url == self._art_url:
|
||||||
|
self._art_bytes = data
|
||||||
|
return data
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import threading
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from ..models import MediaState, MediaStatus
|
from ..models import MediaState, MediaStatus
|
||||||
@@ -10,10 +11,20 @@ from .media_controller import MediaController
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Cap remote artwork downloads (Spotify's artwork url is http(s)://).
|
||||||
|
_MAX_ART_BYTES = 8 * 1024 * 1024
|
||||||
|
_ART_FETCH_TIMEOUT = 5.0 # seconds
|
||||||
|
|
||||||
|
|
||||||
class MacOSMediaController(MediaController):
|
class MacOSMediaController(MediaController):
|
||||||
"""Media controller for macOS using osascript and system commands."""
|
"""Media controller for macOS using osascript and system commands."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
# Cached art bytes keyed by the active art URL.
|
||||||
|
self._art_lock = threading.Lock()
|
||||||
|
self._art_url: Optional[str] = None
|
||||||
|
self._art_bytes: Optional[bytes] = None
|
||||||
|
|
||||||
def _run_osascript(self, script: str) -> Optional[str]:
|
def _run_osascript(self, script: str) -> Optional[str]:
|
||||||
"""Run an AppleScript and return the output."""
|
"""Run an AppleScript and return the output."""
|
||||||
try:
|
try:
|
||||||
@@ -193,12 +204,60 @@ class MacOSMediaController(MediaController):
|
|||||||
status.album = info.get("album")
|
status.album = info.get("album")
|
||||||
status.duration = info.get("duration")
|
status.duration = info.get("duration")
|
||||||
status.position = info.get("position")
|
status.position = info.get("position")
|
||||||
status.album_art_url = info.get("art_url")
|
art_url = info.get("art_url")
|
||||||
|
status.album_art_url = art_url
|
||||||
|
# Track changes invalidate the cached image bytes — actual
|
||||||
|
# fetch happens lazily in get_album_art().
|
||||||
|
with self._art_lock:
|
||||||
|
if art_url != self._art_url:
|
||||||
|
self._art_url = art_url
|
||||||
|
self._art_bytes = None
|
||||||
else:
|
else:
|
||||||
status.state = MediaState.IDLE
|
status.state = MediaState.IDLE
|
||||||
|
|
||||||
return status
|
return status
|
||||||
|
|
||||||
|
def _fetch_art_sync(self, url: str) -> Optional[bytes]:
|
||||||
|
"""Resolve a Spotify/Music art URL (http(s)://) to bytes.
|
||||||
|
|
||||||
|
File-scheme URLs aren't expected on macOS (AppleScript apps return
|
||||||
|
artwork as remote URLs), so only http(s) is supported.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
parsed = urlparse(url)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if parsed.scheme.lower() not in ("http", "https"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "media-server/0.x"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=_ART_FETCH_TIMEOUT) as resp:
|
||||||
|
return resp.read(_MAX_ART_BYTES + 1)[:_MAX_ART_BYTES] or None
|
||||||
|
except (urllib.error.URLError, urllib.error.HTTPError, OSError) as e:
|
||||||
|
logger.debug("Could not fetch macOS art %s: %s", url, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_album_art(self) -> Optional[bytes]:
|
||||||
|
"""Return cached art bytes, fetching once per track URL."""
|
||||||
|
with self._art_lock:
|
||||||
|
url = self._art_url
|
||||||
|
cached = self._art_bytes
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
data = await asyncio.to_thread(self._fetch_art_sync, url)
|
||||||
|
with self._art_lock:
|
||||||
|
if url == self._art_url:
|
||||||
|
self._art_bytes = data
|
||||||
|
return data
|
||||||
|
|
||||||
async def play(self) -> bool:
|
async def play(self) -> bool:
|
||||||
"""Resume playback using media key simulation."""
|
"""Resume playback using media key simulation."""
|
||||||
# Use system media key
|
# Use system media key
|
||||||
@@ -264,8 +323,12 @@ class MacOSMediaController(MediaController):
|
|||||||
|
|
||||||
async def set_volume(self, volume: int) -> bool:
|
async def set_volume(self, volume: int) -> bool:
|
||||||
"""Set system volume."""
|
"""Set system volume."""
|
||||||
result = self._run_osascript(f"set volume output volume {volume}")
|
# osascript returns empty string on success and None on failure (the
|
||||||
return result is not None or True # osascript returns empty on success
|
# _run_osascript helper catches subprocess errors). The previous
|
||||||
|
# `result is not None or True` always returned True regardless of
|
||||||
|
# outcome — surface real failures so the route can return 503.
|
||||||
|
result = self._run_osascript(f"set volume output volume {int(volume)}")
|
||||||
|
return result is not None
|
||||||
|
|
||||||
async def toggle_mute(self) -> bool:
|
async def toggle_mute(self) -> bool:
|
||||||
"""Toggle mute state."""
|
"""Toggle mute state."""
|
||||||
|
|||||||
@@ -106,3 +106,12 @@ class MediaController(ABC):
|
|||||||
True if successful, False otherwise
|
True if successful, False otherwise
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
async def get_album_art(self) -> bytes | None:
|
||||||
|
"""Return the current album art bytes, or ``None`` when unavailable.
|
||||||
|
|
||||||
|
Default impl returns ``None`` — controllers that can produce art
|
||||||
|
(Windows via SMTC thumbnail, Linux via mpris:artUrl, macOS via the
|
||||||
|
Spotify/Music artwork-url field) override this.
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|||||||
@@ -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:
|
def get_cache_dir() -> Path:
|
||||||
"""Get the thumbnail cache directory path.
|
"""Get the thumbnail cache directory path.
|
||||||
|
|
||||||
Returns:
|
Returns user-writable platform cache dir so installs under
|
||||||
Path to the cache directory (project-local).
|
``%PROGRAMFILES%`` / ``/opt`` work without elevated permissions.
|
||||||
|
Mirrors the platform branching of ``config.get_config_dir``.
|
||||||
"""
|
"""
|
||||||
# Store cache in project directory: media-server/.cache/thumbnails/
|
import os
|
||||||
project_root = Path(__file__).parent.parent.parent
|
|
||||||
cache_dir = project_root / ".cache" / "thumbnails"
|
if os.name == "nt":
|
||||||
|
# %LOCALAPPDATA% so the cache survives roaming-profile sync.
|
||||||
|
base = Path(os.environ.get("LOCALAPPDATA")
|
||||||
|
or os.environ.get("APPDATA")
|
||||||
|
or Path.home() / "AppData" / "Local")
|
||||||
|
cache_dir = base / "media-server" / "cache" / "thumbnails"
|
||||||
|
else:
|
||||||
|
# XDG_CACHE_HOME convention; falls back to ~/.cache.
|
||||||
|
xdg = os.environ.get("XDG_CACHE_HOME")
|
||||||
|
base = Path(xdg) if xdg else Path.home() / ".cache"
|
||||||
|
cache_dir = base / "media-server" / "thumbnails"
|
||||||
|
|
||||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
return cache_dir
|
return cache_dir
|
||||||
@@ -321,7 +332,7 @@ class ThumbnailService:
|
|||||||
|
|
||||||
if suffix in AUDIO_EXTENSIONS:
|
if suffix in AUDIO_EXTENSIONS:
|
||||||
# Audio files - run in executor (sync operation)
|
# Audio files - run in executor (sync operation)
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_running_loop()
|
||||||
thumbnail_data = await loop.run_in_executor(
|
thumbnail_data = await loop.run_in_executor(
|
||||||
None,
|
None,
|
||||||
ThumbnailService.generate_audio_thumbnail,
|
ThumbnailService.generate_audio_thumbnail,
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ class ConnectionManager:
|
|||||||
self._active_connections: set[WebSocket] = set()
|
self._active_connections: set[WebSocket] = set()
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
self._last_status: dict[str, Any] | None = None
|
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._get_status_func: Callable[[], Coroutine[Any, Any, Any]] | None = None
|
||||||
self._broadcast_task: asyncio.Task | None = None
|
self._broadcast_task: asyncio.Task | None = None
|
||||||
self._poll_interval: float = 0.5 # Internal poll interval for change detection
|
self._poll_interval: float = 0.5 # Internal poll interval for change detection
|
||||||
@@ -30,8 +33,14 @@ class ConnectionManager:
|
|||||||
self._audio_task: asyncio.Task | None = None
|
self._audio_task: asyncio.Task | None = None
|
||||||
self._audio_analyzer = None
|
self._audio_analyzer = None
|
||||||
|
|
||||||
async def connect(self, websocket: WebSocket) -> None:
|
async def connect(self, websocket: WebSocket, already_accepted: bool = False) -> None:
|
||||||
"""Accept a new WebSocket connection."""
|
"""Accept a new WebSocket connection.
|
||||||
|
|
||||||
|
``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()
|
await websocket.accept()
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
self._active_connections.add(websocket)
|
self._active_connections.add(websocket)
|
||||||
@@ -54,6 +63,18 @@ class ConnectionManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Failed to send initial status: %s", 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:
|
async def disconnect(self, websocket: WebSocket) -> None:
|
||||||
"""Remove a WebSocket connection. Stops audio capture if last visualizer subscriber."""
|
"""Remove a WebSocket connection. Stops audio capture if last visualizer subscriber."""
|
||||||
should_stop = False
|
should_stop = False
|
||||||
@@ -70,16 +91,27 @@ class ConnectionManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def broadcast(self, message: dict[str, Any]) -> None:
|
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:
|
async with self._lock:
|
||||||
connections = list(self._active_connections)
|
connections = list(self._active_connections)
|
||||||
|
|
||||||
if not connections:
|
if not connections:
|
||||||
return
|
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:
|
async def _send(ws: WebSocket) -> WebSocket | None:
|
||||||
try:
|
try:
|
||||||
await ws.send_json(message)
|
await ws.send_text(payload)
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Failed to send to client: %s", e)
|
logger.debug("Failed to send to client: %s", e)
|
||||||
@@ -104,6 +136,35 @@ class ConnectionManager:
|
|||||||
await self.broadcast(message)
|
await self.broadcast(message)
|
||||||
logger.info("Broadcast sent: links_changed")
|
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:
|
async def subscribe_visualizer(self, websocket: WebSocket) -> None:
|
||||||
"""Subscribe a client to audio visualizer data. Starts capture on first subscriber."""
|
"""Subscribe a client to audio visualizer data. Starts capture on first subscriber."""
|
||||||
should_start = False
|
should_start = False
|
||||||
@@ -129,7 +190,7 @@ class ConnectionManager:
|
|||||||
async def _maybe_start_capture(self) -> None:
|
async def _maybe_start_capture(self) -> None:
|
||||||
"""Start audio capture if not already running (called on first subscriber)."""
|
"""Start audio capture if not already running (called on first subscriber)."""
|
||||||
if self._audio_analyzer and not self._audio_analyzer.running:
|
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)
|
started = await loop.run_in_executor(None, self._audio_analyzer.start)
|
||||||
if started:
|
if started:
|
||||||
logger.info("Audio capture started (first subscriber)")
|
logger.info("Audio capture started (first subscriber)")
|
||||||
@@ -139,7 +200,7 @@ class ConnectionManager:
|
|||||||
async def _maybe_stop_capture(self) -> None:
|
async def _maybe_stop_capture(self) -> None:
|
||||||
"""Stop audio capture if running (called when last subscriber leaves)."""
|
"""Stop audio capture if running (called when last subscriber leaves)."""
|
||||||
if self._audio_analyzer and self._audio_analyzer.running:
|
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)
|
await loop.run_in_executor(None, self._audio_analyzer.stop)
|
||||||
logger.info("Audio capture stopped (no subscribers)")
|
logger.info("Audio capture stopped (no subscribers)")
|
||||||
|
|
||||||
@@ -171,7 +232,7 @@ class ConnectionManager:
|
|||||||
idle_interval = 1.0 / max(1, settings.visualizer_fps)
|
idle_interval = 1.0 / max(1, settings.visualizer_fps)
|
||||||
# Bounded wait so we still notice subscribe/unsubscribe transitions.
|
# Bounded wait so we still notice subscribe/unsubscribe transitions.
|
||||||
wake_timeout = max(0.05, idle_interval)
|
wake_timeout = max(0.05, idle_interval)
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
last_seq = -1
|
last_seq = -1
|
||||||
|
|
||||||
@@ -303,6 +364,10 @@ class ConnectionManager:
|
|||||||
get_status_func: Callable[[], Coroutine[Any, Any, Any]],
|
get_status_func: Callable[[], Coroutine[Any, Any, Any]],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Background loop that polls for status changes and broadcasts."""
|
"""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:
|
while self._running:
|
||||||
try:
|
try:
|
||||||
# Only poll if we have connected clients
|
# Only poll if we have connected clients
|
||||||
@@ -329,6 +394,28 @@ class ConnectionManager:
|
|||||||
# Update cached status even without broadcast
|
# Update cached status even without broadcast
|
||||||
self._last_status = status_dict
|
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)
|
await asyncio.sleep(self._poll_interval)
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
|||||||
@@ -15,8 +15,31 @@ logger = logging.getLogger(__name__)
|
|||||||
# Thread pool for WinRT operations (they don't play well with asyncio)
|
# Thread pool for WinRT operations (they don't play well with asyncio)
|
||||||
_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="winrt")
|
_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
|
_current_album_art_bytes: bytes | None = None
|
||||||
|
_art_lock = threading.Lock()
|
||||||
|
|
||||||
|
# Identity of the track whose art is currently in _current_album_art_bytes.
|
||||||
|
# Used to gate the expensive WinRT thumbnail.open_read_async() so the bytes
|
||||||
|
# aren't re-decoded on every 500ms status poll.
|
||||||
|
_current_album_art_key: tuple | None = None
|
||||||
|
|
||||||
# Lock protecting _position_cache and _track_skip_pending from concurrent access
|
# Lock protecting _position_cache and _track_skip_pending from concurrent access
|
||||||
_position_lock = threading.Lock()
|
_position_lock = threading.Lock()
|
||||||
@@ -40,7 +63,8 @@ _track_skip_pending = {
|
|||||||
|
|
||||||
|
|
||||||
def get_current_album_art() -> bytes | None:
|
def get_current_album_art() -> bytes | None:
|
||||||
"""Get the current album art bytes."""
|
"""Get the current album art bytes (thread-safe snapshot)."""
|
||||||
|
with _art_lock:
|
||||||
return _current_album_art_bytes
|
return _current_album_art_bytes
|
||||||
|
|
||||||
# Windows-specific imports
|
# Windows-specific imports
|
||||||
@@ -161,8 +185,6 @@ WINDOWS_AVAILABLE = WINSDK_AVAILABLE
|
|||||||
|
|
||||||
def _sync_get_media_status() -> dict[str, Any]:
|
def _sync_get_media_status() -> dict[str, Any]:
|
||||||
"""Synchronously get media status (runs in thread pool)."""
|
"""Synchronously get media status (runs in thread pool)."""
|
||||||
import asyncio
|
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
"state": "idle",
|
"state": "idle",
|
||||||
"title": None,
|
"title": None,
|
||||||
@@ -174,9 +196,7 @@ def _sync_get_media_status() -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Create a new event loop for this thread
|
loop = _thread_loop()
|
||||||
loop = asyncio.new_event_loop()
|
|
||||||
asyncio.set_event_loop(loop)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get media session manager
|
# Get media session manager
|
||||||
@@ -367,8 +387,20 @@ def _sync_get_media_status() -> dict[str, Any]:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Timeline parse error: {e}")
|
logger.debug(f"Timeline parse error: {e}")
|
||||||
|
|
||||||
# Try to get album art (requires media_props)
|
# Try to get album art (requires media_props). Gated by track key so
|
||||||
|
# the WinRT IPC + bytes copy only runs when the track actually
|
||||||
|
# changes; otherwise we just preserve the existing cached bytes.
|
||||||
if media_props:
|
if media_props:
|
||||||
|
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:
|
try:
|
||||||
thumbnail = media_props.thumbnail
|
thumbnail = media_props.thumbnail
|
||||||
if thumbnail:
|
if thumbnail:
|
||||||
@@ -384,16 +416,25 @@ def _sync_get_media_status() -> dict[str, Any]:
|
|||||||
reader.close()
|
reader.close()
|
||||||
stream.close()
|
stream.close()
|
||||||
|
|
||||||
global _current_album_art_bytes
|
with _art_lock:
|
||||||
_current_album_art_bytes = bytes(buffer)
|
_current_album_art_bytes = bytes(buffer)
|
||||||
|
_current_album_art_key = track_key
|
||||||
result["album_art_url"] = "/api/media/artwork"
|
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:
|
except Exception as e:
|
||||||
logger.debug(f"Failed to get album art: {e}")
|
logger.debug(f"Failed to get album art: {e}")
|
||||||
|
|
||||||
result["source"] = session.source_app_user_model_id
|
result["source"] = session.source_app_user_model_id
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
loop.close()
|
# Reuse the loop across calls — see _thread_loop above.
|
||||||
|
pass
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting media status: {e}")
|
logger.error(f"Error getting media status: {e}")
|
||||||
@@ -439,13 +480,8 @@ def _find_best_session(manager, loop):
|
|||||||
|
|
||||||
def _sync_media_command(command: str) -> bool:
|
def _sync_media_command(command: str) -> bool:
|
||||||
"""Synchronously execute a media command (runs in thread pool)."""
|
"""Synchronously execute a media command (runs in thread pool)."""
|
||||||
import asyncio
|
|
||||||
|
|
||||||
try:
|
|
||||||
loop = asyncio.new_event_loop()
|
|
||||||
asyncio.set_event_loop(loop)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
loop = _thread_loop()
|
||||||
manager = loop.run_until_complete(MediaManager.request_async())
|
manager = loop.run_until_complete(MediaManager.request_async())
|
||||||
if manager is None:
|
if manager is None:
|
||||||
return False
|
return False
|
||||||
@@ -466,8 +502,6 @@ def _sync_media_command(command: str) -> bool:
|
|||||||
return loop.run_until_complete(session.try_skip_previous_async())
|
return loop.run_until_complete(session.try_skip_previous_async())
|
||||||
|
|
||||||
return False
|
return False
|
||||||
finally:
|
|
||||||
loop.close()
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error executing media command {command}: {e}")
|
logger.error(f"Error executing media command {command}: {e}")
|
||||||
@@ -476,13 +510,8 @@ def _sync_media_command(command: str) -> bool:
|
|||||||
|
|
||||||
def _sync_seek(position: float) -> bool:
|
def _sync_seek(position: float) -> bool:
|
||||||
"""Synchronously seek to position."""
|
"""Synchronously seek to position."""
|
||||||
import asyncio
|
|
||||||
|
|
||||||
try:
|
|
||||||
loop = asyncio.new_event_loop()
|
|
||||||
asyncio.set_event_loop(loop)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
loop = _thread_loop()
|
||||||
manager = loop.run_until_complete(MediaManager.request_async())
|
manager = loop.run_until_complete(MediaManager.request_async())
|
||||||
if manager is None:
|
if manager is None:
|
||||||
return False
|
return False
|
||||||
@@ -495,8 +524,6 @@ def _sync_seek(position: float) -> bool:
|
|||||||
return loop.run_until_complete(
|
return loop.run_until_complete(
|
||||||
session.try_change_playback_position_async(position_ticks)
|
session.try_change_playback_position_async(position_ticks)
|
||||||
)
|
)
|
||||||
finally:
|
|
||||||
loop.close()
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error seeking: {e}")
|
logger.error(f"Error seeking: {e}")
|
||||||
@@ -559,7 +586,7 @@ class WindowsMediaController(MediaController):
|
|||||||
|
|
||||||
# Get media info in thread pool (avoids asyncio/WinRT issues)
|
# Get media info in thread pool (avoids asyncio/WinRT issues)
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_running_loop()
|
||||||
media_info = await asyncio.wait_for(
|
media_info = await asyncio.wait_for(
|
||||||
loop.run_in_executor(_executor, _sync_get_media_status),
|
loop.run_in_executor(_executor, _sync_get_media_status),
|
||||||
timeout=5.0
|
timeout=5.0
|
||||||
@@ -592,7 +619,7 @@ class WindowsMediaController(MediaController):
|
|||||||
async def _run_command(self, command: str) -> bool:
|
async def _run_command(self, command: str) -> bool:
|
||||||
"""Run a media command in the thread pool."""
|
"""Run a media command in the thread pool."""
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_running_loop()
|
||||||
return await asyncio.wait_for(
|
return await asyncio.wait_for(
|
||||||
loop.run_in_executor(_executor, _sync_media_command, command),
|
loop.run_in_executor(_executor, _sync_media_command, command),
|
||||||
timeout=5.0
|
timeout=5.0
|
||||||
@@ -616,16 +643,15 @@ class WindowsMediaController(MediaController):
|
|||||||
"""Stop playback."""
|
"""Stop playback."""
|
||||||
return await self._run_command("stop")
|
return await self._run_command("stop")
|
||||||
|
|
||||||
async def next_track(self) -> bool:
|
async def _skip_track(self, command: str) -> bool:
|
||||||
"""Skip to next track."""
|
# Read the current title from the position cache instead of doing a
|
||||||
# Get current title before skipping
|
# full WinRT round-trip (which can take up to 5s) just for one field.
|
||||||
try:
|
with _position_lock:
|
||||||
status = await self.get_status()
|
track_id = _position_cache.get("track_id") or ""
|
||||||
old_title = status.title or ""
|
# track_id is "title:artist:duration" — extract just the title.
|
||||||
except Exception:
|
old_title = track_id.split(":", 1)[0] if track_id else ""
|
||||||
old_title = ""
|
|
||||||
|
|
||||||
result = await self._run_command("next")
|
result = await self._run_command(command)
|
||||||
if result:
|
if result:
|
||||||
with _position_lock:
|
with _position_lock:
|
||||||
_track_skip_pending["active"] = True
|
_track_skip_pending["active"] = True
|
||||||
@@ -634,23 +660,13 @@ class WindowsMediaController(MediaController):
|
|||||||
logger.debug(f"Track skip initiated, old title: {old_title}")
|
logger.debug(f"Track skip initiated, old title: {old_title}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
async def next_track(self) -> bool:
|
||||||
|
"""Skip to next track."""
|
||||||
|
return await self._skip_track("next")
|
||||||
|
|
||||||
async def previous_track(self) -> bool:
|
async def previous_track(self) -> bool:
|
||||||
"""Go to previous track."""
|
"""Go to previous track."""
|
||||||
# Get current title before skipping
|
return await self._skip_track("previous")
|
||||||
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
|
|
||||||
|
|
||||||
async def set_volume(self, volume: int) -> bool:
|
async def set_volume(self, volume: int) -> bool:
|
||||||
"""Set system volume."""
|
"""Set system volume."""
|
||||||
@@ -680,7 +696,7 @@ class WindowsMediaController(MediaController):
|
|||||||
async def seek(self, position: float) -> bool:
|
async def seek(self, position: float) -> bool:
|
||||||
"""Seek to position in seconds."""
|
"""Seek to position in seconds."""
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_running_loop()
|
||||||
return await asyncio.wait_for(
|
return await asyncio.wait_for(
|
||||||
loop.run_in_executor(_executor, _sync_seek, position),
|
loop.run_in_executor(_executor, _sync_seek, position),
|
||||||
timeout=5.0
|
timeout=5.0
|
||||||
@@ -705,7 +721,7 @@ class WindowsMediaController(MediaController):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import os
|
import os
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_running_loop()
|
||||||
await loop.run_in_executor(None, lambda: os.startfile(file_path))
|
await loop.run_in_executor(None, lambda: os.startfile(file_path))
|
||||||
logger.info(f"Opened file with default player: {file_path}")
|
logger.info(f"Opened file with default player: {file_path}")
|
||||||
return True
|
return True
|
||||||
|
|||||||
+529
-118
@@ -329,7 +329,7 @@ body.translations-loaded {
|
|||||||
button:focus-visible {
|
button:focus-visible {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
box-shadow: 0 0 0 4px rgba(29, 185, 84, 0.2);
|
box-shadow: 0 0 0 4px rgba(var(--copper-rgb), 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus-visible,
|
input:focus-visible,
|
||||||
@@ -337,7 +337,7 @@ select:focus-visible,
|
|||||||
textarea:focus-visible {
|
textarea:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 3px rgba(29, 185, 84, 0.15);
|
box-shadow: 0 0 0 3px rgba(var(--copper-rgb), 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-btn:focus-visible {
|
.tab-btn:focus-visible {
|
||||||
@@ -1004,7 +1004,7 @@ button:disabled {
|
|||||||
.controls button:focus-visible {
|
.controls button:focus-visible {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
box-shadow: 0 0 0 4px rgba(29, 185, 84, 0.25);
|
box-shadow: 0 0 0 4px rgba(var(--copper-rgb), 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mute-btn:focus-visible,
|
.mute-btn:focus-visible,
|
||||||
@@ -1012,7 +1012,7 @@ button:disabled {
|
|||||||
.vinyl-toggle-btn:focus-visible {
|
.vinyl-toggle-btn:focus-visible {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
box-shadow: 0 0 0 4px rgba(29, 185, 84, 0.25);
|
box-shadow: 0 0 0 4px rgba(var(--copper-rgb), 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls button.primary {
|
.controls button.primary {
|
||||||
@@ -1060,7 +1060,7 @@ button:disabled {
|
|||||||
|
|
||||||
#volume-slider:hover::-webkit-slider-thumb {
|
#volume-slider:hover::-webkit-slider-thumb {
|
||||||
transform: scale(1.3);
|
transform: scale(1.3);
|
||||||
box-shadow: 0 0 6px rgba(29, 185, 84, 0.4);
|
box-shadow: 0 0 6px rgba(var(--copper-rgb), 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
#volume-slider::-moz-range-thumb {
|
#volume-slider::-moz-range-thumb {
|
||||||
@@ -1075,7 +1075,7 @@ button:disabled {
|
|||||||
|
|
||||||
#volume-slider:hover::-moz-range-thumb {
|
#volume-slider:hover::-moz-range-thumb {
|
||||||
transform: scale(1.3);
|
transform: scale(1.3);
|
||||||
box-shadow: 0 0 6px rgba(29, 185, 84, 0.4);
|
box-shadow: 0 0 6px rgba(var(--copper-rgb), 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-display {
|
.volume-display {
|
||||||
@@ -1169,7 +1169,7 @@ button:disabled {
|
|||||||
.vinyl-toggle-btn.active {
|
.vinyl-toggle-btn.active {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: rgba(29, 185, 84, 0.1);
|
background: rgba(var(--copper-rgb), 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vinyl-toggle-btn svg {
|
.vinyl-toggle-btn svg {
|
||||||
@@ -1393,7 +1393,7 @@ button:disabled {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
font-family: var(--sans, inherit);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.25s ease, box-shadow 0.25s ease;
|
transition: border-color 0.25s ease, box-shadow 0.25s ease;
|
||||||
@@ -1402,7 +1402,7 @@ button:disabled {
|
|||||||
.audio-device-selector select:focus {
|
.audio-device-selector select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 3px rgba(29, 185, 84, 0.15);
|
box-shadow: 0 0 0 3px rgba(var(--copper-rgb), 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.audio-device-status {
|
.audio-device-status {
|
||||||
@@ -1836,13 +1836,18 @@ button:disabled {
|
|||||||
dialog {
|
dialog {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
/* Editorial chrome to match the rest of the Studio Reference layout:
|
||||||
|
no rounded corners, hairline border, and a copper top accent that
|
||||||
|
lets the dialog read as a continuation of the magazine rather than
|
||||||
|
a generic Material modal. */
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-top: 1px solid var(--copper);
|
||||||
|
border-radius: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
max-width: 500px;
|
max-width: 520px;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8);
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.65);
|
||||||
animation: dialogIn 0.25s ease-out;
|
animation: dialogIn 0.25s ease-out;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
@@ -2768,36 +2773,6 @@ button.primary svg {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Footer */
|
|
||||||
footer {
|
|
||||||
text-align: center;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
transition: padding-bottom 0.3s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.mini-player-visible footer {
|
|
||||||
padding-bottom: 70px;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer a {
|
|
||||||
color: var(--accent);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer a:hover {
|
|
||||||
color: var(--accent-hover);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer .separator {
|
|
||||||
margin: 0 0.5rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================================
|
/* ========================================
|
||||||
Media Browser Styles
|
Media Browser Styles
|
||||||
======================================== */
|
======================================== */
|
||||||
@@ -2857,7 +2832,7 @@ footer .separator {
|
|||||||
|
|
||||||
.breadcrumb-item:hover {
|
.breadcrumb-item:hover {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
background: rgba(29, 185, 84, 0.08);
|
background: rgba(var(--copper-rgb), 0.08);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3102,19 +3077,25 @@ footer .separator {
|
|||||||
|
|
||||||
/* Browser List View */
|
/* Browser List View */
|
||||||
.browser-list {
|
.browser-list {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: 40px 1fr auto auto auto auto;
|
||||||
gap: 1px;
|
column-gap: 1.25rem;
|
||||||
|
row-gap: 1px;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.browser-list > .browser-empty,
|
||||||
|
.browser-list > .browser-loading {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
/* List view column header */
|
/* List view column header */
|
||||||
.browser-list-header {
|
.browser-list-header {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 40px 1fr auto auto auto auto;
|
grid-template-columns: subgrid;
|
||||||
|
grid-column: 1 / -1;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.4rem 0.75rem;
|
padding: 0.4rem 0.75rem;
|
||||||
font-size: 0.688rem;
|
font-size: 0.688rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -3132,9 +3113,9 @@ footer .separator {
|
|||||||
|
|
||||||
.browser-list-item {
|
.browser-list-item {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 40px 1fr auto auto auto auto;
|
grid-template-columns: subgrid;
|
||||||
|
grid-column: 1 / -1;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
@@ -3265,12 +3246,14 @@ footer .separator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.browser-item {
|
.browser-item {
|
||||||
background: var(--bg-tertiary);
|
/* Match the editorial card language used elsewhere on the page —
|
||||||
|
transparent background, hairline border, copper-on-hover. */
|
||||||
|
background: transparent;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 10px;
|
border-radius: 0;
|
||||||
padding: 0.6rem;
|
padding: 0.6rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3280,6 +3263,11 @@ footer .separator {
|
|||||||
animation-delay: calc(var(--item-index, 0) * 25ms);
|
animation-delay: calc(var(--item-index, 0) * 25ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.browser-item:hover {
|
||||||
|
border-color: rgba(var(--copper-rgb), 0.45);
|
||||||
|
background: rgba(var(--copper-rgb), 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes itemFadeIn {
|
@keyframes itemFadeIn {
|
||||||
from { opacity: 0; transform: translateY(10px); }
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
@@ -3316,14 +3304,21 @@ footer .separator {
|
|||||||
height: 56px;
|
height: 56px;
|
||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: rgba(29, 185, 84, 0.1);
|
background: rgba(var(--copper-rgb), 0.1);
|
||||||
border: 1px solid rgba(29, 185, 84, 0.15);
|
border: 1px solid rgba(var(--copper-rgb), 0.15);
|
||||||
transition: all 0.25s;
|
transition: all 0.25s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Root folder SVG ships with hardcoded width=24 height=24 (browser.js folderSvg),
|
||||||
|
which renders at ~43% of the 56px icon box. Override to fill it properly. */
|
||||||
|
.browser-item.browser-root-folder .browser-icon > svg {
|
||||||
|
width: 60%;
|
||||||
|
height: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
.browser-item.browser-root-folder:hover .browser-icon {
|
.browser-item.browser-root-folder:hover .browser-icon {
|
||||||
background: rgba(29, 185, 84, 0.18);
|
background: rgba(var(--copper-rgb), 0.18);
|
||||||
border-color: rgba(29, 185, 84, 0.3);
|
border-color: rgba(var(--copper-rgb), 0.3);
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3668,9 +3663,12 @@ footer .separator {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.browser-list-header {
|
.browser-list {
|
||||||
grid-template-columns: 32px 1fr auto auto;
|
grid-template-columns: 32px 1fr auto auto;
|
||||||
gap: 0.5rem;
|
column-gap: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-list-header {
|
||||||
padding: 0.35rem 0.5rem;
|
padding: 0.35rem 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3679,8 +3677,6 @@ footer .separator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.browser-list-item {
|
.browser-list-item {
|
||||||
grid-template-columns: 32px 1fr auto auto;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.4rem 0.5rem;
|
padding: 0.4rem 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3750,17 +3746,13 @@ footer .separator {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.browser-list-header {
|
.browser-list {
|
||||||
grid-template-columns: 40px 1fr auto auto auto;
|
grid-template-columns: 40px 1fr auto auto auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.browser-list-header span:nth-child(3) {
|
.browser-list-header span:nth-child(3) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.browser-list-item {
|
|
||||||
grid-template-columns: 40px 1fr auto auto auto;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Update Banner */
|
/* Update Banner */
|
||||||
@@ -3926,14 +3918,6 @@ html {
|
|||||||
padding-right: max(1rem, env(safe-area-inset-right));
|
padding-right: max(1rem, env(safe-area-inset-right));
|
||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
|
||||||
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
|
|
||||||
}
|
|
||||||
|
|
||||||
body.mini-player-visible footer {
|
|
||||||
padding-bottom: calc(70px + env(safe-area-inset-bottom, 0px));
|
|
||||||
}
|
|
||||||
|
|
||||||
.connection-banner {
|
.connection-banner {
|
||||||
padding-top: max(10px, env(safe-area-inset-top));
|
padding-top: max(10px, env(safe-area-inset-top));
|
||||||
}
|
}
|
||||||
@@ -3991,13 +3975,23 @@ body.mini-player-visible footer {
|
|||||||
════════════════════════════════════════════════════════════════ */
|
════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
/* ─── Container & header ────────────────────────────────────── */
|
/* ─── Container & header ────────────────────────────────────── */
|
||||||
|
/* The footer was removed in favour of an About dialog, so the page
|
||||||
|
bottom is now whatever the active tab content ends with. A 64px
|
||||||
|
bottom pad left a visible dead band under the player view; 24px
|
||||||
|
keeps a breath of breathing room without painting an empty page. */
|
||||||
.container {
|
.container {
|
||||||
max-width: 1280px;
|
max-width: 1280px;
|
||||||
padding: 56px 48px 64px;
|
padding: 56px 48px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.container { padding: 48px 18px 56px; }
|
.container { padding: 32px 18px 20px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Phones: trim the editorial spread further so the first viewport isn't
|
||||||
|
90% chrome. The 56px top pad eats a third of a 360x640 screen. */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.container { padding: 16px 12px 16px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Folio marks (page corners, all tabs) ────────────────── */
|
/* ─── Folio marks (page corners, all tabs) ────────────────── */
|
||||||
@@ -4695,6 +4689,12 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
|
|||||||
-2px 8px 24px rgba(0, 0, 0, 0.5),
|
-2px 8px 24px rgba(0, 0, 0, 0.5),
|
||||||
-4px 18px 44px rgba(0, 0, 0, 0.35);
|
-4px 18px 44px rgba(0, 0, 0, 0.35);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
/* Subtle counterclockwise tilt — sleeve rests on the disc as if
|
||||||
|
casually placed, breaking up the otherwise rigid rectilinear
|
||||||
|
grid. The shadow above carries the same diagonal so the lean
|
||||||
|
reads as physical rather than transformed. */
|
||||||
|
transform: rotate(-2deg);
|
||||||
|
transform-origin: 50% 60%;
|
||||||
}
|
}
|
||||||
:root[data-theme="light"] .vinyl-stage .sleeve {
|
:root[data-theme="light"] .vinyl-stage .sleeve {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -6506,38 +6506,60 @@ dialog::backdrop {
|
|||||||
.toast.info { border-color: var(--rule-strong); box-shadow: 0 14px 40px rgba(0,0,0,0.5); }
|
.toast.info { border-color: var(--rule-strong); box-shadow: 0 14px 40px rgba(0,0,0,0.5); }
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
FOOTER (colophon)
|
ABOUT DIALOG (colophon)
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
footer {
|
.about-dialog {
|
||||||
margin-top: 36px;
|
max-width: 520px;
|
||||||
padding: 20px 0 0;
|
}
|
||||||
border-top: 1px solid var(--rule-strong);
|
.about-credit {
|
||||||
background: transparent;
|
font-family: var(--serif);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
margin: 0 0 22px;
|
||||||
|
font-variation-settings: 'opsz' 30;
|
||||||
|
}
|
||||||
|
.about-credit strong {
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 22px;
|
||||||
|
color: var(--ink);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
font-variation-settings: 'opsz' 60;
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.about-links {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.about-links li {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 14px 0;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.about-links-label {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
letter-spacing: 0.16em;
|
letter-spacing: 0.16em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
footer a {
|
.about-links a {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
color: var(--copper);
|
color: var(--copper);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-bottom: 1px solid transparent;
|
border-bottom: 1px solid transparent;
|
||||||
transition: border-color 200ms var(--ease);
|
transition: border-color 200ms var(--ease);
|
||||||
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
footer a:hover { border-bottom-color: var(--copper); }
|
.about-links a:hover { border-bottom-color: var(--copper); }
|
||||||
footer strong {
|
|
||||||
font-family: var(--serif);
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 400;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--ink-soft);
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
text-transform: none;
|
|
||||||
font-variation-settings: 'opsz' 30;
|
|
||||||
}
|
|
||||||
footer .separator { color: var(--ink-ghost); margin: 0 8px; }
|
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
DISPLAY container (monitors tab)
|
DISPLAY container (monitors tab)
|
||||||
@@ -6597,7 +6619,6 @@ footer .separator { color: var(--ink-ghost); margin: 0 8px; }
|
|||||||
.auth-modal h2 { font-size: 28px; }
|
.auth-modal h2 { font-size: 28px; }
|
||||||
.settings-section { padding: 20px; }
|
.settings-section { padding: 20px; }
|
||||||
.settings-section summary { font-size: 22px; }
|
.settings-section summary { font-size: 22px; }
|
||||||
footer { font-size: 9px; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ════════════════════════════════════════════════════════════════
|
/* ════════════════════════════════════════════════════════════════
|
||||||
@@ -7394,9 +7415,15 @@ select option {
|
|||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
}
|
}
|
||||||
|
/* Let the compact icon fill its thumb-wrapper (matches grid view behaviour).
|
||||||
|
The previous 28x28 size left the icon stranded at the wrapper's top-left
|
||||||
|
because .browser-thumb-wrapper is not a flex container. The wrapper square
|
||||||
|
is sized by the grid; the emoji inside .browser-icon centers via the base
|
||||||
|
flex rules. */
|
||||||
.browser-container .browser-grid-compact .browser-icon {
|
.browser-container .browser-grid-compact .browser-icon {
|
||||||
width: 28px;
|
width: 100%;
|
||||||
height: 28px;
|
height: 100%;
|
||||||
|
font-size: 2.25rem;
|
||||||
}
|
}
|
||||||
.browser-container .browser-item {
|
.browser-container .browser-item {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
@@ -7957,10 +7984,16 @@ select option {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
/* Allow text to wrap so we don't ellipsis-truncate the model name */
|
min-width: 0;
|
||||||
|
}
|
||||||
|
/* Truncate the monitor name itself, not its sibling badge — putting
|
||||||
|
overflow:hidden on the parent flex container clipped the favourite
|
||||||
|
star whenever the model name was long enough to ellipsis. */
|
||||||
|
.display-container .display-monitor-name-text {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.display-container .display-monitor-details {
|
.display-container .display-monitor-details {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
@@ -7980,6 +8013,7 @@ select option {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
|
flex-shrink: 0;
|
||||||
filter: drop-shadow(0 0 4px var(--copper-glow));
|
filter: drop-shadow(0 0 4px var(--copper-glow));
|
||||||
}
|
}
|
||||||
.display-container .display-primary-badge svg {
|
.display-container .display-primary-badge svg {
|
||||||
@@ -8022,24 +8056,36 @@ select option {
|
|||||||
background: rgba(var(--copper-rgb), 0.06) !important;
|
background: rgba(var(--copper-rgb), 0.06) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Brightness control row */
|
/* Slider rows (brightness + contrast share this layout) */
|
||||||
.display-container .display-brightness-control {
|
.display-container .display-slider-row {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: 18px minmax(0, auto) 1fr auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.display-container .display-slider-row.display-brightness-control {
|
||||||
padding-top: 16px;
|
padding-top: 16px;
|
||||||
border-top: 1px solid var(--rule);
|
border-top: 1px solid var(--rule);
|
||||||
}
|
}
|
||||||
.display-container .display-brightness-icon {
|
.display-container .display-slider-icon {
|
||||||
color: var(--ink-mute);
|
color: var(--ink-mute);
|
||||||
width: 18px !important;
|
width: 18px !important;
|
||||||
height: 18px !important;
|
height: 18px !important;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.display-container .display-brightness-slider {
|
.display-container .display-slider-label {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-mute);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.display-container .display-slider {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
height: 2px !important;
|
height: 2px !important;
|
||||||
background: var(--rule-strong);
|
background: var(--rule-strong);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
@@ -8049,7 +8095,7 @@ select option {
|
|||||||
border: 0 !important;
|
border: 0 !important;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.display-container .display-brightness-slider::-webkit-slider-thumb {
|
.display-container .display-slider::-webkit-slider-thumb {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
width: 14px;
|
width: 14px;
|
||||||
@@ -8059,20 +8105,24 @@ select option {
|
|||||||
box-shadow: 0 0 12px var(--copper-glow);
|
box-shadow: 0 0 12px var(--copper-glow);
|
||||||
border: 0;
|
border: 0;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
|
transition: transform 140ms var(--ease);
|
||||||
}
|
}
|
||||||
.display-container .display-brightness-slider::-moz-range-thumb {
|
.display-container .display-slider::-moz-range-thumb {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
background: var(--copper);
|
background: var(--copper);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 0;
|
border: 0;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
|
transition: transform 140ms var(--ease);
|
||||||
}
|
}
|
||||||
.display-container .display-brightness-slider:disabled {
|
.display-container .display-slider:hover::-webkit-slider-thumb { transform: scale(1.15); }
|
||||||
|
.display-container .display-slider:hover::-moz-range-thumb { transform: scale(1.15); }
|
||||||
|
.display-container .display-slider:disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
.display-container .display-brightness-value {
|
.display-container .display-slider-value {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--copper);
|
color: var(--copper);
|
||||||
@@ -8082,6 +8132,57 @@ select option {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Picture tuning section — input source, color preset, picture mode.
|
||||||
|
The underlying <select> is hidden by IconSelect; the visible trigger
|
||||||
|
inherits the editorial .icon-select-trigger overrides defined later
|
||||||
|
in this file. */
|
||||||
|
.display-container .display-tuning {
|
||||||
|
padding-top: 18px;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.display-container .display-tuning-title {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.28em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.display-container .display-tuning-title::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(to right, var(--rule), transparent);
|
||||||
|
}
|
||||||
|
.display-container .display-tuning-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.display-container .display-tuning-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.display-container .display-tuning-label {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-mute);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* Make the IconSelect trigger fill the field width (cards are narrow) */
|
||||||
|
.display-container .display-tuning-field .icon-select-trigger {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
.display-container .display-monitors > .empty-state-illustration {
|
.display-container .display-monitors > .empty-state-illustration {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -8707,14 +8808,6 @@ select option {
|
|||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Footer: ultra-compact ───────────────────────────── */
|
|
||||||
footer {
|
|
||||||
font-size: 10px !important;
|
|
||||||
padding: 12px 12px !important;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
footer .separator { margin: 0 4px !important; }
|
|
||||||
|
|
||||||
/* Auth modal: full-bleed feel on phones */
|
/* Auth modal: full-bleed feel on phones */
|
||||||
.auth-modal {
|
.auth-modal {
|
||||||
width: 92% !important;
|
width: 92% !important;
|
||||||
@@ -9228,3 +9321,321 @@ body.is-fullscreen-player .now-playing .vu-meter {
|
|||||||
body.is-fullscreen-player .fs-bloom #fs-bloom-art { animation: none !important; }
|
body.is-fullscreen-player .fs-bloom #fs-bloom-art { animation: none !important; }
|
||||||
:root[data-theme="light"] body.is-fullscreen-player .fs-bloom { opacity: 0.22; }
|
:root[data-theme="light"] body.is-fullscreen-player .fs-bloom { opacity: 0.22; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════════
|
||||||
|
FOREGROUND container — editorial process plate
|
||||||
|
════════════════════════════════════════════════════════════════ */
|
||||||
|
.foreground-container {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foreground-stage {
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Match the inter-section gap used between .settings-section blocks
|
||||||
|
in the Settings tab — keeps cadence consistent across tabs. */
|
||||||
|
.display-container > * + * {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foreground-card {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
padding: clamp(24px, 3vw, 40px) clamp(24px, 3vw, 40px) 28px;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-top: 2px solid var(--copper);
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 80% at 0% 0%, rgba(var(--copper-rgb), 0.05), transparent 60%),
|
||||||
|
var(--bg-paper);
|
||||||
|
box-shadow:
|
||||||
|
0 1px 0 var(--bg-paper),
|
||||||
|
0 28px 60px -28px rgba(0, 0, 0, 0.45),
|
||||||
|
0 8px 20px -10px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
.foreground-card[data-fullscreen="1"] {
|
||||||
|
border-top-color: var(--copper-hi);
|
||||||
|
box-shadow:
|
||||||
|
0 1px 0 var(--bg-paper),
|
||||||
|
0 28px 60px -28px rgba(0, 0, 0, 0.55),
|
||||||
|
0 0 0 1px rgba(var(--copper-rgb), 0.18),
|
||||||
|
0 0 60px -12px var(--copper-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.foreground-card .fg-kicker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.32em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--copper);
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
.foreground-card .fg-kicker::before,
|
||||||
|
.foreground-card .fg-kicker::after {
|
||||||
|
content: "";
|
||||||
|
height: 1px;
|
||||||
|
background: var(--copper);
|
||||||
|
opacity: 0.6;
|
||||||
|
flex: 0 0 24px;
|
||||||
|
}
|
||||||
|
.foreground-card .fg-kicker::after { flex: 1 0 auto; }
|
||||||
|
|
||||||
|
.foreground-card .fg-process {
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: clamp(34px, 4.4vw, 56px);
|
||||||
|
line-height: 1.02;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
font-variation-settings: 'opsz' 144;
|
||||||
|
color: var(--ink);
|
||||||
|
margin: 0 0 10px;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
transition: color 180ms var(--ease, ease);
|
||||||
|
}
|
||||||
|
.foreground-card .fg-process:hover {
|
||||||
|
color: var(--copper-hi);
|
||||||
|
}
|
||||||
|
|
||||||
|
.foreground-card .fg-window-title {
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 300;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-variation-settings: 'opsz' 60;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
line-height: 1.35;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.foreground-card .fg-window-title:empty { display: none; }
|
||||||
|
|
||||||
|
.foreground-card .fg-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.foreground-card .fg-chips:empty { display: none; }
|
||||||
|
|
||||||
|
.fg-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 5px 11px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--rule-strong);
|
||||||
|
border-radius: 999px;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.fg-chip.fg-chip-accent {
|
||||||
|
color: var(--copper);
|
||||||
|
border-color: var(--copper);
|
||||||
|
background: rgba(var(--copper-rgb), 0.07);
|
||||||
|
}
|
||||||
|
.fg-chip.fg-chip-mute {
|
||||||
|
color: var(--ink-mute);
|
||||||
|
border-color: var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.foreground-card .fg-details {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.foreground-card .fg-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(160px, 220px) 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 14px 0;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
align-items: baseline;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.foreground-card .fg-row dt {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--copper);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.foreground-card .fg-row dd {
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--ink);
|
||||||
|
font-variation-settings: 'opsz' 30;
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.foreground-card .fg-mono {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foreground-empty {
|
||||||
|
padding: 60px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ink-mute);
|
||||||
|
}
|
||||||
|
.foreground-empty svg {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
opacity: 0.55;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.foreground-empty p {
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.foreground-empty .foreground-empty-error {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--ink-mute);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Header status badge ──────────────────────────────────── */
|
||||||
|
.foreground-status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px 0 10px;
|
||||||
|
margin-right: 4px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--rule-strong);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: 240px;
|
||||||
|
transition: color 180ms ease, border-color 180ms ease, background 180ms ease;
|
||||||
|
}
|
||||||
|
.foreground-status-badge:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
border-color: var(--copper);
|
||||||
|
background: rgba(var(--copper-rgb), 0.06);
|
||||||
|
}
|
||||||
|
.foreground-status-badge.hidden { display: none !important; }
|
||||||
|
|
||||||
|
.foreground-status-badge .fg-badge-mark {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ink-mute);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.foreground-status-badge.is-media .fg-badge-mark,
|
||||||
|
.foreground-status-badge.is-fullscreen .fg-badge-mark {
|
||||||
|
background: var(--copper);
|
||||||
|
box-shadow: 0 0 8px var(--copper-glow);
|
||||||
|
}
|
||||||
|
.foreground-status-badge.is-fullscreen {
|
||||||
|
border-color: var(--copper);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.foreground-status-badge .fg-badge-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 140px;
|
||||||
|
}
|
||||||
|
.foreground-status-badge .fg-badge-tag {
|
||||||
|
color: var(--copper);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
font-size: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.foreground-status-badge .fg-badge-tag.hidden { display: none; }
|
||||||
|
|
||||||
|
/* ─── Light theme overrides ──────────────────────────────── */
|
||||||
|
:root[data-theme="light"] .foreground-card {
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 80% at 0% 0%, rgba(var(--copper-rgb), 0.05), transparent 60%),
|
||||||
|
var(--bg-paper);
|
||||||
|
box-shadow:
|
||||||
|
0 1px 0 var(--bg-paper),
|
||||||
|
0 22px 50px -24px rgba(26, 23, 21, 0.20),
|
||||||
|
0 6px 16px -8px rgba(26, 23, 21, 0.12);
|
||||||
|
}
|
||||||
|
:root[data-theme="light"] .foreground-card[data-fullscreen="1"] {
|
||||||
|
box-shadow:
|
||||||
|
0 1px 0 var(--bg-paper),
|
||||||
|
0 22px 50px -24px rgba(26, 23, 21, 0.28),
|
||||||
|
0 0 0 1px rgba(var(--copper-rgb), 0.20),
|
||||||
|
0 0 50px -12px var(--copper-glow);
|
||||||
|
}
|
||||||
|
:root[data-theme="light"] .foreground-status-badge {
|
||||||
|
border-color: rgba(26, 23, 21, 0.18);
|
||||||
|
}
|
||||||
|
:root[data-theme="light"] .foreground-status-badge:hover {
|
||||||
|
background: rgba(var(--copper-rgb), 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Mobile breakpoint ──────────────────────────────────── */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.foreground-card {
|
||||||
|
padding: 22px 18px 20px;
|
||||||
|
}
|
||||||
|
.foreground-card .fg-process {
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
.foreground-card .fg-window-title {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.foreground-card .fg-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
.foreground-card .fg-row dd {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.foreground-status-badge {
|
||||||
|
max-width: 160px;
|
||||||
|
}
|
||||||
|
.foreground-status-badge .fg-badge-name {
|
||||||
|
max-width: 80px;
|
||||||
|
}
|
||||||
|
.foreground-status-badge .fg-badge-tag {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 208 B After Width: | Height: | Size: 37 KiB |
@@ -1,10 +1,33 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Media Server">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
<stop offset="0%" style="stop-color:#1db954;stop-opacity:1" />
|
<stop offset="0%" stop-color="#0B3D3B"/>
|
||||||
<stop offset="100%" style="stop-color:#1ed760;stop-opacity:1" />
|
<stop offset="100%" stop-color="#1A6B5E"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<linearGradient id="sheen" x1="50%" y1="0%" x2="50%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.18"/>
|
||||||
|
<stop offset="55%" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="triShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feGaussianBlur stdDeviation="3"/>
|
||||||
|
<feOffset dx="3" dy="5"/>
|
||||||
|
<feComponentTransfer><feFuncA type="linear" slope="0.45"/></feComponentTransfer>
|
||||||
|
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
<clipPath id="clip"><rect x="0" y="0" width="256" height="256" rx="58" ry="58"/></clipPath>
|
||||||
</defs>
|
</defs>
|
||||||
<circle cx="50" cy="50" r="45" fill="url(#grad)"/>
|
<g clip-path="url(#clip)">
|
||||||
<path fill="white" d="M35 25 L35 75 L75 50 Z"/>
|
<rect width="256" height="256" fill="url(#bg)"/>
|
||||||
|
<rect width="256" height="256" fill="url(#sheen)"/>
|
||||||
|
<g stroke="#F5F1E8" stroke-width="4.6" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.18">
|
||||||
|
<polyline points="66,72 105,128 66,184"/>
|
||||||
|
<polyline points="85,82 124,128 85,174" opacity="0.6"/>
|
||||||
|
</g>
|
||||||
|
<path d="M88.3 55 L88.3 201 L193.3 128 Z"
|
||||||
|
fill="#F5F1E8"
|
||||||
|
stroke="#F5F1E8" stroke-width="9" stroke-linejoin="round"
|
||||||
|
filter="url(#triShadow)"/>
|
||||||
|
</g>
|
||||||
|
<rect x="0.5" y="0.5" width="255" height="255" rx="58" ry="58"
|
||||||
|
fill="none" stroke="#000000" stroke-opacity="0.18" stroke-width="1"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 421 B After Width: | Height: | Size: 1.6 KiB |
+103
-79
@@ -26,16 +26,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mini-controls">
|
<div class="mini-controls">
|
||||||
<button class="mini-control-btn mini-nav-btn" onclick="previousTrack()" data-i18n-title="player.previous" title="Previous">
|
<button class="mini-control-btn mini-nav-btn" data-onclick="previousTrack()" data-i18n-title="player.previous" data-i18n-aria-label="player.previous" title="Previous" aria-label="Previous">
|
||||||
<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="mini-control-btn" onclick="togglePlayPause()" id="mini-btn-play-pause" title="Play/Pause">
|
<button class="mini-control-btn" data-onclick="togglePlayPause()" id="mini-btn-play-pause" title="Play/Pause" aria-label="Play/Pause">
|
||||||
<svg viewBox="0 0 24 24" id="mini-play-pause-icon">
|
<svg viewBox="0 0 24 24" id="mini-play-pause-icon" aria-hidden="true" focusable="false">
|
||||||
<path d="M8 5v14l11-7z"/>
|
<path d="M8 5v14l11-7z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="mini-control-btn mini-nav-btn" onclick="nextTrack()" data-i18n-title="player.next" title="Next">
|
<button class="mini-control-btn mini-nav-btn" data-onclick="nextTrack()" data-i18n-title="player.next" data-i18n-aria-label="player.next" title="Next" aria-label="Next">
|
||||||
<svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mini-progress-container">
|
<div class="mini-progress-container">
|
||||||
@@ -48,8 +48,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mini-volume-container">
|
<div class="mini-volume-container">
|
||||||
<button class="mini-control-btn" onclick="toggleMute()" id="mini-btn-mute" title="Mute">
|
<button class="mini-control-btn" data-onclick="toggleMute()" id="mini-btn-mute" title="Mute" aria-label="Mute" aria-pressed="false">
|
||||||
<svg viewBox="0 0 24 24" id="mini-mute-icon">
|
<svg viewBox="0 0 24 24" id="mini-mute-icon" aria-hidden="true" focusable="false">
|
||||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
|
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
<h2 data-i18n="app.title">Media Server</h2>
|
<h2 data-i18n="app.title">Media Server</h2>
|
||||||
<p data-i18n="auth.message">Enter your API token to connect to the media server.</p>
|
<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">
|
<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">
|
<div class="help-text">
|
||||||
<p data-i18n="auth.help">To get your token, run:</p>
|
<p data-i18n="auth.help">To get your token, run:</p>
|
||||||
<code>media-server --show-token</code>
|
<code>media-server --show-token</code>
|
||||||
@@ -88,23 +88,26 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="header-toolbar">
|
<div class="header-toolbar">
|
||||||
<div id="headerLinks" class="header-links"></div>
|
<div id="headerLinks" class="header-links"></div>
|
||||||
<a class="header-btn" href="/docs" target="_blank" title="API Documentation" aria-label="API Documentation">
|
<a class="header-btn" href="/docs" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" title="API Documentation" aria-label="API Documentation">
|
||||||
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 2l5 5h-5V4zM6 20V4h5v7h7v9H6zm2-4h8v2H8v-2zm0-3h8v2H8v-2z"/></svg>
|
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 2l5 5h-5V4zM6 20V4h5v7h7v9H6zm2-4h8v2H8v-2zm0-3h8v2H8v-2z"/></svg>
|
||||||
</a>
|
</a>
|
||||||
|
<button class="header-btn" data-onclick="showAboutDialog()" data-i18n-title="about.button_title" title="About" aria-label="About">
|
||||||
|
<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">
|
<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>
|
<span class="accent-dot" id="accentDot"></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="accent-picker-dropdown" id="accentDropdown"></div>
|
<div class="accent-picker-dropdown" id="accentDropdown"></div>
|
||||||
</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>
|
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 3v10.55A4 4 0 1 0 14 17V7h4V3h-6z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="header-btn" onclick="togglePlayerFullscreen()" data-i18n-title="player.fullscreen" title="Fullscreen player" aria-label="Fullscreen player" id="fullscreenToggle">
|
<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-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>
|
<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>
|
||||||
<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="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;">
|
<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"/>
|
<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>
|
</svg>
|
||||||
@@ -112,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"/>
|
<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>
|
</svg>
|
||||||
</button>
|
</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="en">EN</option>
|
||||||
<option value="ru">RU</option>
|
<option value="ru">RU</option>
|
||||||
</select>
|
</select>
|
||||||
<span class="header-toolbar-sep"></span>
|
<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>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,29 +136,29 @@
|
|||||||
<!-- Connection Banner -->
|
<!-- Connection Banner -->
|
||||||
<div class="connection-banner hidden" id="connectionBanner">
|
<div class="connection-banner hidden" id="connectionBanner">
|
||||||
<span id="connectionBannerText"></span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Tab Bar (editorial: numbered, italic active) -->
|
<!-- Tab Bar (editorial: numbered, italic active) -->
|
||||||
<div class="tab-bar" id="tabBar" role="tablist">
|
<div class="tab-bar" id="tabBar" role="tablist">
|
||||||
<div class="tab-indicator" id="tabIndicator"></div>
|
<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">
|
<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 class="tab-num">01</span>
|
||||||
<span data-i18n="tab.player">Now Spinning</span>
|
<span data-i18n="tab.player">Now Spinning</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tab-btn" data-tab="display" onclick="switchTab('display')" role="tab" aria-selected="false" aria-controls="panel-display" tabindex="-1">
|
<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 class="tab-num">02</span>
|
||||||
<span data-i18n="tab.display">Display</span>
|
<span data-i18n="tab.display">Display</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tab-btn" data-tab="browser" onclick="switchTab('browser')" role="tab" aria-selected="false" aria-controls="panel-browser" tabindex="-1">
|
<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 class="tab-num">03</span>
|
||||||
<span data-i18n="tab.browser">Library</span>
|
<span data-i18n="tab.browser">Library</span>
|
||||||
</button>
|
</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">
|
<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 class="tab-num">04</span>
|
||||||
<span data-i18n="tab.quick_access">Quick Access</span>
|
<span data-i18n="tab.quick_access">Quick Access</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tab-btn" data-tab="settings" onclick="switchTab('settings')" role="tab" aria-selected="false" aria-controls="panel-settings" tabindex="-1">
|
<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 class="tab-num">05</span>
|
||||||
<span data-i18n="tab.settings">Settings</span>
|
<span data-i18n="tab.settings">Settings</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -169,7 +172,7 @@
|
|||||||
<span class="fs-chrome-sep">·</span>
|
<span class="fs-chrome-sep">·</span>
|
||||||
<span class="fs-chrome-kicker" data-i18n="player.kicker">Now Playing</span>
|
<span class="fs-chrome-kicker" data-i18n="player.kicker">Now Playing</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="fs-chrome-exit" onclick="togglePlayerFullscreen()" data-i18n-title="player.fullscreen.exit" title="Exit fullscreen" aria-label="Exit fullscreen">
|
<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>
|
<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>
|
<span data-i18n="player.fullscreen.exit_short">Exit</span>
|
||||||
<kbd class="fs-chrome-kbd">ESC</kbd>
|
<kbd class="fs-chrome-kbd">ESC</kbd>
|
||||||
@@ -203,19 +206,19 @@
|
|||||||
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
|
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="armGrad" x1="0" x2="1">
|
<linearGradient id="armGrad" x1="0" x2="1">
|
||||||
<stop offset="0" stop-color="#3a3528"/>
|
<stop offset="0" stop-color="#6d5f44"/>
|
||||||
<stop offset="0.5" stop-color="#9C937F"/>
|
<stop offset="0.5" stop-color="#d8c39a"/>
|
||||||
<stop offset="1" stop-color="#5C5447"/>
|
<stop offset="1" stop-color="#8a7a5a"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<circle cx="176" cy="24" r="14" fill="#1a1611" stroke="#3A3528" stroke-width="1"/>
|
<circle cx="176" cy="24" r="14" fill="#2a241c" stroke="#9C835A" stroke-width="1.5"/>
|
||||||
<circle cx="176" cy="24" r="6" fill="#3A3528"/>
|
<circle cx="176" cy="24" r="6" fill="#5C5447"/>
|
||||||
<circle cx="176" cy="24" r="2" fill="#E08038"/>
|
<circle cx="176" cy="24" r="2.5" fill="#E08038"/>
|
||||||
<line x1="176" y1="24" x2="64" y2="136" stroke="url(#armGrad)" stroke-width="3.5" stroke-linecap="round"/>
|
<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="#26211A" stroke="#3A3528"/>
|
<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="#26211A" stroke="#3A3528" transform="rotate(-45 67 137)"/>
|
<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" fill="#E08038" opacity="0.8"/>
|
<circle cx="62" cy="138" r="3.5" fill="#E08038" opacity="0.95"/>
|
||||||
<circle cx="62" cy="138" r="6" fill="none" stroke="#E08038" stroke-width="0.5" opacity="0.4"/>
|
<circle cx="62" cy="138" r="7" fill="none" stroke="#E08038" stroke-width="0.8" opacity="0.5"/>
|
||||||
</svg>
|
</svg>
|
||||||
<canvas id="spectrogram-canvas" class="spectrogram-canvas" width="300" height="64"></canvas>
|
<canvas id="spectrogram-canvas" class="spectrogram-canvas" width="300" height="64"></canvas>
|
||||||
</div>
|
</div>
|
||||||
@@ -264,13 +267,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<button class="btn-trans" onclick="previousTrack()" data-i18n-title="player.previous" title="Previous" id="btn-previous">
|
<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>
|
<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-trans primary" onclick="togglePlayPause()" data-i18n-title="player.play" title="Play/Pause" id="btn-play-pause">
|
<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>
|
<svg viewBox="0 0 24 24" id="play-pause-icon"><path d="M8 5v14l11-7z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-trans" onclick="nextTrack()" data-i18n-title="player.next" title="Next" id="btn-next">
|
<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>
|
<svg viewBox="0 0 24 24"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -284,7 +287,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Volume control: mute + slim slider, integrated -->
|
<!-- Volume control: mute + slim slider, integrated -->
|
||||||
<div class="vu-volume">
|
<div class="vu-volume">
|
||||||
<button class="mute-btn" onclick="toggleMute()" data-i18n-title="player.mute" title="Mute" id="btn-mute">
|
<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">
|
<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"/>
|
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -298,7 +301,7 @@
|
|||||||
<!-- Hidden but functional: legacy display + visualizer toggle. -->
|
<!-- Hidden but functional: legacy display + visualizer toggle. -->
|
||||||
<div class="visually-hidden">
|
<div class="visually-hidden">
|
||||||
<div id="volume-display">50%</div>
|
<div id="volume-display">50%</div>
|
||||||
<button onclick="toggleVisualizer()" id="visualizerToggle" data-i18n-title="player.visualizer" title="Audio visualizer" style="display:none">
|
<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>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -315,34 +318,34 @@
|
|||||||
<div class="browser-toolbar" id="browserToolbar">
|
<div class="browser-toolbar" id="browserToolbar">
|
||||||
<div class="browser-toolbar-left">
|
<div class="browser-toolbar-left">
|
||||||
<div class="view-toggle">
|
<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>
|
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M3 3h8v8H3V3zm0 10h8v8H3v-8zm10-10h8v8h-8V3zm0 10h8v8h-8v-8z"/></svg>
|
||||||
</button>
|
</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>
|
<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>
|
||||||
<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>
|
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M3 4h18v2H3V4zm0 7h18v2H3v-2zm0 7h18v2H3v-2z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
<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>
|
||||||
<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>
|
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="browser-search-wrapper" id="browserSearchWrapper" style="display: none;">
|
<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>
|
<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()">
|
<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" onclick="clearBrowserSearch()" style="display: none;">
|
<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>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="browser-toolbar-right">
|
<div class="browser-toolbar-right">
|
||||||
<label class="items-per-page-label">
|
<label class="items-per-page-label">
|
||||||
<span data-i18n="browser.items_per_page">Items per page:</span>
|
<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="25">25</option>
|
||||||
<option value="50">50</option>
|
<option value="50">50</option>
|
||||||
<option value="100" selected>100</option>
|
<option value="100" selected>100</option>
|
||||||
@@ -363,13 +366,13 @@
|
|||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
<div class="pagination" id="browserPagination" style="display: none;">
|
<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">
|
<div class="pagination-center">
|
||||||
<span data-i18n="browser.page">Page</span>
|
<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>
|
<span id="pageTotal">/ 1</span>
|
||||||
</div>
|
</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>
|
<span class="pagination-showing" id="paginationShowing"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -395,7 +398,7 @@
|
|||||||
<div class="audio-device-selector">
|
<div class="audio-device-selector">
|
||||||
<label>
|
<label>
|
||||||
<span data-i18n="settings.audio.device">Loopback Device</span>
|
<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>
|
<option value="" data-i18n="settings.audio.auto">Auto-detect</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
@@ -431,7 +434,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="add-card" onclick="showAddFolderDialog()">
|
<div class="add-card" data-onclick="showAddFolderDialog()">
|
||||||
<span class="add-card-icon">+</span>
|
<span class="add-card-icon">+</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -461,7 +464,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="add-card" onclick="showAddScriptDialog()">
|
<div class="add-card" data-onclick="showAddScriptDialog()">
|
||||||
<span class="add-card-icon">+</span>
|
<span class="add-card-icon">+</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -493,7 +496,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="add-card" onclick="showAddLinkDialog()">
|
<div class="add-card" data-onclick="showAddLinkDialog()">
|
||||||
<span class="add-card-icon">+</span>
|
<span class="add-card-icon">+</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -519,20 +522,20 @@
|
|||||||
<td colspan="4" class="empty-state">
|
<td colspan="4" class="empty-state">
|
||||||
<div class="empty-state-illustration">
|
<div class="empty-state-illustration">
|
||||||
<svg viewBox="0 0 64 64"><circle cx="32" cy="32" r="24"/><path d="M32 20v12l8 8"/></svg>
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="add-card" onclick="showAddCallbackDialog()">
|
<div class="add-card" data-onclick="showAddCallbackDialog()">
|
||||||
<span class="add-card-icon">+</span>
|
<span class="add-card-icon">+</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</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-container" data-tab-content="display" role="tabpanel" id="panel-display">
|
||||||
<div class="display-monitors" id="displayMonitors">
|
<div class="display-monitors" id="displayMonitors">
|
||||||
<div class="empty-state-illustration">
|
<div class="empty-state-illustration">
|
||||||
@@ -540,6 +543,12 @@
|
|||||||
<p data-i18n="display.loading">Loading monitors...</p>
|
<p data-i18n="display.loading">Loading monitors...</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -548,7 +557,7 @@
|
|||||||
<div class="dialog-header">
|
<div class="dialog-header">
|
||||||
<h3 id="dialogTitle" data-i18n="scripts.dialog.add">Add Script</h3>
|
<h3 id="dialogTitle" data-i18n="scripts.dialog.add">Add Script</h3>
|
||||||
</div>
|
</div>
|
||||||
<form id="scriptForm" onsubmit="saveScript(event)">
|
<form id="scriptForm" data-onsubmit="saveScript(event)">
|
||||||
<div class="dialog-body">
|
<div class="dialog-body">
|
||||||
<input type="hidden" id="scriptOriginalName">
|
<input type="hidden" id="scriptOriginalName">
|
||||||
<input type="hidden" id="scriptIsEdit">
|
<input type="hidden" id="scriptIsEdit">
|
||||||
@@ -590,13 +599,13 @@
|
|||||||
<div class="params-section">
|
<div class="params-section">
|
||||||
<div class="params-header">
|
<div class="params-header">
|
||||||
<span data-i18n="scripts.field.parameters">Parameters</span>
|
<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>
|
||||||
<div id="scriptParamsContainer"></div>
|
<div id="scriptParamsContainer"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dialog-footer">
|
<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>
|
<button type="submit" class="btn-primary" data-i18n="scripts.button.save">Save</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -605,14 +614,14 @@
|
|||||||
<!-- Script Parameters Input Dialog (shown before executing scripts with params) -->
|
<!-- Script Parameters Input Dialog (shown before executing scripts with params) -->
|
||||||
<dialog id="scriptParamsDialog">
|
<dialog id="scriptParamsDialog">
|
||||||
<div class="dialog-header">
|
<div class="dialog-header">
|
||||||
<h3 id="scriptParamsDialogTitle">Execute Script</h3>
|
<h3 id="scriptParamsDialogTitle" data-i18n="scripts.params.execute">Execute Script</h3>
|
||||||
</div>
|
</div>
|
||||||
<form id="scriptParamsForm" onsubmit="submitScriptWithParams(event)">
|
<form id="scriptParamsForm" data-onsubmit="submitScriptWithParams(event)">
|
||||||
<div class="dialog-body">
|
<div class="dialog-body">
|
||||||
<div id="scriptParamsInputs"></div>
|
<div id="scriptParamsInputs"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dialog-footer">
|
<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>
|
<button type="submit" class="btn-primary" data-i18n="scripts.params.execute">Execute</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -623,7 +632,7 @@
|
|||||||
<div class="dialog-header">
|
<div class="dialog-header">
|
||||||
<h3 id="callbackDialogTitle" data-i18n="callbacks.dialog.add">Add Callback</h3>
|
<h3 id="callbackDialogTitle" data-i18n="callbacks.dialog.add">Add Callback</h3>
|
||||||
</div>
|
</div>
|
||||||
<form id="callbackForm" onsubmit="saveCallback(event)">
|
<form id="callbackForm" data-onsubmit="saveCallback(event)">
|
||||||
<div class="dialog-body">
|
<div class="dialog-body">
|
||||||
<input type="hidden" id="callbackIsEdit">
|
<input type="hidden" id="callbackIsEdit">
|
||||||
|
|
||||||
@@ -661,7 +670,7 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="dialog-footer">
|
<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>
|
<button type="submit" class="btn-primary" data-i18n="callbacks.button.save">Save</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -672,7 +681,7 @@
|
|||||||
<div class="dialog-header">
|
<div class="dialog-header">
|
||||||
<h3 id="linkDialogTitle" data-i18n="links.dialog.add">Add Link</h3>
|
<h3 id="linkDialogTitle" data-i18n="links.dialog.add">Add Link</h3>
|
||||||
</div>
|
</div>
|
||||||
<form id="linkForm" onsubmit="saveLink(event)">
|
<form id="linkForm" data-onsubmit="saveLink(event)">
|
||||||
<div class="dialog-body">
|
<div class="dialog-body">
|
||||||
<input type="hidden" id="linkOriginalName">
|
<input type="hidden" id="linkOriginalName">
|
||||||
<input type="hidden" id="linkIsEdit">
|
<input type="hidden" id="linkIsEdit">
|
||||||
@@ -707,7 +716,7 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="dialog-footer">
|
<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>
|
<button type="submit" class="btn-primary" data-i18n="links.button.save">Save</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -734,7 +743,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dialog-footer">
|
<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>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
@@ -743,7 +752,7 @@
|
|||||||
<div class="dialog-header">
|
<div class="dialog-header">
|
||||||
<h3 id="folderDialogTitle" data-i18n="browser.folder_dialog.title_add">Add Media Folder</h3>
|
<h3 id="folderDialogTitle" data-i18n="browser.folder_dialog.title_add">Add Media Folder</h3>
|
||||||
</div>
|
</div>
|
||||||
<form id="folderForm" onsubmit="saveFolder(event)">
|
<form id="folderForm" data-onsubmit="saveFolder(event)">
|
||||||
<div class="dialog-body">
|
<div class="dialog-body">
|
||||||
<input type="hidden" id="folderIsEdit">
|
<input type="hidden" id="folderIsEdit">
|
||||||
<input type="hidden" id="folderOriginalId">
|
<input type="hidden" id="folderOriginalId">
|
||||||
@@ -770,7 +779,7 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="dialog-footer">
|
<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>
|
<button type="submit" class="btn-primary" data-i18n="browser.folder_dialog.save">Save</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -788,16 +797,31 @@
|
|||||||
<!-- Toast Notifications -->
|
<!-- Toast Notifications -->
|
||||||
<div class="toast-container" id="toast-container"></div>
|
<div class="toast-container" id="toast-container"></div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- About Dialog -->
|
||||||
<footer>
|
<dialog id="aboutDialog" class="about-dialog">
|
||||||
<div>
|
<div class="dialog-header">
|
||||||
<span data-i18n="footer.created_by">Created by</span> <strong>Alexei Dolgolyov</strong>
|
<h3 data-i18n="about.title">About</h3>
|
||||||
<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>
|
|
||||||
</div>
|
</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>
|
<script src="/static/dist/app.bundle.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
VOLUME_THROTTLE_MS, VOLUME_RELEASE_DELAY_MS,
|
VOLUME_THROTTLE_MS, VOLUME_RELEASE_DELAY_MS,
|
||||||
changeLocale, t,
|
changeLocale, t,
|
||||||
setAuthRequired,
|
setAuthRequired,
|
||||||
|
showAboutDialog, closeAboutDialog,
|
||||||
} from './core.js';
|
} from './core.js';
|
||||||
|
|
||||||
// Layer 1: Player (tabs, theme, accent, vinyl, visualizer, UI)
|
// Layer 1: Player (tabs, theme, accent, vinyl, visualizer, UI)
|
||||||
@@ -62,6 +63,8 @@ import {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange,
|
loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange,
|
||||||
|
onDisplayContrastInput, onDisplayContrastChange,
|
||||||
|
onDisplayInputSourceChange, onDisplayColorPresetChange, onDisplayPictureModeChange,
|
||||||
toggleDisplayPower, loadHeaderLinks, loadLinksTable,
|
toggleDisplayPower, loadHeaderLinks, loadLinksTable,
|
||||||
showAddLinkDialog, showEditLinkDialog, closeLinkDialog, saveLink, deleteLinkConfirm,
|
showAddLinkDialog, showEditLinkDialog, closeLinkDialog, saveLink, deleteLinkConfirm,
|
||||||
linkFormDirty, setLinkFormDirty,
|
linkFormDirty, setLinkFormDirty,
|
||||||
@@ -71,6 +74,10 @@ import {
|
|||||||
toggleDynamicBackground, applyDynamicBackground, updateBackgroundColors,
|
toggleDynamicBackground, applyDynamicBackground, updateBackgroundColors,
|
||||||
} from './background.js';
|
} from './background.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
updateForegroundUI, loadForegroundProcess,
|
||||||
|
} from './foreground.js';
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Register late-bound callbacks for core's updateAllText()
|
// Register late-bound callbacks for core's updateAllText()
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -126,9 +133,15 @@ Object.assign(window, {
|
|||||||
saveLink, deleteLinkConfirm,
|
saveLink, deleteLinkConfirm,
|
||||||
// Display
|
// Display
|
||||||
loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange,
|
loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange,
|
||||||
|
onDisplayContrastInput, onDisplayContrastChange,
|
||||||
|
onDisplayInputSourceChange, onDisplayColorPresetChange, onDisplayPictureModeChange,
|
||||||
toggleDisplayPower,
|
toggleDisplayPower,
|
||||||
// Audio device
|
// Audio device
|
||||||
onAudioDeviceChanged,
|
onAudioDeviceChanged,
|
||||||
|
// About
|
||||||
|
showAboutDialog, closeAboutDialog,
|
||||||
|
// Foreground
|
||||||
|
loadForegroundProcess,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -152,10 +165,72 @@ HTMLDialogElement.prototype.showModal = function (...args) {
|
|||||||
return result;
|
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 () => {
|
window.addEventListener('DOMContentLoaded', async () => {
|
||||||
// Cache DOM references
|
// Cache DOM references
|
||||||
cacheDom();
|
cacheDom();
|
||||||
|
|
||||||
|
// Wire CSP-safe inline-handler stand-ins from index.html
|
||||||
|
wireInlineHandlers(document);
|
||||||
|
|
||||||
// Initialize theme and accent color
|
// Initialize theme and accent color
|
||||||
initTheme();
|
initTheme();
|
||||||
initAccentColor();
|
initAccentColor();
|
||||||
@@ -399,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)
|
// Delegated click handlers for link table actions (XSS-safe)
|
||||||
document.getElementById('linksTableBody').addEventListener('click', (e) => {
|
document.getElementById('linksTableBody').addEventListener('click', (e) => {
|
||||||
const btn = e.target.closest('[data-action]');
|
const btn = e.target.closest('[data-action]');
|
||||||
|
|||||||
@@ -66,12 +66,14 @@ function showRootFolders() {
|
|||||||
// Hide search at root level
|
// Hide search at root level
|
||||||
showBrowserSearch(false);
|
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');
|
const breadcrumb = document.getElementById('breadcrumb');
|
||||||
breadcrumb.innerHTML = '';
|
breadcrumb.innerHTML = '';
|
||||||
const root = document.createElement('span');
|
const root = document.createElement('span');
|
||||||
root.className = 'breadcrumb-item breadcrumb-home';
|
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);
|
breadcrumb.appendChild(root);
|
||||||
|
|
||||||
// Hide play all button and pagination
|
// Hide play all button and pagination
|
||||||
@@ -133,8 +135,10 @@ function showRootFolders() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function browsePath(folderId, path, offset = 0, nocache = false) {
|
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);
|
showBrowserSearch(false);
|
||||||
|
bumpBrowseGen();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!hasCredentials()) return;
|
if (!hasCredentials()) return;
|
||||||
@@ -195,10 +199,13 @@ function renderBreadcrumbs(currentPathStr, parentPath) {
|
|||||||
const parts = (currentPathStr || '').split('/').filter(p => p);
|
const parts = (currentPathStr || '').split('/').filter(p => p);
|
||||||
let path = '/';
|
let path = '/';
|
||||||
|
|
||||||
// Home link (back to folder list)
|
// Home link (back to folder list) — use a real <button> so it's
|
||||||
const home = document.createElement('span');
|
// keyboard-focusable and reachable by screen readers.
|
||||||
|
const home = document.createElement('button');
|
||||||
|
home.type = 'button';
|
||||||
home.className = 'breadcrumb-item breadcrumb-home';
|
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();
|
home.onclick = () => showRootFolders();
|
||||||
breadcrumb.appendChild(home);
|
breadcrumb.appendChild(home);
|
||||||
|
|
||||||
@@ -512,16 +519,34 @@ function formatBitrate(bps) {
|
|||||||
return Math.round(bps / 1000) + ' kbps';
|
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) {
|
async function loadThumbnail(imgElement, fileName) {
|
||||||
|
const myGen = currentBrowseGen();
|
||||||
|
const folderId = currentFolderId;
|
||||||
|
const relPath = buildRelativeFilePath(currentPath, fileName);
|
||||||
|
const cacheKey = `${folderId}|${relPath}`;
|
||||||
try {
|
try {
|
||||||
if (!hasCredentials()) return;
|
if (!hasCredentials()) return;
|
||||||
|
// Note: the imgElement is intentionally NOT in the DOM yet when
|
||||||
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName);
|
// 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
|
// Check cache first
|
||||||
if (thumbnailCache.has(absolutePath)) {
|
if (thumbnailCache.has(cacheKey)) {
|
||||||
const cachedUrl = thumbnailCache.get(absolutePath);
|
const cachedUrl = thumbnailCache.get(cacheKey);
|
||||||
imgElement.onload = () => {
|
imgElement.onload = () => {
|
||||||
|
if (!imgElement.isConnected) return;
|
||||||
imgElement.classList.remove('loading');
|
imgElement.classList.remove('loading');
|
||||||
imgElement.classList.add('loaded');
|
imgElement.classList.add('loaded');
|
||||||
};
|
};
|
||||||
@@ -529,17 +554,24 @@ async function loadThumbnail(imgElement, fileName) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const encodedPath = encodeURIComponent(absolutePath);
|
const params = new URLSearchParams({
|
||||||
|
folder_id: folderId,
|
||||||
|
path: relPath,
|
||||||
|
size: 'medium',
|
||||||
|
});
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/browser/thumbnail?path=${encodedPath}&size=medium`,
|
`/api/browser/thumbnail?${params.toString()}`,
|
||||||
{ headers: getAuthHeaders() }
|
{ headers: getAuthHeaders() }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Drop the response if the user has since navigated away.
|
||||||
|
if (myGen !== currentBrowseGen() || !imgElement.isConnected) return;
|
||||||
|
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
|
if (myGen !== currentBrowseGen() || !imgElement.isConnected) return;
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
thumbnailCache.set(absolutePath, url);
|
thumbnailCache.set(cacheKey, url);
|
||||||
|
|
||||||
// Evict oldest entries when cache exceeds limit
|
// Evict oldest entries when cache exceeds limit
|
||||||
if (thumbnailCache.size > THUMBNAIL_CACHE_MAX) {
|
if (thumbnailCache.size > THUMBNAIL_CACHE_MAX) {
|
||||||
@@ -548,13 +580,11 @@ async function loadThumbnail(imgElement, fileName) {
|
|||||||
thumbnailCache.delete(oldest);
|
thumbnailCache.delete(oldest);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for image to actually load before showing it
|
|
||||||
imgElement.onload = () => {
|
imgElement.onload = () => {
|
||||||
imgElement.classList.remove('loading');
|
imgElement.classList.remove('loading');
|
||||||
imgElement.classList.add('loaded');
|
imgElement.classList.add('loaded');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Revoke previous blob URL if not managed by cache
|
|
||||||
if (imgElement.src && imgElement.src.startsWith('blob:')) {
|
if (imgElement.src && imgElement.src.startsWith('blob:')) {
|
||||||
let isCached = false;
|
let isCached = false;
|
||||||
for (const cachedUrl of thumbnailCache.values()) {
|
for (const cachedUrl of thumbnailCache.values()) {
|
||||||
@@ -564,8 +594,8 @@ async function loadThumbnail(imgElement, fileName) {
|
|||||||
}
|
}
|
||||||
imgElement.src = url;
|
imgElement.src = url;
|
||||||
} else {
|
} else {
|
||||||
// Fallback to icon (204 = no thumbnail available)
|
|
||||||
const parent = imgElement.parentElement;
|
const parent = imgElement.parentElement;
|
||||||
|
if (!parent) return;
|
||||||
const isList = parent.classList.contains('browser-list-icon');
|
const isList = parent.classList.contains('browser-list-icon');
|
||||||
imgElement.remove();
|
imgElement.remove();
|
||||||
if (isList) {
|
if (isList) {
|
||||||
@@ -579,7 +609,7 @@ async function loadThumbnail(imgElement, fileName) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading thumbnail:', 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 {
|
try {
|
||||||
if (!hasCredentials()) return;
|
if (!hasCredentials()) return;
|
||||||
|
|
||||||
const absolutePath = buildAbsolutePath(currentFolderId, currentPath, fileName);
|
const relativePath = buildRelativeFilePath(currentPath, fileName);
|
||||||
|
|
||||||
const response = await fetch('/api/browser/play', {
|
const response = await fetch('/api/browser/play', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
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');
|
if (!response.ok) throw new Error('Failed to play file');
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ async function _loadCallbacksTableImpl() {
|
|||||||
`).join('');
|
`).join('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading callbacks:', 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);
|
const callback = callbacksList.find(c => c.name === callbackName);
|
||||||
|
|
||||||
if (!callback) {
|
if (!callback) {
|
||||||
showToast('Callback not found', 'error');
|
showToast(t('callbacks.msg.not_found'), 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ export async function showEditCallbackDialog(callbackName) {
|
|||||||
dialog.showModal();
|
dialog.showModal();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading callback for edit:', 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
|
shell: true
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const encodedName = encodeURIComponent(callbackName);
|
||||||
const endpoint = isEdit ?
|
const endpoint = isEdit ?
|
||||||
`/api/callbacks/update/${callbackName}` :
|
`/api/callbacks/update/${encodedName}` :
|
||||||
`/api/callbacks/create/${callbackName}`;
|
`/api/callbacks/create/${encodedName}`;
|
||||||
|
|
||||||
const method = isEdit ? 'PUT' : 'POST';
|
const method = isEdit ? 'PUT' : 'POST';
|
||||||
|
|
||||||
@@ -191,16 +192,16 @@ export async function saveCallback(event) {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (response.ok && result.success) {
|
if (response.ok && result.success) {
|
||||||
showToast(`Callback ${isEdit ? 'updated' : 'created'} successfully`, 'success');
|
showToast(t(isEdit ? 'callbacks.msg.updated' : 'callbacks.msg.created'), 'success');
|
||||||
callbackFormDirty = false;
|
callbackFormDirty = false;
|
||||||
closeCallbackDialog();
|
closeCallbackDialog();
|
||||||
loadCallbacksTable();
|
loadCallbacksTable();
|
||||||
} else {
|
} 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) {
|
} catch (error) {
|
||||||
console.error('Error saving callback:', 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 {
|
} finally {
|
||||||
if (submitBtn) submitBtn.disabled = false;
|
if (submitBtn) submitBtn.disabled = false;
|
||||||
}
|
}
|
||||||
@@ -212,7 +213,7 @@ export async function deleteCallbackConfirm(callbackName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/callbacks/delete/${callbackName}`, {
|
const response = await fetch(`/api/callbacks/delete/${encodeURIComponent(callbackName)}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: getAuthHeaders()
|
headers: getAuthHeaders()
|
||||||
});
|
});
|
||||||
@@ -220,13 +221,13 @@ export async function deleteCallbackConfirm(callbackName) {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (response.ok && result.success) {
|
if (response.ok && result.success) {
|
||||||
showToast('Callback deleted successfully', 'success');
|
showToast(t('callbacks.msg.deleted'), 'success');
|
||||||
loadCallbacksTable();
|
loadCallbacksTable();
|
||||||
} else {
|
} else {
|
||||||
showToast(result.detail || 'Failed to delete callback', 'error');
|
showToast(result.detail || t('callbacks.msg.delete_failed'), 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting callback:', error);
|
console.error('Error deleting callback:', error);
|
||||||
showToast('Error deleting callback', 'error');
|
showToast(t('callbacks.msg.delete_failed'), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ export const VOLUME_RELEASE_DELAY_MS = 500;
|
|||||||
// Shared state (accessed across multiple modules)
|
// Shared state (accessed across multiple modules)
|
||||||
export let ws = null;
|
export let ws = null;
|
||||||
export function setWs(value) { ws = value; }
|
export function setWs(value) { ws = value; }
|
||||||
|
export function getWs() { return ws; }
|
||||||
export let currentState = 'idle';
|
export let currentState = 'idle';
|
||||||
export function setCurrentState(value) { currentState = value; }
|
export function setCurrentState(value) { currentState = value; }
|
||||||
export let currentDuration = 0;
|
export let currentDuration = 0;
|
||||||
@@ -397,6 +398,16 @@ export function closeDialog(dialog) {
|
|||||||
}, { once: true });
|
}, { 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) {
|
export function showConfirm(message) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const dialog = document.getElementById('confirmDialog');
|
const dialog = document.getElementById('confirmDialog');
|
||||||
@@ -503,6 +514,12 @@ export function setVolume(volume) {
|
|||||||
sendCommand('volume', { volume: 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() {
|
export function toggleMute() {
|
||||||
sendCommand('mute');
|
sendCommand('mute');
|
||||||
@@ -526,23 +543,68 @@ function _persistMdiCache() {
|
|||||||
try { localStorage.setItem('mdiIconCache', JSON.stringify(mdiIconCache)); } catch {}
|
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) {
|
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];
|
if (mdiIconCache[name]) return mdiIconCache[name];
|
||||||
|
|
||||||
try {
|
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) {
|
if (response.ok) {
|
||||||
const svg = await response.text();
|
const raw = await response.text();
|
||||||
mdiIconCache[name] = svg;
|
const safe = sanitizeSvg(raw);
|
||||||
|
if (safe) {
|
||||||
|
mdiIconCache[name] = safe;
|
||||||
_persistMdiCache();
|
_persistMdiCache();
|
||||||
return svg;
|
return safe;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to fetch MDI icon:', name, 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) {
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+383
-21
@@ -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 { t, showToast, escapeHtml, closeDialog, showConfirm, resolveMdiIcons, fetchMdiIcon, getAuthHeaders, hasCredentials } from './core.js';
|
||||||
|
import { IconSelect } from './icon-select.js';
|
||||||
|
|
||||||
let displayBrightnessTimers = {};
|
let displayBrightnessTimers = {};
|
||||||
|
let displayContrastTimers = {};
|
||||||
|
let _displayIconSelects = [];
|
||||||
const DISPLAY_THROTTLE_MS = 50;
|
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() {
|
export async function loadDisplayMonitors() {
|
||||||
if (!hasCredentials()) return;
|
if (!hasCredentials()) return;
|
||||||
|
|
||||||
@@ -14,7 +109,7 @@ export async function loadDisplayMonitors() {
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/display/monitors?refresh=true', {
|
const response = await fetch('/api/display/monitors', {
|
||||||
headers: getAuthHeaders()
|
headers: getAuthHeaders()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,7 +131,13 @@ export async function loadDisplayMonitors() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Destroy IconSelects from a previous render so listeners + popups
|
||||||
|
// don't pile up.
|
||||||
|
_displayIconSelects.forEach(inst => { try { inst.destroy(); } catch (_) {} });
|
||||||
|
_displayIconSelects = [];
|
||||||
|
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
const pendingIconSelects = [];
|
||||||
monitors.forEach(monitor => {
|
monitors.forEach(monitor => {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'display-monitor-card';
|
card.className = 'display-monitor-card';
|
||||||
@@ -47,16 +148,19 @@ export async function loadDisplayMonitors() {
|
|||||||
|
|
||||||
let powerBtn = '';
|
let powerBtn = '';
|
||||||
if (monitor.power_supported) {
|
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 = `
|
powerBtn = `
|
||||||
<button class="display-power-btn ${monitor.power_on ? 'on' : 'off'}" id="power-btn-${monitor.id}"
|
<button class="display-power-btn ${monitor.power_on ? 'on' : 'off'}" id="power-btn-${monitor.id}"
|
||||||
onclick="toggleDisplayPower(${monitor.id}, '${monitor.name.replace(/'/g, "\\'")}')"
|
data-action="toggle-power" data-monitor-id="${monitor.id}"
|
||||||
title="${monitor.power_on ? t('display.power_off') : t('display.power_on')}">
|
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>
|
<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>`;
|
</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const details = [monitor.resolution, monitor.manufacturer].filter(Boolean).join(' \u00B7 ');
|
const details = [monitor.resolution, monitor.manufacturer].filter(Boolean).join(' \u00B7 ');
|
||||||
const detailsHtml = details ? `<span class="display-monitor-details">${details}</span>` : '';
|
const detailsHtml = details ? `<span class="display-monitor-details">${escapeHtml(details)}</span>` : '';
|
||||||
const primaryBadge = monitor.is_primary
|
const primaryBadge = monitor.is_primary
|
||||||
? `<span class="display-primary-badge" title="${t('display.primary')}" aria-label="${t('display.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">
|
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
|
||||||
@@ -65,29 +169,169 @@ export async function loadDisplayMonitors() {
|
|||||||
</span>`
|
</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 = `
|
card.innerHTML = `
|
||||||
<div class="display-monitor-header">
|
<div class="display-monitor-header">
|
||||||
<svg class="display-monitor-icon" viewBox="0 0 24 24" width="20" height="20">
|
<svg class="display-monitor-icon" viewBox="0 0 24 24" width="20" height="20">
|
||||||
<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"/>
|
<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>
|
</svg>
|
||||||
<div class="display-monitor-info">
|
<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}
|
${detailsHtml}
|
||||||
</div>
|
</div>
|
||||||
${powerBtn}
|
${powerBtn}
|
||||||
</div>
|
</div>
|
||||||
<div class="display-brightness-control">
|
<div class="display-slider-row display-brightness-control">
|
||||||
<svg class="display-brightness-icon" viewBox="0 0 24 24" width="16" height="16">
|
<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"/>
|
<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>
|
</svg>
|
||||||
<input type="range" class="display-brightness-slider" min="0" max="100" value="${brightnessValue}" ${brightnessDisabled}
|
<span class="display-slider-label" data-i18n="display.brightness">${t('display.brightness')}</span>
|
||||||
oninput="onDisplayBrightnessInput(${monitor.id}, this.value)"
|
<input type="range" class="display-slider display-brightness-slider" min="0" max="100" value="${brightnessValue}" ${brightnessDisabled}
|
||||||
onchange="onDisplayBrightnessChange(${monitor.id}, this.value)">
|
data-display-slider="brightness" data-monitor-id="${monitor.id}">
|
||||||
<span class="display-brightness-value" id="brightness-val-${monitor.id}">${brightnessValue}%</span>
|
<span class="display-slider-value display-brightness-value" id="brightness-val-${monitor.id}">${brightnessValue}%</span>
|
||||||
</div>`;
|
</div>
|
||||||
|
${contrastRow}
|
||||||
|
${tuningBlock}`;
|
||||||
|
|
||||||
container.appendChild(card);
|
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) {
|
} catch (e) {
|
||||||
console.error('Failed to load display monitors:', e);
|
console.error('Failed to load display monitors:', e);
|
||||||
}
|
}
|
||||||
@@ -124,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 btn = document.getElementById(`power-btn-${monitorId}`);
|
||||||
const isOn = btn && btn.classList.contains('on');
|
const isOn = btn && btn.classList.contains('on');
|
||||||
const newState = !isOn;
|
const newState = !isOn;
|
||||||
@@ -142,13 +501,13 @@ export async function toggleDisplayPower(monitorId, monitorName) {
|
|||||||
btn.classList.toggle('off', !newState);
|
btn.classList.toggle('off', !newState);
|
||||||
btn.title = newState ? t('display.power_off') : t('display.power_on');
|
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 {
|
} else {
|
||||||
showToast('Failed to change monitor power', 'error');
|
showToast(t('display.msg.power_failed'), 'error');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to set display power:', e);
|
console.error('Failed to set display power:', e);
|
||||||
showToast('Failed to change monitor power', 'error');
|
showToast(t('display.msg.power_failed'), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +536,8 @@ export async function loadHeaderLinks() {
|
|||||||
a.href = link.url;
|
a.href = link.url;
|
||||||
a.target = '_blank';
|
a.target = '_blank';
|
||||||
a.rel = 'noopener noreferrer';
|
a.rel = 'noopener noreferrer';
|
||||||
|
// Prevent leaking the WebUI URL (with ?token=) via Referer.
|
||||||
|
a.referrerPolicy = 'no-referrer';
|
||||||
a.className = 'header-link';
|
a.className = 'header-link';
|
||||||
a.title = link.label || link.url;
|
a.title = link.label || link.url;
|
||||||
|
|
||||||
@@ -245,7 +606,7 @@ async function _loadLinksTableImpl() {
|
|||||||
resolveMdiIcons(tbody);
|
resolveMdiIcons(tbody);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading links:', 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>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,9 +709,10 @@ export async function saveLink(event) {
|
|||||||
description: document.getElementById('linkDescription').value || ''
|
description: document.getElementById('linkDescription').value || ''
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const encodedName = encodeURIComponent(linkName);
|
||||||
const endpoint = isEdit ?
|
const endpoint = isEdit ?
|
||||||
`/api/links/update/${linkName}` :
|
`/api/links/update/${encodedName}` :
|
||||||
`/api/links/create/${linkName}`;
|
`/api/links/create/${encodedName}`;
|
||||||
|
|
||||||
const method = isEdit ? 'PUT' : 'POST';
|
const method = isEdit ? 'PUT' : 'POST';
|
||||||
|
|
||||||
@@ -384,7 +746,7 @@ export async function deleteLinkConfirm(linkName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/links/delete/${linkName}`, {
|
const response = await fetch(`/api/links/delete/${encodeURIComponent(linkName)}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: getAuthHeaders()
|
headers: getAuthHeaders()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import {
|
|||||||
ws, currentState, setCurrentState, currentDuration, setCurrentDuration,
|
ws, currentState, setCurrentState, currentDuration, setCurrentDuration,
|
||||||
currentPosition, setCurrentPosition, isUserAdjustingVolume,
|
currentPosition, setCurrentPosition, isUserAdjustingVolume,
|
||||||
lastStatus, setLastStatus, currentPlayState, setCurrentPlayState,
|
lastStatus, setLastStatus, currentPlayState, setCurrentPlayState,
|
||||||
POSITION_INTERPOLATION_MS, seek,
|
POSITION_INTERPOLATION_MS, seek, notifyRemoteVolume,
|
||||||
getAuthHeaders, hasCredentials,
|
getAuthHeaders, hasCredentials,
|
||||||
|
togglePlayPause, nextTrack, previousTrack,
|
||||||
} from './core.js';
|
} from './core.js';
|
||||||
import { updateBackgroundColors } from './background.js';
|
import { updateBackgroundColors } from './background.js';
|
||||||
import { loadDisplayMonitors } from './links.js';
|
import { loadDisplayMonitors } from './links.js';
|
||||||
|
import { loadForegroundProcess } from './foreground.js';
|
||||||
import { IconSelect } from './icon-select.js';
|
import { IconSelect } from './icon-select.js';
|
||||||
|
|
||||||
// Tab management
|
// Tab management
|
||||||
@@ -75,6 +77,7 @@ export function switchTab(tabName) {
|
|||||||
|
|
||||||
if (tabName === 'display') {
|
if (tabName === 'display') {
|
||||||
loadDisplayMonitors();
|
loadDisplayMonitors();
|
||||||
|
loadForegroundProcess();
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem('activeTab', tabName);
|
localStorage.setItem('activeTab', tabName);
|
||||||
@@ -205,20 +208,39 @@ export function renderAccentSwatches() {
|
|||||||
const swatches = accentPresets.map(p =>
|
const swatches = accentPresets.map(p =>
|
||||||
`<div class="accent-swatch ${p.color === current ? 'active' : ''}"
|
`<div class="accent-swatch ${p.color === current ? 'active' : ''}"
|
||||||
style="background: ${p.color}"
|
style="background: ${p.color}"
|
||||||
onclick="selectAccentColor('${p.color}', '${p.hover}')"
|
data-accent-color="${p.color}" data-accent-hover="${p.hover}"
|
||||||
title="${p.name}"></div>`
|
title="${p.name}"></div>`
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
const customRow = `
|
const customRow = `
|
||||||
<div class="accent-custom-row ${isCustom ? 'active' : ''}" onclick="document.getElementById('accentCustomInput').click()">
|
<div class="accent-custom-row ${isCustom ? 'active' : ''}" data-accent-custom-row>
|
||||||
<span class="accent-custom-swatch" style="background: ${isCustom ? current : '#888'}"></span>
|
<span class="accent-custom-swatch" style="background: ${isCustom ? current : '#888'}"></span>
|
||||||
<span class="accent-custom-label">${t('accent.custom')}</span>
|
<span class="accent-custom-label">${t('accent.custom')}</span>
|
||||||
<input type="color" id="accentCustomInput" value="${current}"
|
<input type="color" id="accentCustomInput" value="${current}">
|
||||||
onclick="event.stopPropagation()"
|
|
||||||
onchange="selectAccentColor(this.value, lightenColor(this.value, 15))">
|
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
dropdown.innerHTML = swatches + customRow;
|
dropdown.innerHTML = swatches + customRow;
|
||||||
|
|
||||||
|
// Wire CSP-safe handlers (script-src 'self' blocks inline on* attributes).
|
||||||
|
dropdown.querySelectorAll('.accent-swatch[data-accent-color]').forEach(el => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
selectAccentColor(el.dataset.accentColor, el.dataset.accentHover);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const customRowEl = dropdown.querySelector('[data-accent-custom-row]');
|
||||||
|
const customInput = dropdown.querySelector('#accentCustomInput');
|
||||||
|
if (customRowEl && customInput) {
|
||||||
|
customRowEl.addEventListener('click', (e) => {
|
||||||
|
// The native color popup only opens from a user-initiated click on
|
||||||
|
// the <input>. Forward clicks on the row to the input — except when
|
||||||
|
// the input itself was the source (avoids re-entry).
|
||||||
|
if (e.target !== customInput) customInput.click();
|
||||||
|
});
|
||||||
|
customInput.addEventListener('click', (e) => e.stopPropagation());
|
||||||
|
customInput.addEventListener('change', () => {
|
||||||
|
selectAccentColor(customInput.value, lightenColor(customInput.value, 15));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectAccentColor(color, hover) {
|
export function selectAccentColor(color, hover) {
|
||||||
@@ -360,11 +382,85 @@ function buildVisualizerGradient() {
|
|||||||
|
|
||||||
function startVisualizerRender() {
|
function startVisualizerRender() {
|
||||||
if (visualizerAnimFrame) return;
|
if (visualizerAnimFrame) return;
|
||||||
|
// Don't even queue a frame while the tab is hidden — rAF still fires on
|
||||||
|
// hidden tabs (throttled but not paused) and would burn CPU + battery
|
||||||
|
// smoothing into bars no one can see. We resume on `visibilitychange`.
|
||||||
|
if (typeof document !== 'undefined' && document.hidden) return;
|
||||||
// Cache editorial spectrum bar refs once per start.
|
// Cache editorial spectrum bar refs once per start.
|
||||||
cacheEditorialSpectrumBars();
|
cacheEditorialSpectrumBars();
|
||||||
renderVisualizerFrame();
|
renderVisualizerFrame();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── OS Media Session integration ─────────────────────────────
|
||||||
|
// Hooks the page into the system's media session so headset / lockscreen /
|
||||||
|
// Bluetooth play-pause-skip buttons drive the active track. Action handlers
|
||||||
|
// are set once and never re-registered; only the metadata + playback state
|
||||||
|
// flip when a track changes.
|
||||||
|
let _mediaSessionInitialised = false;
|
||||||
|
let _lastMediaSessionKey = '';
|
||||||
|
function syncMediaSession(status) {
|
||||||
|
if (typeof navigator === 'undefined' || !('mediaSession' in navigator)) return;
|
||||||
|
const session = navigator.mediaSession;
|
||||||
|
|
||||||
|
if (!_mediaSessionInitialised) {
|
||||||
|
const setHandler = (name, fn) => {
|
||||||
|
try { session.setActionHandler(name, fn); } catch { /* unsupported action */ }
|
||||||
|
};
|
||||||
|
setHandler('play', () => togglePlayPause());
|
||||||
|
setHandler('pause', () => togglePlayPause());
|
||||||
|
setHandler('nexttrack', () => nextTrack());
|
||||||
|
setHandler('previoustrack', () => previousTrack());
|
||||||
|
setHandler('seekto', (ev) => { if (ev && typeof ev.seekTime === 'number') seek(ev.seekTime); });
|
||||||
|
_mediaSessionInitialised = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track-identity key — re-build metadata only when title/artist/album change.
|
||||||
|
const artworkSrc = status && status.album_art_url ? '/api/media/artwork' : '';
|
||||||
|
const key = `${status.title || ''}|${status.artist || ''}|${status.album || ''}|${artworkSrc}`;
|
||||||
|
if (key !== _lastMediaSessionKey) {
|
||||||
|
_lastMediaSessionKey = key;
|
||||||
|
try {
|
||||||
|
session.metadata = new MediaMetadata({
|
||||||
|
title: status.title || '',
|
||||||
|
artist: status.artist || '',
|
||||||
|
album: status.album || '',
|
||||||
|
artwork: artworkSrc ? [{ src: artworkSrc, sizes: '512x512', type: 'image/*' }] : [],
|
||||||
|
});
|
||||||
|
} catch { /* MediaMetadata unsupported on very old browsers */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
session.playbackState =
|
||||||
|
status.state === 'playing' ? 'playing'
|
||||||
|
: status.state === 'paused' ? 'paused'
|
||||||
|
: 'none';
|
||||||
|
|
||||||
|
if (typeof session.setPositionState === 'function'
|
||||||
|
&& status.duration && status.duration > 0
|
||||||
|
&& typeof status.position === 'number') {
|
||||||
|
try {
|
||||||
|
session.setPositionState({
|
||||||
|
duration: status.duration,
|
||||||
|
position: Math.min(status.position, status.duration),
|
||||||
|
playbackRate: 1.0,
|
||||||
|
});
|
||||||
|
} catch { /* invalid range — ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pause / resume the visualizer with tab visibility. Idempotent: called once
|
||||||
|
// at module init below, no-op if no listener support.
|
||||||
|
if (typeof document !== 'undefined' && document.addEventListener) {
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
stopVisualizerRender();
|
||||||
|
} else if (frequencyData) {
|
||||||
|
// Only restart if a payload is live (otherwise startVisualizerRender
|
||||||
|
// would queue a no-op rAF chain forever waiting for one).
|
||||||
|
startVisualizerRender();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function stopVisualizerRender() {
|
export function stopVisualizerRender() {
|
||||||
if (visualizerAnimFrame) {
|
if (visualizerAnimFrame) {
|
||||||
cancelAnimationFrame(visualizerAnimFrame);
|
cancelAnimationFrame(visualizerAnimFrame);
|
||||||
@@ -687,54 +783,49 @@ export async function onAudioDeviceChanged() {
|
|||||||
|
|
||||||
let lastArtworkKey = null;
|
let lastArtworkKey = null;
|
||||||
let currentArtworkBlobUrl = null;
|
let currentArtworkBlobUrl = null;
|
||||||
|
let artworkFetchGen = 0;
|
||||||
|
let artworkAbort = null;
|
||||||
let lastPositionUpdate = 0;
|
let lastPositionUpdate = 0;
|
||||||
let lastPositionValue = 0;
|
let lastPositionValue = 0;
|
||||||
let interpolationInterval = null;
|
let interpolationInterval = null;
|
||||||
|
|
||||||
export function setupProgressDrag(bar, fill) {
|
export function setupProgressDrag(bar, fill) {
|
||||||
let dragging = false;
|
// Listeners are attached on mousedown and removed on mouseup so the
|
||||||
|
// document doesn't carry per-progress-bar move handlers for the entire
|
||||||
|
// session (especially expensive on mobile).
|
||||||
function getPercent(clientX) {
|
function getPercent(clientX) {
|
||||||
const rect = bar.getBoundingClientRect();
|
const rect = bar.getBoundingClientRect();
|
||||||
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||||
}
|
}
|
||||||
|
function updatePreview(percent) { fill.style.width = (percent * 100) + '%'; }
|
||||||
|
|
||||||
function updatePreview(percent) {
|
function pointerStart(getX, moveEvent, endEvent, getMoveX, getEndX) {
|
||||||
fill.style.width = (percent * 100) + '%';
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleStart(clientX) {
|
|
||||||
if (currentDuration <= 0) return;
|
if (currentDuration <= 0) return;
|
||||||
dragging = true;
|
|
||||||
bar.classList.add('dragging');
|
bar.classList.add('dragging');
|
||||||
updatePreview(getPercent(clientX));
|
updatePreview(getPercent(getX));
|
||||||
}
|
|
||||||
|
|
||||||
function handleMove(clientX) {
|
function onMove(e) { updatePreview(getPercent(getMoveX(e))); }
|
||||||
if (!dragging) return;
|
function onEnd(e) {
|
||||||
updatePreview(getPercent(clientX));
|
document.removeEventListener(moveEvent, onMove);
|
||||||
}
|
document.removeEventListener(endEvent, onEnd);
|
||||||
|
|
||||||
function handleEnd(clientX) {
|
|
||||||
if (!dragging) return;
|
|
||||||
dragging = false;
|
|
||||||
bar.classList.remove('dragging');
|
bar.classList.remove('dragging');
|
||||||
const percent = getPercent(clientX);
|
const clientX = getEndX(e);
|
||||||
seek(percent * currentDuration);
|
if (clientX !== undefined) seek(getPercent(clientX) * currentDuration);
|
||||||
|
}
|
||||||
|
document.addEventListener(moveEvent, onMove);
|
||||||
|
document.addEventListener(endEvent, onEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
bar.addEventListener('mousedown', (e) => { e.preventDefault(); handleStart(e.clientX); });
|
bar.addEventListener('mousedown', (e) => {
|
||||||
document.addEventListener('mousemove', (e) => { handleMove(e.clientX); });
|
e.preventDefault();
|
||||||
document.addEventListener('mouseup', (e) => { handleEnd(e.clientX); });
|
pointerStart(e.clientX, 'mousemove', 'mouseup',
|
||||||
|
(ev) => ev.clientX, (ev) => ev.clientX);
|
||||||
bar.addEventListener('touchstart', (e) => { handleStart(e.touches[0].clientX); }, { passive: true });
|
|
||||||
document.addEventListener('touchmove', (e) => { if (dragging) handleMove(e.touches[0].clientX); });
|
|
||||||
document.addEventListener('touchend', (e) => {
|
|
||||||
if (dragging) {
|
|
||||||
const touch = e.changedTouches[0];
|
|
||||||
handleEnd(touch.clientX);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
bar.addEventListener('touchstart', (e) => {
|
||||||
|
pointerStart(e.touches[0].clientX, 'touchmove', 'touchend',
|
||||||
|
(ev) => ev.touches[0].clientX,
|
||||||
|
(ev) => ev.changedTouches?.[0]?.clientX);
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
bar.addEventListener('click', (e) => {
|
bar.addEventListener('click', (e) => {
|
||||||
if (currentDuration > 0) {
|
if (currentDuration > 0) {
|
||||||
@@ -811,28 +902,35 @@ export function updateUI(status) {
|
|||||||
lastArtworkKey = artworkKey;
|
lastArtworkKey = artworkKey;
|
||||||
const placeholderArt = "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";
|
const placeholderArt = "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";
|
||||||
const placeholderGlow = "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";
|
const placeholderGlow = "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";
|
||||||
|
// Cancel any in-flight artwork fetch and bump the generation so a
|
||||||
|
// late response from a previous track cannot overwrite the new one.
|
||||||
|
if (artworkAbort) {
|
||||||
|
try { artworkAbort.abort(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
const myGen = ++artworkFetchGen;
|
||||||
|
artworkAbort = new AbortController();
|
||||||
|
|
||||||
if (artworkSource) {
|
if (artworkSource) {
|
||||||
// No cache-buster: when album_art_url is unchanged the
|
|
||||||
// browser can reuse the decoded bitmap. The artworkKey gate
|
|
||||||
// already skips fetches when the user hasn't switched tracks.
|
|
||||||
fetch('/api/media/artwork', {
|
fetch('/api/media/artwork', {
|
||||||
headers: getAuthHeaders()
|
headers: getAuthHeaders(),
|
||||||
|
signal: artworkAbort.signal,
|
||||||
})
|
})
|
||||||
.then(r => r.ok ? r.blob() : null)
|
.then(r => r.ok ? r.blob() : null)
|
||||||
.then(blob => {
|
.then(blob => {
|
||||||
if (!blob) return;
|
if (!blob || myGen !== artworkFetchGen) return;
|
||||||
const oldBlobUrl = currentArtworkBlobUrl;
|
const oldBlobUrl = currentArtworkBlobUrl;
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
currentArtworkBlobUrl = url;
|
currentArtworkBlobUrl = url;
|
||||||
swapArtworkSrc(dom.albumArt, url);
|
swapArtworkSrc(dom.albumArt, url);
|
||||||
if (dom.miniAlbumArt.src !== url) dom.miniAlbumArt.src = url;
|
if (dom.miniAlbumArt.src !== url) dom.miniAlbumArt.src = url;
|
||||||
if (dom.albumArtGlow && dom.albumArtGlow.src !== url) dom.albumArtGlow.src = url;
|
if (dom.albumArtGlow && dom.albumArtGlow.src !== url) dom.albumArtGlow.src = url;
|
||||||
// Mirror to fullscreen bloom directly — drops the
|
|
||||||
// MutationObserver fan-out path.
|
|
||||||
syncFullscreenBloomArt(url);
|
syncFullscreenBloomArt(url);
|
||||||
if (oldBlobUrl) setTimeout(() => URL.revokeObjectURL(oldBlobUrl), 1000);
|
if (oldBlobUrl) setTimeout(() => URL.revokeObjectURL(oldBlobUrl), 1000);
|
||||||
})
|
})
|
||||||
.catch(err => console.error('Artwork fetch failed:', err));
|
.catch(err => {
|
||||||
|
if (err && err.name === 'AbortError') return;
|
||||||
|
console.error('Artwork fetch failed:', err);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
if (currentArtworkBlobUrl) {
|
if (currentArtworkBlobUrl) {
|
||||||
URL.revokeObjectURL(currentArtworkBlobUrl);
|
URL.revokeObjectURL(currentArtworkBlobUrl);
|
||||||
@@ -858,6 +956,9 @@ export function updateUI(status) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isUserAdjustingVolume) {
|
if (!isUserAdjustingVolume) {
|
||||||
|
// Re-seed the throttling cache so a future call to setVolume() with
|
||||||
|
// the previously-sent value still propagates after an external change.
|
||||||
|
notifyRemoteVolume(status.volume);
|
||||||
dom.volumeSlider.value = status.volume;
|
dom.volumeSlider.value = status.volume;
|
||||||
dom.volumeDisplay.textContent = `${status.volume}%`;
|
dom.volumeDisplay.textContent = `${status.volume}%`;
|
||||||
dom.miniVolumeSlider.value = status.volume;
|
dom.miniVolumeSlider.value = status.volume;
|
||||||
@@ -877,6 +978,11 @@ export function updateUI(status) {
|
|||||||
|
|
||||||
updateMuteIcon(status.muted);
|
updateMuteIcon(status.muted);
|
||||||
|
|
||||||
|
// Wire the OS Media Session so headset buttons, lockscreen controls, and
|
||||||
|
// Bluetooth remotes drive the active media (not the WebUI tab). Cheap and
|
||||||
|
// idempotent — re-running setActionHandler with the same fn is a no-op.
|
||||||
|
syncMediaSession(status);
|
||||||
|
|
||||||
const src = resolveMediaSource(status.source);
|
const src = resolveMediaSource(status.source);
|
||||||
dom.source.textContent = src ? src.name : t('player.unknown_source');
|
dom.source.textContent = src ? src.name : t('player.unknown_source');
|
||||||
dom.sourceIcon.innerHTML = src?.icon || '';
|
dom.sourceIcon.innerHTML = src?.icon || '';
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ export async function displayQuickAccess() {
|
|||||||
card.href = link.url;
|
card.href = link.url;
|
||||||
card.target = '_blank';
|
card.target = '_blank';
|
||||||
card.rel = 'noopener noreferrer';
|
card.rel = 'noopener noreferrer';
|
||||||
|
// Prevent the WebUI's URL (which may carry ?token=...) from
|
||||||
|
// leaking to third-party sites via Referer.
|
||||||
|
card.referrerPolicy = 'no-referrer';
|
||||||
|
|
||||||
if (link.icon) {
|
if (link.icon) {
|
||||||
const iconEl = document.createElement('div');
|
const iconEl = document.createElement('div');
|
||||||
@@ -150,7 +153,7 @@ async function executeScript(scriptName, buttonElement) {
|
|||||||
|
|
||||||
async function _doExecuteScript(scriptName, params) {
|
async function _doExecuteScript(scriptName, params) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
|
const response = await fetch(`/api/scripts/execute/${encodeURIComponent(scriptName)}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||||
body: JSON.stringify({ params })
|
body: JSON.stringify({ params })
|
||||||
@@ -393,7 +396,7 @@ async function _loadScriptsTableImpl() {
|
|||||||
resolveMdiIcons(tbody);
|
resolveMdiIcons(tbody);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading scripts:', 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);
|
const script = scriptsList.find(s => s.name === scriptName);
|
||||||
|
|
||||||
if (!script) {
|
if (!script) {
|
||||||
showToast('Script not found', 'error');
|
showToast(t('scripts.msg.not_found'), 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,7 +473,7 @@ export async function showEditScriptDialog(scriptName) {
|
|||||||
dialog.showModal();
|
dialog.showModal();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading script for edit:', 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(),
|
parameters: _collectParameterDefinitions(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const encodedName = encodeURIComponent(scriptName);
|
||||||
const endpoint = isEdit ?
|
const endpoint = isEdit ?
|
||||||
`/api/scripts/update/${scriptName}` :
|
`/api/scripts/update/${encodedName}` :
|
||||||
`/api/scripts/create/${scriptName}`;
|
`/api/scripts/create/${encodedName}`;
|
||||||
|
|
||||||
const method = isEdit ? 'PUT' : 'POST';
|
const method = isEdit ? 'PUT' : 'POST';
|
||||||
|
|
||||||
@@ -524,15 +528,15 @@ export async function saveScript(event) {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (response.ok && result.success) {
|
if (response.ok && result.success) {
|
||||||
showToast(`Script ${isEdit ? 'updated' : 'created'} successfully`, 'success');
|
showToast(t(isEdit ? 'scripts.msg.updated' : 'scripts.msg.created'), 'success');
|
||||||
scriptFormDirty = false;
|
scriptFormDirty = false;
|
||||||
closeScriptDialog();
|
closeScriptDialog();
|
||||||
} else {
|
} 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) {
|
} catch (error) {
|
||||||
console.error('Error saving script:', 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 {
|
} finally {
|
||||||
if (submitBtn) submitBtn.disabled = false;
|
if (submitBtn) submitBtn.disabled = false;
|
||||||
}
|
}
|
||||||
@@ -544,7 +548,7 @@ export async function deleteScriptConfirm(scriptName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/scripts/delete/${scriptName}`, {
|
const response = await fetch(`/api/scripts/delete/${encodeURIComponent(scriptName)}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: getAuthHeaders()
|
headers: getAuthHeaders()
|
||||||
});
|
});
|
||||||
@@ -552,13 +556,13 @@ export async function deleteScriptConfirm(scriptName) {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (response.ok && result.success) {
|
if (response.ok && result.success) {
|
||||||
showToast('Script deleted successfully', 'success');
|
showToast(t('scripts.msg.deleted'), 'success');
|
||||||
} else {
|
} else {
|
||||||
showToast(result.detail || 'Failed to delete script', 'error');
|
showToast(result.detail || t('scripts.msg.delete_failed'), 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting script:', 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 outputPre = document.getElementById('executionOutput');
|
||||||
const errorPre = document.getElementById('executionError');
|
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 success = result.success && result.exit_code === 0;
|
||||||
const statusClass = success ? 'success' : 'error';
|
const statusClass = success ? 'success' : 'error';
|
||||||
const statusText = success ? 'Success' : 'Failed';
|
const statusText = t(success ? 'execution.success' : 'execution.failed');
|
||||||
|
|
||||||
statusDiv.innerHTML = `
|
statusDiv.innerHTML = `
|
||||||
<div class="status-item ${statusClass}">
|
<div class="status-item ${statusClass}">
|
||||||
<label>Status</label>
|
<label>${escapeHtml(t('execution.status'))}</label>
|
||||||
<value>${statusText}</value>
|
<value>${escapeHtml(statusText)}</value>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-item">
|
<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>
|
<value>${result.exit_code !== undefined ? result.exit_code : 'N/A'}</value>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-item">
|
<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>
|
<value>${result.execution_time !== undefined && result.execution_time !== null ? result.execution_time.toFixed(3) + 's' : 'N/A'}</value>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -606,7 +610,7 @@ function showExecutionResult(name, result, type = 'script') {
|
|||||||
if (result.stdout && result.stdout.trim()) {
|
if (result.stdout && result.stdout.trim()) {
|
||||||
outputPre.textContent = result.stdout;
|
outputPre.textContent = result.stdout;
|
||||||
} else {
|
} else {
|
||||||
outputPre.textContent = '(no output)';
|
outputPre.textContent = t('execution.no_output');
|
||||||
outputPre.style.fontStyle = 'italic';
|
outputPre.style.fontStyle = 'italic';
|
||||||
outputPre.style.color = 'var(--text-secondary)';
|
outputPre.style.color = 'var(--text-secondary)';
|
||||||
}
|
}
|
||||||
@@ -642,11 +646,11 @@ async function _executeScriptDebugWithParams(scriptName, params) {
|
|||||||
const title = document.getElementById('executionDialogTitle');
|
const title = document.getElementById('executionDialogTitle');
|
||||||
const statusDiv = document.getElementById('executionStatus');
|
const statusDiv = document.getElementById('executionStatus');
|
||||||
|
|
||||||
title.textContent = `Executing: ${scriptName}`;
|
title.textContent = `${t('execution.executing')}: ${scriptName}`;
|
||||||
statusDiv.innerHTML = `
|
statusDiv.innerHTML = `
|
||||||
<div class="status-item">
|
<div class="status-item">
|
||||||
<label>Status</label>
|
<label>${escapeHtml(t('execution.status'))}</label>
|
||||||
<value><span class="loading-spinner"></span> Running...</value>
|
<value><span class="loading-spinner"></span> ${escapeHtml(t('execution.running'))}</value>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.getElementById('outputSection').style.display = 'none';
|
document.getElementById('outputSection').style.display = 'none';
|
||||||
@@ -813,11 +817,11 @@ export async function executeCallbackDebug(callbackName) {
|
|||||||
const title = document.getElementById('executionDialogTitle');
|
const title = document.getElementById('executionDialogTitle');
|
||||||
const statusDiv = document.getElementById('executionStatus');
|
const statusDiv = document.getElementById('executionStatus');
|
||||||
|
|
||||||
title.textContent = `Executing: ${callbackName}`;
|
title.textContent = `${t('execution.executing')}: ${callbackName}`;
|
||||||
statusDiv.innerHTML = `
|
statusDiv.innerHTML = `
|
||||||
<div class="status-item">
|
<div class="status-item">
|
||||||
<label>Status</label>
|
<label>${escapeHtml(t('execution.status'))}</label>
|
||||||
<value><span class="loading-spinner"></span> Running...</value>
|
<value><span class="loading-spinner"></span> ${escapeHtml(t('execution.running'))}</value>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.getElementById('outputSection').style.display = 'none';
|
document.getElementById('outputSection').style.display = 'none';
|
||||||
@@ -826,7 +830,7 @@ export async function executeCallbackDebug(callbackName) {
|
|||||||
dialog.showModal();
|
dialog.showModal();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/callbacks/execute/${callbackName}`, {
|
const response = await fetch(`/api/callbacks/execute/${encodeURIComponent(callbackName)}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }
|
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import {
|
import {
|
||||||
dom, t, showToast, setWs,
|
dom, t, setWs, getWs,
|
||||||
WS_BACKOFF_BASE_MS, WS_BACKOFF_MAX_MS,
|
WS_BACKOFF_BASE_MS, WS_BACKOFF_MAX_MS,
|
||||||
WS_MAX_RECONNECT_ATTEMPTS, WS_PING_INTERVAL_MS,
|
WS_MAX_RECONNECT_ATTEMPTS, WS_PING_INTERVAL_MS,
|
||||||
authRequired, showUpdateBanner,
|
authRequired, showUpdateBanner,
|
||||||
@@ -12,10 +12,29 @@ import { updateUI, setFrequencyData, stopPositionInterpolation, loadAudioDevices
|
|||||||
import { loadScripts, loadScriptsTable, displayQuickAccess } from './scripts.js';
|
import { loadScripts, loadScriptsTable, displayQuickAccess } from './scripts.js';
|
||||||
import { loadCallbacksTable } from './callbacks.js';
|
import { loadCallbacksTable } from './callbacks.js';
|
||||||
import { loadHeaderLinks, loadLinksTable } from './links.js';
|
import { loadHeaderLinks, loadLinksTable } from './links.js';
|
||||||
|
import { updateForegroundUI } from './foreground.js';
|
||||||
|
|
||||||
let reconnectTimeout = null;
|
let reconnectTimeout = null;
|
||||||
let pingInterval = null;
|
|
||||||
let wsReconnectAttempts = 0;
|
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 = '') {
|
export function showAuthForm(errorMessage = '') {
|
||||||
const overlay = document.getElementById('auth-overlay');
|
const overlay = document.getElementById('auth-overlay');
|
||||||
@@ -47,26 +66,45 @@ export function authenticate() {
|
|||||||
|
|
||||||
export function clearToken() {
|
export function clearToken() {
|
||||||
localStorage.removeItem('media_server_token');
|
localStorage.removeItem('media_server_token');
|
||||||
// Access ws via import
|
const current = getWs();
|
||||||
import('./core.js').then(core => {
|
if (current) {
|
||||||
if (core.ws) {
|
try { current.close(1000, 'token cleared'); } catch { /* ignore */ }
|
||||||
core.ws.close();
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
showAuthForm(t('auth.cleared'));
|
showAuthForm(t('auth.cleared'));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function connectWebSocket(token) {
|
export function connectWebSocket(token) {
|
||||||
if (pingInterval) {
|
// Always cancel a pending reconnect first — otherwise a user-triggered
|
||||||
clearInterval(pingInterval);
|
// reconnect can race a scheduled one and create two live sockets.
|
||||||
pingInterval = null;
|
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 protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const wsBase = `${protocol}//${window.location.host}/api/media/ws`;
|
const wsBase = `${protocol}//${window.location.host}/api/media/ws`;
|
||||||
const wsUrl = token ? `${wsBase}?token=${encodeURIComponent(token)}` : wsBase;
|
|
||||||
|
|
||||||
const newWs = new WebSocket(wsUrl);
|
// Prefer Sec-WebSocket-Protocol-based auth so the token never appears in
|
||||||
|
// the URL (which would otherwise land in browser history, server access
|
||||||
|
// logs, and Referer headers). Keep the ?token=... fallback for clients
|
||||||
|
// that pre-date this change and don't speak the subprotocol.
|
||||||
|
let newWs;
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
newWs = new WebSocket(wsBase, [`media-server.token.${token}`]);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Subprotocol WS handshake failed, falling back to ?token=', e);
|
||||||
|
newWs = new WebSocket(`${wsBase}?token=${encodeURIComponent(token)}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newWs = new WebSocket(wsBase);
|
||||||
|
}
|
||||||
|
activeSocket = newWs;
|
||||||
setWs(newWs);
|
setWs(newWs);
|
||||||
|
|
||||||
newWs.onopen = () => {
|
newWs.onopen = () => {
|
||||||
@@ -84,10 +122,18 @@ export function connectWebSocket(token) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
newWs.onmessage = (event) => {
|
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') {
|
if (msg.type === 'status' || msg.type === 'status_update') {
|
||||||
updateUI(msg.data);
|
updateUI(msg.data);
|
||||||
|
} else if (msg.type === 'foreground' || msg.type === 'foreground_update') {
|
||||||
|
updateForegroundUI(msg.data);
|
||||||
} else if (msg.type === 'scripts_changed') {
|
} else if (msg.type === 'scripts_changed') {
|
||||||
console.log('Scripts changed, reloading...');
|
console.log('Scripts changed, reloading...');
|
||||||
loadScripts();
|
loadScripts();
|
||||||
@@ -116,6 +162,13 @@ export function connectWebSocket(token) {
|
|||||||
updateConnectionStatus(false);
|
updateConnectionStatus(false);
|
||||||
stopPositionInterpolation();
|
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) {
|
if (event.code === 4001) {
|
||||||
localStorage.removeItem('media_server_token');
|
localStorage.removeItem('media_server_token');
|
||||||
showAuthForm(t('auth.invalid'));
|
showAuthForm(t('auth.invalid'));
|
||||||
@@ -133,7 +186,9 @@ export function connectWebSocket(token) {
|
|||||||
showConnectionBanner(t('connection.reconnecting').replace('{attempt}', wsReconnectAttempts), false);
|
showConnectionBanner(t('connection.reconnecting').replace('{attempt}', wsReconnectAttempts), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearReconnect();
|
||||||
reconnectTimeout = setTimeout(() => {
|
reconnectTimeout = setTimeout(() => {
|
||||||
|
reconnectTimeout = null;
|
||||||
const savedToken = localStorage.getItem('media_server_token');
|
const savedToken = localStorage.getItem('media_server_token');
|
||||||
if (savedToken || !authRequired) {
|
if (savedToken || !authRequired) {
|
||||||
connectWebSocket(savedToken || '');
|
connectWebSocket(savedToken || '');
|
||||||
@@ -145,9 +200,10 @@ export function connectWebSocket(token) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
pingInterval = setInterval(() => {
|
clearPing();
|
||||||
if (newWs && newWs.readyState === WebSocket.OPEN) {
|
activePingInterval = setInterval(() => {
|
||||||
newWs.send(JSON.stringify({ type: 'ping' }));
|
if (newWs.readyState === WebSocket.OPEN) {
|
||||||
|
try { newWs.send(JSON.stringify({ type: 'ping' })); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
}, WS_PING_INTERVAL_MS);
|
}, WS_PING_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
@@ -182,3 +238,23 @@ export function manualReconnect() {
|
|||||||
connectWebSocket(savedToken || '');
|
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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -161,6 +161,31 @@
|
|||||||
"display.power_on": "Turn on",
|
"display.power_on": "Turn on",
|
||||||
"display.power_off": "Turn off",
|
"display.power_off": "Turn off",
|
||||||
"display.primary": "Primary",
|
"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.title": "Media Browser",
|
||||||
"browser.home": "Home",
|
"browser.home": "Home",
|
||||||
"browser.manage_folders": "Manage Folders",
|
"browser.manage_folders": "Manage Folders",
|
||||||
@@ -259,8 +284,37 @@
|
|||||||
"links.msg.load_failed": "Failed to load link details",
|
"links.msg.load_failed": "Failed to load link details",
|
||||||
"links.confirm.delete": "Are you sure you want to delete the link \"{name}\"?",
|
"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?",
|
"links.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?",
|
||||||
"footer.created_by": "Created by",
|
"about.button_title": "About",
|
||||||
"footer.source_code": "Source Code",
|
"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.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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,6 +161,31 @@
|
|||||||
"display.power_on": "Включить",
|
"display.power_on": "Включить",
|
||||||
"display.power_off": "Выключить",
|
"display.power_off": "Выключить",
|
||||||
"display.primary": "Основной",
|
"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.title": "Медиа Браузер",
|
||||||
"browser.home": "Главная",
|
"browser.home": "Главная",
|
||||||
"browser.manage_folders": "Управление папками",
|
"browser.manage_folders": "Управление папками",
|
||||||
@@ -259,8 +284,37 @@
|
|||||||
"links.msg.load_failed": "Не удалось загрузить данные ссылки",
|
"links.msg.load_failed": "Не удалось загрузить данные ссылки",
|
||||||
"links.confirm.delete": "Вы уверены, что хотите удалить ссылку \"{name}\"?",
|
"links.confirm.delete": "Вы уверены, что хотите удалить ссылку \"{name}\"?",
|
||||||
"links.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
|
"links.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
|
||||||
"footer.created_by": "Создано",
|
"about.button_title": "О программе",
|
||||||
"footer.source_code": "Исходный код",
|
"about.title": "О программе",
|
||||||
|
"about.created_by": "Создано",
|
||||||
|
"about.email": "Эл. почта",
|
||||||
|
"about.repository": "Репозиторий",
|
||||||
|
"about.source_code": "Исходный код",
|
||||||
|
"dialog.close": "Закрыть",
|
||||||
"update.available": "Доступно обновление: v{version}",
|
"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",
|
"name": "Media Server",
|
||||||
"short_name": "Media",
|
"short_name": "Media",
|
||||||
"description": "Remote media player control and file browser",
|
"description": "Remote media player control and file browser",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"orientation": "any",
|
"orientation": "any",
|
||||||
"background_color": "#121212",
|
"background_color": "#0E0D0B",
|
||||||
"theme_color": "#121212",
|
"theme_color": "#0E0D0B",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/static/icons/icon.svg",
|
"src": "/static/icons/icon.svg",
|
||||||
|
|||||||
@@ -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.
|
// 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.addEventListener('install', () => {
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
@@ -9,7 +11,3 @@ self.addEventListener('install', () => {
|
|||||||
self.addEventListener('activate', (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
event.waitUntil(self.clients.claim());
|
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>
|
||||||
+115
-34
@@ -3,6 +3,7 @@
|
|||||||
import ctypes
|
import ctypes
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
|
import sys
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
@@ -30,62 +31,136 @@ _IDYES = 6
|
|||||||
|
|
||||||
|
|
||||||
def _confirm(title: str, message: str) -> bool:
|
def _confirm(title: str, message: str) -> bool:
|
||||||
"""Show a Yes/No dialog using native Windows MessageBox."""
|
"""Show a Yes/No dialog before a destructive tray action.
|
||||||
|
|
||||||
|
Uses the native Windows MessageBox on win32; on Linux/macOS we don't
|
||||||
|
have a no-dependency GUI dialog available (Tk pulls in tkinter, gtk
|
||||||
|
pulls in PyGObject), so we log + auto-confirm — the tray menu items
|
||||||
|
themselves require a deliberate click already.
|
||||||
|
"""
|
||||||
|
if sys.platform == "win32":
|
||||||
result = ctypes.windll.user32.MessageBoxW(
|
result = ctypes.windll.user32.MessageBoxW(
|
||||||
0, message, title, _MB_YESNO | _MB_ICONQUESTION | _MB_TOPMOST | _MB_SETFOREGROUND
|
0,
|
||||||
|
message,
|
||||||
|
title,
|
||||||
|
_MB_YESNO | _MB_ICONQUESTION | _MB_TOPMOST | _MB_SETFOREGROUND,
|
||||||
)
|
)
|
||||||
return result == _IDYES
|
return result == _IDYES
|
||||||
|
|
||||||
|
logger.info("Tray confirm (auto-yes on non-Windows): %s — %s", title, message)
|
||||||
|
return True
|
||||||
|
|
||||||
def _create_icon_image(size: int = 64) -> Image.Image:
|
|
||||||
"""Create a tray icon: green circle with white play triangle."""
|
# Frame size we ask the multi-resolution ICO for. Most Windows tray surfaces
|
||||||
|
# show 16x16 in the notification area and 32x32 in jump lists / Alt+Tab; 64
|
||||||
|
# gives pystray enough headroom for both without forcing it to upscale.
|
||||||
|
_TRAY_ICON_SIZE = 64
|
||||||
|
|
||||||
|
# Palette mirrors media_server/static/icons/icon.svg ("Beacon" design).
|
||||||
|
_BG_DARK = (11, 61, 59, 255) # #0B3D3B
|
||||||
|
_BG_LIGHT = (26, 107, 94, 255) # #1A6B5E
|
||||||
|
_FG_PARCHMENT = (245, 241, 232, 255) # #F5F1E8
|
||||||
|
|
||||||
|
|
||||||
|
def _create_icon_image(size: int = _TRAY_ICON_SIZE) -> Image.Image:
|
||||||
|
"""Procedural fallback when no icon file is available.
|
||||||
|
|
||||||
|
Matches the "Beacon" palette (deep teal squircle + warm parchment play
|
||||||
|
triangle) so a missing icon.ico does not regress us back to the old
|
||||||
|
Spotify-green circle.
|
||||||
|
"""
|
||||||
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||||
draw = ImageDraw.Draw(img)
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
# Green circle background
|
# Squircle background. Vertical gradient approximates the diagonal one
|
||||||
padding = 2
|
# in the real SVG well enough for a 64px fallback.
|
||||||
draw.ellipse(
|
radius = int(size * 0.225)
|
||||||
[padding, padding, size - padding, size - padding],
|
for y in range(size):
|
||||||
fill=(29, 185, 84, 255),
|
t = y / max(1, size - 1)
|
||||||
|
color = tuple(
|
||||||
|
round(_BG_DARK[i] + (_BG_LIGHT[i] - _BG_DARK[i]) * t) for i in range(3)
|
||||||
|
) + (255,)
|
||||||
|
draw.line([(0, y), (size - 1, y)], fill=color)
|
||||||
|
mask = Image.new("L", (size, size), 0)
|
||||||
|
ImageDraw.Draw(mask).rounded_rectangle((0, 0, size - 1, size - 1), radius=radius, fill=255)
|
||||||
|
bg = img.copy()
|
||||||
|
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||||
|
img.paste(bg, (0, 0), mask=mask)
|
||||||
|
|
||||||
|
# Play triangle, positioned to match icon.svg's geometry.
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
draw.polygon(
|
||||||
|
[
|
||||||
|
(size * 0.345, size * 0.215),
|
||||||
|
(size * 0.345, size * 0.785),
|
||||||
|
(size * 0.755, size * 0.500),
|
||||||
|
],
|
||||||
|
fill=_FG_PARCHMENT,
|
||||||
)
|
)
|
||||||
|
|
||||||
# White play triangle
|
|
||||||
cx, cy = size // 2, size // 2
|
|
||||||
r = size * 0.28
|
|
||||||
triangle = [
|
|
||||||
(cx - r * 0.6, cy - r),
|
|
||||||
(cx - r * 0.6, cy + r),
|
|
||||||
(cx + r * 0.9, cy),
|
|
||||||
]
|
|
||||||
draw.polygon(triangle, fill=(255, 255, 255, 255))
|
|
||||||
|
|
||||||
return img
|
return img
|
||||||
|
|
||||||
|
|
||||||
def _load_icon_image() -> Image.Image:
|
def _select_frame(image: Image.Image, target: int) -> Image.Image:
|
||||||
"""Load the ICO/SVG app icon, falling back to a generated image."""
|
"""Pick the best frame from a multi-resolution ICO.
|
||||||
|
|
||||||
|
Pillow's ICO loader exposes the embedded sizes via ``image.info['sizes']``.
|
||||||
|
We pick the smallest frame at least as large as the target (so we never
|
||||||
|
upscale) and resize down to ``target x target`` with LANCZOS.
|
||||||
|
"""
|
||||||
|
sizes = sorted(image.info.get("sizes", []) or [], key=lambda wh: wh[0])
|
||||||
|
chosen = next((wh for wh in sizes if wh[0] >= target), sizes[-1] if sizes else None)
|
||||||
|
if chosen is not None:
|
||||||
|
image.size = chosen
|
||||||
|
frame = image.copy().convert("RGBA")
|
||||||
|
if frame.size != (target, target):
|
||||||
|
frame = frame.resize((target, target), Image.LANCZOS)
|
||||||
|
return frame
|
||||||
|
|
||||||
|
|
||||||
|
def _load_icon_image(size: int = _TRAY_ICON_SIZE) -> Image.Image:
|
||||||
|
"""Load the app icon for the tray.
|
||||||
|
|
||||||
|
Order:
|
||||||
|
1. ``icon.ico`` — the multi-resolution Windows icon ships with every
|
||||||
|
build; pick the frame closest to ``size`` and downscale if needed.
|
||||||
|
2. ``icon.svg`` via resvg-py (preferred) or cairosvg (legacy).
|
||||||
|
3. Procedural ``_create_icon_image`` fallback.
|
||||||
|
"""
|
||||||
icons_dir = Path(__file__).parent / "static" / "icons"
|
icons_dir = Path(__file__).parent / "static" / "icons"
|
||||||
|
|
||||||
# Try .ico first (best for Windows tray)
|
|
||||||
ico_path = icons_dir / "icon.ico"
|
ico_path = icons_dir / "icon.ico"
|
||||||
if ico_path.exists():
|
if ico_path.exists():
|
||||||
try:
|
try:
|
||||||
return Image.open(ico_path)
|
with Image.open(ico_path) as ico:
|
||||||
except Exception:
|
return _select_frame(ico, size)
|
||||||
pass
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to load tray icon from %s: %s", ico_path, exc)
|
||||||
# Try SVG via cairosvg
|
|
||||||
try:
|
|
||||||
import cairosvg
|
|
||||||
|
|
||||||
svg_path = icons_dir / "icon.svg"
|
svg_path = icons_dir / "icon.svg"
|
||||||
if svg_path.exists():
|
if svg_path.exists():
|
||||||
png_data = cairosvg.svg2png(url=str(svg_path), output_width=64, output_height=64)
|
try:
|
||||||
return Image.open(io.BytesIO(png_data))
|
import resvg_py
|
||||||
|
|
||||||
|
png_data = resvg_py.svg_to_bytes(
|
||||||
|
svg_string=svg_path.read_text(encoding="utf-8"),
|
||||||
|
width=size,
|
||||||
|
height=size,
|
||||||
|
)
|
||||||
|
return Image.open(io.BytesIO(bytes(png_data))).convert("RGBA")
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("resvg rasterization of %s failed: %s", svg_path, exc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import cairosvg
|
||||||
|
|
||||||
|
png_data = cairosvg.svg2png(url=str(svg_path), output_width=size, output_height=size)
|
||||||
|
return Image.open(io.BytesIO(png_data)).convert("RGBA")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return _create_icon_image()
|
return _create_icon_image(size)
|
||||||
|
|
||||||
|
|
||||||
class TrayManager:
|
class TrayManager:
|
||||||
@@ -101,6 +176,9 @@ class TrayManager:
|
|||||||
|
|
||||||
self._port = port
|
self._port = port
|
||||||
self._on_exit = on_exit
|
self._on_exit = on_exit
|
||||||
|
# Initialize so the property and any cross-thread reader cannot ever
|
||||||
|
# observe an uninitialized attribute. Set before _on_exit() fires.
|
||||||
|
self._restart_requested = False
|
||||||
|
|
||||||
menu = pystray.Menu(
|
menu = pystray.Menu(
|
||||||
pystray.MenuItem("Show UI", self._show_ui, default=True),
|
pystray.MenuItem("Show UI", self._show_ui, default=True),
|
||||||
@@ -123,13 +201,16 @@ class TrayManager:
|
|||||||
if not _confirm("Media Server", "Restart the server?"):
|
if not _confirm("Media Server", "Restart the server?"):
|
||||||
return
|
return
|
||||||
logger.info("Restart requested from tray")
|
logger.info("Restart requested from tray")
|
||||||
|
# Set the flag BEFORE signalling exit so the main thread observes it
|
||||||
|
# when it wakes from server_thread.join() — order matters across the
|
||||||
|
# tray/uvicorn handoff.
|
||||||
self._restart_requested = True
|
self._restart_requested = True
|
||||||
self._on_exit()
|
self._on_exit()
|
||||||
self._icon.stop()
|
self._icon.stop()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def restart_requested(self) -> bool:
|
def restart_requested(self) -> bool:
|
||||||
return getattr(self, "_restart_requested", False)
|
return self._restart_requested
|
||||||
|
|
||||||
def _shutdown(self, icon: "pystray.Icon", item: "pystray.MenuItem") -> None:
|
def _shutdown(self, icon: "pystray.Icon", item: "pystray.MenuItem") -> None:
|
||||||
if not _confirm("Media Server", "Shut down the server?"):
|
if not _confirm("Media Server", "Shut down the server?"):
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "media-server-frontend",
|
"name": "media-server-frontend",
|
||||||
"version": "0.2.0",
|
"version": "0.4.0",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "media-server-frontend",
|
"name": "media-server-frontend",
|
||||||
"version": "0.2.0",
|
"version": "0.4.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"esbuild": "^0.27.4"
|
"esbuild": "^0.27.4"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "media-server-frontend",
|
"name": "media-server-frontend",
|
||||||
"version": "0.2.0",
|
"version": "0.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Frontend build tooling for media server WebUI",
|
"description": "Frontend build tooling for media server WebUI",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+27
-7
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "media-server"
|
name = "media-server"
|
||||||
version = "0.2.1"
|
version = "0.4.0"
|
||||||
description = "REST API server for controlling system-wide media playback"
|
description = "REST API server for controlling system-wide media playback"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
@@ -34,24 +34,44 @@ dependencies = [
|
|||||||
"pillow>=10.0.0",
|
"pillow>=10.0.0",
|
||||||
"soundcard>=0.4.0",
|
"soundcard>=0.4.0",
|
||||||
"numpy>=1.24.0,<2.0",
|
"numpy>=1.24.0,<2.0",
|
||||||
|
# screen-brightness-control + monitorcontrol are cross-platform
|
||||||
|
# (Windows / Linux i2c-dev). Kept in base deps so a plain
|
||||||
|
# `pip install media-server` yields a working /api/display on any
|
||||||
|
# platform that has the underlying hardware support.
|
||||||
|
"screen-brightness-control>=0.20.0",
|
||||||
|
"monitorcontrol>=3.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
windows = [
|
windows = [
|
||||||
"winsdk>=1.0.0b10",
|
"winsdk>=1.0.0b10",
|
||||||
"pywin32>=306",
|
"pywin32>=306; sys_platform == 'win32'",
|
||||||
"comtypes>=1.2.0",
|
"comtypes>=1.2.0; sys_platform == 'win32'",
|
||||||
"pycaw>=20230407",
|
"pycaw>=20230407; sys_platform == 'win32'",
|
||||||
"screen-brightness-control>=0.20.0",
|
"wmi>=1.5.1; sys_platform == 'win32'",
|
||||||
"wmi>=1.5.1",
|
|
||||||
"monitorcontrol>=3.0.0",
|
|
||||||
"pystray>=0.19.0",
|
"pystray>=0.19.0",
|
||||||
]
|
]
|
||||||
|
linux = [
|
||||||
|
# MPRIS / D-Bus media control. Requires libdbus-1-dev, libglib2.0-dev,
|
||||||
|
# and pkg-config installed on the host before pip install.
|
||||||
|
"dbus-python>=1.3.2; sys_platform == 'linux'",
|
||||||
|
"PyGObject>=3.46; sys_platform == 'linux'",
|
||||||
|
# X11 foreground-window probe (Wayland sessions degrade gracefully).
|
||||||
|
"python-xlib>=0.33; sys_platform == 'linux'",
|
||||||
|
]
|
||||||
|
macos = [
|
||||||
|
# PyObjC for the foreground-window + active-app probes.
|
||||||
|
"pyobjc-framework-Cocoa>=10.0; sys_platform == 'darwin'",
|
||||||
|
"pyobjc-framework-Quartz>=10.0; sys_platform == 'darwin'",
|
||||||
|
]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=7.0",
|
"pytest>=7.0",
|
||||||
"pytest-asyncio>=0.21",
|
"pytest-asyncio>=0.21",
|
||||||
"httpx>=0.24",
|
"httpx>=0.24",
|
||||||
"ruff>=0.4.0",
|
"ruff>=0.4.0",
|
||||||
|
# SVG -> PNG rasterizer used by scripts/generate-icon.py to (re)build
|
||||||
|
# media_server/static/icons/icon.ico from icon.svg. Build-time only.
|
||||||
|
"resvg-py>=0.3.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Generate the Media Server application icon.
|
||||||
|
|
||||||
|
The SVG in ``media_server/static/icons/icon.svg`` is the single source of
|
||||||
|
truth. This script rasterizes it at every Windows ICO size via ``resvg-py``
|
||||||
|
(Rust-backed, identical math to Firefox's SVG renderer) and packs them all
|
||||||
|
into a multi-resolution ``icon.ico``.
|
||||||
|
|
||||||
|
This replaces the original 16x16-only ICO that Windows was upscaling into
|
||||||
|
mush for the installer chrome, Start Menu, desktop shortcuts, and Alt+Tab.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/generate-icon.py
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
pip install resvg-py Pillow
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import resvg_py
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
# Sizes packed into the ICO. Windows picks the closest match per surface;
|
||||||
|
# more sizes = sharper rendering everywhere (taskbar, installer header,
|
||||||
|
# Alt+Tab, jump lists, Start tile, desktop, file explorer details/tiles).
|
||||||
|
ICO_SIZES: tuple[int, ...] = (16, 20, 24, 32, 40, 48, 64, 96, 128, 256)
|
||||||
|
|
||||||
|
# The SVG source. Squircle + diagonal teal gradient + warm parchment play
|
||||||
|
# triangle with a drop shadow + ghosted echo-chevrons hinting at broadcast.
|
||||||
|
SVG_SOURCE = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Media Server">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#0B3D3B"/>
|
||||||
|
<stop offset="100%" stop-color="#1A6B5E"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="sheen" x1="50%" y1="0%" x2="50%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.18"/>
|
||||||
|
<stop offset="55%" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="triShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feGaussianBlur stdDeviation="3"/>
|
||||||
|
<feOffset dx="3" dy="5"/>
|
||||||
|
<feComponentTransfer><feFuncA type="linear" slope="0.45"/></feComponentTransfer>
|
||||||
|
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
<clipPath id="clip"><rect x="0" y="0" width="256" height="256" rx="58" ry="58"/></clipPath>
|
||||||
|
</defs>
|
||||||
|
<g clip-path="url(#clip)">
|
||||||
|
<rect width="256" height="256" fill="url(#bg)"/>
|
||||||
|
<rect width="256" height="256" fill="url(#sheen)"/>
|
||||||
|
<g stroke="#F5F1E8" stroke-width="4.6" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.18">
|
||||||
|
<polyline points="66,72 105,128 66,184"/>
|
||||||
|
<polyline points="85,82 124,128 85,174" opacity="0.6"/>
|
||||||
|
</g>
|
||||||
|
<path d="M88.3 55 L88.3 201 L193.3 128 Z"
|
||||||
|
fill="#F5F1E8"
|
||||||
|
stroke="#F5F1E8" stroke-width="9" stroke-linejoin="round"
|
||||||
|
filter="url(#triShadow)"/>
|
||||||
|
</g>
|
||||||
|
<rect x="0.5" y="0.5" width="255" height="255" rx="58" ry="58"
|
||||||
|
fill="none" stroke="#000000" stroke-opacity="0.18" stroke-width="1"/>
|
||||||
|
</svg>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _render_png(size: int) -> Image.Image:
|
||||||
|
"""Rasterize the SVG to a PNG at ``size x size`` via resvg."""
|
||||||
|
data = resvg_py.svg_to_bytes(
|
||||||
|
svg_string=SVG_SOURCE,
|
||||||
|
width=size,
|
||||||
|
height=size,
|
||||||
|
shape_rendering="geometric_precision",
|
||||||
|
)
|
||||||
|
return Image.open(io.BytesIO(bytes(data))).convert("RGBA")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
root = Path(__file__).resolve().parent.parent
|
||||||
|
out_dir = root / "media_server" / "static" / "icons"
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# SVG — canonical source, also used as the Web UI favicon.
|
||||||
|
svg_path = out_dir / "icon.svg"
|
||||||
|
svg_path.write_text(SVG_SOURCE, encoding="utf-8")
|
||||||
|
print(f"wrote {svg_path}")
|
||||||
|
|
||||||
|
# Rasterize every ICO size via resvg.
|
||||||
|
frames = [_render_png(size) for size in ICO_SIZES]
|
||||||
|
|
||||||
|
# Pack into a multi-resolution ICO. The "primary" image must be the
|
||||||
|
# largest; the rest go via append_images. Pillow's ICO writer then
|
||||||
|
# serializes one frame per size.
|
||||||
|
primary = frames[-1]
|
||||||
|
ico_path = out_dir / "icon.ico"
|
||||||
|
primary.save(
|
||||||
|
ico_path,
|
||||||
|
format="ICO",
|
||||||
|
sizes=[(s, s) for s in ICO_SIZES],
|
||||||
|
append_images=frames[:-1],
|
||||||
|
)
|
||||||
|
print(f"wrote {ico_path} ({ico_path.stat().st_size:,} bytes, sizes={list(ICO_SIZES)})")
|
||||||
|
|
||||||
|
# Largest PNG for documentation / non-Windows surfaces.
|
||||||
|
png_path = out_dir / "icon-256.png"
|
||||||
|
primary.save(png_path, format="PNG", optimize=True)
|
||||||
|
print(f"wrote {png_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+83
-15
@@ -1,13 +1,80 @@
|
|||||||
# Restart the Media Server
|
# Restart the Media Server.
|
||||||
# Stop any running instance
|
#
|
||||||
$procs = Get-Process -Name 'media-server' -ErrorAction SilentlyContinue
|
# Robust against the two ways the server gets started:
|
||||||
foreach ($p in $procs) {
|
# - Installer build: %LOCALAPPDATA%\Media Server\media-server.bat
|
||||||
Write-Host "Stopping server (PID $($p.Id))..."
|
# (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.
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 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
|
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
|
||||||
}
|
}
|
||||||
if ($procs) { Start-Sleep -Seconds 2 }
|
|
||||||
|
|
||||||
# Merge registry PATH with current PATH so newly-installed tools are visible
|
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')
|
$regUser = [Environment]::GetEnvironmentVariable('PATH', 'User')
|
||||||
if ($regUser) {
|
if ($regUser) {
|
||||||
$currentDirs = $env:PATH -split ';' | ForEach-Object { $_.TrimEnd('\') }
|
$currentDirs = $env:PATH -split ';' | ForEach-Object { $_.TrimEnd('\') }
|
||||||
@@ -17,19 +84,20 @@ if ($regUser) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Write-Host "Starting dev install (PATH media-server)..."
|
||||||
# Start server detached
|
|
||||||
Write-Host "Starting server..."
|
|
||||||
Start-Process -FilePath 'media-server' `
|
Start-Process -FilePath 'media-server' `
|
||||||
-WorkingDirectory 'c:\Users\Alexei\Documents\haos-integration-media-player\media-server' `
|
-WorkingDirectory 'c:\Users\Alexei\Documents\haos-integration-media-player\media-server' `
|
||||||
-WindowStyle Hidden
|
-WindowStyle Hidden
|
||||||
|
}
|
||||||
|
|
||||||
Start-Sleep -Seconds 3
|
Start-Sleep -Seconds 3
|
||||||
|
|
||||||
# Verify it's running
|
# --- Verify it's listening ---------------------------------------------------
|
||||||
$check = Get-Process -Name 'media-server' -ErrorAction SilentlyContinue
|
$verify = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue
|
||||||
if ($check) {
|
if ($verify) {
|
||||||
Write-Host "Server started (PID $($check[0].Id))"
|
$vpid = $verify[0].OwningProcess
|
||||||
|
$vproc = Get-Process -Id $vpid -ErrorAction SilentlyContinue
|
||||||
|
Write-Host "Server listening on port $Port (PID $vpid, $($vproc.ProcessName))"
|
||||||
} else {
|
} 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,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