Compare commits

..

17 Commits

Author SHA1 Message Date
alexei.dolgolyov 81d5b0a402 ci: sparse-checkout RELEASE_NOTES.md in create-release job
Release / create-release (push) Successful in 3s
Lint & Test / test (push) Successful in 10s
Release / build-linux (push) Successful in 30s
Release / build-windows (push) Successful in 1m8s
2026-03-25 23:20:00 +03:00
alexei.dolgolyov d67e61ae39 ci: embed RELEASE_NOTES.md in Gitea release body
Lint & Test / test (push) Successful in 11s
Release / create-release (push) Successful in 2s
Release / build-linux (push) Successful in 36s
Release / build-windows (push) Successful in 1m21s
2026-03-25 23:17:01 +03:00
alexei.dolgolyov e795d224a8 chore: update release notes and version for v0.1.0
Lint & Test / test (push) Successful in 19s
Release / create-release (push) Successful in 8s
Release / build-linux (push) Successful in 36s
Release / build-windows (push) Has been cancelled
2026-03-25 23:10:42 +03:00
alexei.dolgolyov d0830cbbe5 ci: use warning annotation for existing release fallback
Lint & Test / test (push) Successful in 10s
2026-03-25 23:02:46 +03:00
alexei.dolgolyov 4ef11c8f00 chore: CI/build improvements and version detection
Lint & Test / test (push) Successful in 10s
- Rename GITEA_TOKEN to DEPLOY_TOKEN in release workflow
- Extract shared version detection into build-common.sh
- Use importlib.metadata for runtime version instead of hardcoded string
- Use PEP 440 parsing (packaging lib) for update version comparison
- Add packaging>=23.0 to dependencies
- Fix update banner close button alignment (CSS)
- Update CLAUDE.md with versioning docs and frontend rebuild notes
2026-03-25 15:43:27 +03:00
alexei.dolgolyov fb56e6cdc0 feat: persist audio capture device selection to config.yaml
Release / create-release (push) Successful in 1s
Lint & Test / test (push) Successful in 9s
Release / build-linux (push) Successful in 26s
Release / build-windows (push) Successful in 1m3s
Device choice now survives server restarts. Falls back to default
if the saved device is no longer available.
2026-03-25 11:50:01 +03:00
alexei.dolgolyov ff6712620e chore: bump version to 1.0.1
Release / create-release (push) Successful in 2s
Lint & Test / test (push) Successful in 38s
Release / build-linux (push) Successful in 2m10s
Release / build-windows (push) Successful in 2m48s
2026-03-25 11:37:50 +03:00
alexei.dolgolyov 795a15cb8b feat: add update-available notification system
Lint & Test / test (push) Successful in 10s
- Abstract ReleaseProvider protocol for platform-agnostic version checking
- GiteaReleaseProvider implementation using stdlib urllib
- UpdateChecker service with periodic background checks and WS broadcast
- Persistent dismissible banner in Web UI when a new version is detected
- Health endpoint now returns cached update info
- Configurable via update_check_enabled and update_check_interval settings
- i18n support (EN/RU)
2026-03-25 11:37:09 +03:00
alexei.dolgolyov 1410a8d2cb feat: typed script parameters with validation and icon-grid selector
Release / create-release (push) Successful in 1s
Lint & Test / test (push) Successful in 10s
Release / build-linux (push) Successful in 36s
Release / build-windows (push) Successful in 1m15s
- Add ScriptParameterConfig model (string, integer, float, boolean, select types)
- Server-side validation at both define-time and execute-time
- Parameters passed as SCRIPT_PARAM_* environment variables
- Web UI parameter editor in script create/edit dialog (add/remove/reorder)
- Icon-grid selector component (ported from wled-screen-controller)
- Replace audio device dropdown with icon-grid selector
- Replace callback event dropdown with icon-grid selector
- Localization for parameter UI (en, ru)
2026-03-25 11:25:03 +03:00
alexei.dolgolyov 1c0a011342 feat: tint slider tracks with 15% accent color
Lint & Test / test (push) Successful in 9s
2026-03-24 15:59:55 +03:00
alexei.dolgolyov 2b1e09ded9 feat: add Swagger API docs button to header toolbar
Lint & Test / test (push) Successful in 9s
2026-03-24 15:58:01 +03:00
alexei.dolgolyov 415231f2f2 fix: tray restart uses python -m for reliable process respawn
Release / create-release (push) Successful in 1s
Lint & Test / test (push) Successful in 15s
Release / build-linux (push) Successful in 32s
Release / build-windows (push) Successful in 1m8s
The previous os.execv approach and console_script detection both
failed on Windows. Now restart always spawns `python -m media_server.main`
via subprocess.Popen with start_new_session, which works regardless
of how the server was originally started.
2026-03-24 15:26:14 +03:00
alexei.dolgolyov 32e2ff532d fix: add --only-binary to pip download fallback (CI compatibility)
Lint & Test / test (push) Successful in 9s
Release / create-release (push) Successful in 1s
Release / build-linux (push) Successful in 37s
Release / build-windows (push) Successful in 1m15s
2026-03-24 15:07:33 +03:00
alexei.dolgolyov 309f547a5e feat: add default MDI icons to example config scripts
Lint & Test / test (push) Successful in 9s
2026-03-24 15:07:09 +03:00
alexei.dolgolyov 402183765c fix: tray main-thread message loop, numpy <2.0 pin, installer config copy
Lint & Test / test (push) Successful in 9s
Release / create-release (push) Successful in 1s
Release / build-windows (push) Failing after 30s
Release / build-linux (push) Successful in 35s
- Rewrite tray to run on main thread (pystray owns message loop, uvicorn
  in background thread) — fixes unresponsive confirmation dialogs
- Use native Windows MessageBoxW instead of tkinter (embedded Python
  has no tkinter)
- Pin numpy <2.0 to fix soundcard's numpy.fromstring (removed in 2.0)
- Strip transitive numpy 2.x wheels in build script
- Installer copies config.example.yaml as config.yaml on fresh install
- Suppress noisy screen_brightness_control warnings
2026-03-24 15:05:36 +03:00
alexei.dolgolyov d7e10b1005 fix: interpolate tag in release body template (f-string)
Lint & Test / test (push) Successful in 10s
Release / create-release (push) Successful in 1s
Release / build-linux (push) Successful in 33s
Release / build-windows (push) Successful in 1m8s
2026-03-24 14:26:14 +03:00
alexei.dolgolyov 3f14512e5d feat: add Restart and Shutdown tray actions with confirmation dialogs
Lint & Test / test (push) Successful in 24s
Release / create-release (push) Successful in 1s
Release / build-linux (push) Successful in 31s
Release / build-windows (push) Successful in 1m13s
2026-03-24 14:19:15 +03:00
32 changed files with 2170 additions and 202 deletions
+90 -19
View File
@@ -13,10 +13,16 @@ jobs:
release_id: ${{ steps.create.outputs.release_id }}
version: ${{ steps.create.outputs.version }}
steps:
- name: Fetch RELEASE_NOTES.md only
uses: actions/checkout@v4
with:
sparse-checkout: RELEASE_NOTES.md
sparse-checkout-cone-mode: false
- name: Create Gitea release
id: create
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
TAG="${{ gitea.ref_name }}"
VERSION="${TAG#v}"
@@ -27,21 +33,40 @@ jobs:
IS_PRE="true"
fi
if [ -f RELEASE_NOTES.md ]; then
export RELEASE_NOTES=$(cat RELEASE_NOTES.md)
echo "Found RELEASE_NOTES.md"
else
export RELEASE_NOTES=""
echo "No RELEASE_NOTES.md found"
fi
BODY_JSON=$(python3 -c "
import json, textwrap
import json, os, textwrap
tag = '$TAG'
body = '''## Downloads
release_notes = os.environ.get('RELEASE_NOTES', '')
sections = []
if release_notes.strip():
sections.append(release_notes.strip())
sections.append(textwrap.dedent(f'''
## Downloads
| Platform | File |
|----------|------|
| Windows (installer) | \`MediaServer-{tag}-setup.exe\` |
| Windows (portable) | \`MediaServer-{tag}-win-x64.zip\` |
| Linux | \`MediaServer-{tag}-linux-x64.tar.gz\` |
'''
print(json.dumps(textwrap.dedent(body).strip()))
''').strip())
print(json.dumps('\n\n'.join(sections)))
")
RELEASE=$(curl -s -X POST "$BASE_URL/releases" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Authorization: token $DEPLOY_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"tag_name\": \"$TAG\",
@@ -62,9 +87,9 @@ jobs:
print(json.dumps(data, indent=2), file=sys.stderr)
sys.exit(1)
" 2>&1) || {
echo "Create failed, fetching existing release for tag $TAG..."
echo "::warning::Release already exists for tag $TAG — reusing existing release"
RELEASE=$(curl -s "$BASE_URL/releases/tags/$TAG" \
-H "Authorization: token $GITEA_TOKEN")
-H "Authorization: token $DEPLOY_TOKEN")
RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
}
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
@@ -103,19 +128,45 @@ jobs:
- name: Upload assets to release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
upload_asset() {
local file="$1"
local name
name=$(basename "$file")
# Delete existing asset with the same name (idempotent re-runs)
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
echo "Replacing existing asset: $name (id=$ASSET_ID)"
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$ASSET_ID" \
-H "Authorization: token $DEPLOY_TOKEN"
fi
echo "Uploading $name..."
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"
}
for FILE in build/MediaServer-*.zip build/MediaServer-*-setup.exe; do
[ -f "$FILE" ] || continue
echo "Uploading $(basename "$FILE")..."
curl -s -X POST \
"$BASE_URL/releases/$RELEASE_ID/assets?name=$(basename "$FILE")" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$FILE"
upload_asset "$FILE"
done
# --- Build Linux tarball ---
@@ -143,15 +194,35 @@ jobs:
- name: Upload assets to release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
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-*-linux-x64.tar.gz | head -1)
echo "Uploading $(basename "$FILE")..."
NAME=$(basename "$FILE")
# Delete existing asset with the same name (idempotent re-runs)
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
echo "Replacing existing asset: $NAME (id=$ASSET_ID)"
curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$ASSET_ID" \
-H "Authorization: token $DEPLOY_TOKEN"
fi
echo "Uploading $NAME..."
curl -s -X POST \
"$BASE_URL/releases/$RELEASE_ID/assets?name=$(basename "$FILE")" \
-H "Authorization: token $GITEA_TOKEN" \
"$BASE_URL/releases/$RELEASE_ID/assets?name=$NAME" \
-H "Authorization: token $DEPLOY_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$FILE"
+20 -5
View File
@@ -41,10 +41,20 @@ Unregister-ScheduledTask -TaskName "MediaServer" -Confirm:$false
**When restart is NOT needed:**
- Static file changes (`*.html`, `*.css`, `*.js`, `*.json`) - browser refresh is enough
- Static file changes (`*.html`, `*.css`, `*.json`) - browser refresh is enough
- README or documentation updates
- Changes to install/service scripts (only affects new installations)
### Frontend Rebuild After JS Changes
**CRITICAL:** The frontend is bundled via esbuild into `static/dist/app.bundle.js`. After modifying ANY JavaScript file in `media_server/static/js/`, you **MUST** run:
```bash
npm run build
```
Raw JS file edits have **NO effect** until the bundle is rebuilt. After rebuilding, a browser hard-refresh (Ctrl+Shift+R) is sufficient — no server restart needed.
**How to restart during development:**
1. Find the running server process:
@@ -124,12 +134,17 @@ To add support for a new language:
## Versioning
Version is tracked in two files that must be kept in sync:
**`pyproject.toml`** is the single source of truth for the version string.
- `pyproject.toml` - `[project].version`
- `media_server/__init__.py` - `__version__`
At runtime, `media_server/__init__.py` reads the version via `importlib.metadata.version()` — no manual syncing needed.
When releasing a new version, update both files with the same version string.
Version flow:
1. `git tag v0.3.0` → CI reads the tag
2. Build scripts stamp `pyproject.toml` with the clean version via `sed`
3. `pip install` bakes the version into package metadata
4. `importlib.metadata.version("media-server")` reads it at runtime
When bumping the version for a new release, only `pyproject.toml` needs to be updated.
**Important:** After making any changes, always ask the user if the version needs to be incremented.
+148
View File
@@ -0,0 +1,148 @@
## v0.1.0 (2026-03-25)
Initial public release of Media Server — a standalone REST API server (FastAPI) for controlling system-wide media playback on Windows, Linux, macOS, and Android.
### Features
- Remote media control: play, pause, stop, next, previous, volume, seek ([83acf5f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/83acf5f))
- Built-in Web UI for media control and monitoring ([a0d138b](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/a0d138b))
- Media browser with grid/compact/list views and single-click playback ([e16674c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/e16674c))
- Low-latency volume control via WebSocket ([32b058c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/32b058c))
- Audio visualizer with spectrogram, beat-reactive art, and device selection ([0691e3d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/0691e3d))
- Dynamic WebGL background with audio reactivity ([be48318](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/be48318))
- Runtime script management with Home Assistant integration ([d7c5994](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d7c5994))
- Typed script parameters with validation and icon-grid selector ([1410a8d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/1410a8d))
- Callback management API/UI and theme support ([a0af855](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/a0af855))
- Multi-token authentication with client labels ([71a0a6e](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/71a0a6e))
- Optional authentication — no tokens = no auth ([4d1bb78](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4d1bb78))
- Internationalization (i18n) with English and Russian locales ([9bbb8e1](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/9bbb8e1))
- PWA support: installable standalone app with safe area handling ([a20812e](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/a20812e))
- System tray icon with Show UI, Restart, and Shutdown actions ([6500d6f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/6500d6f), [3f14512](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/3f14512))
- Display brightness and power control ([a568608](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/a568608))
- Custom accent color picker and primary display indicator ([397d38a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/397d38a))
- 3D album art rotation and vinyl desaturation effect ([4112367](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4112367))
- Friendly media source names with brand icons ([73a6f38](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/73a6f38))
- Browser search/filter for media items ([5f474d6](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/5f474d6))
- Header quick links with CRUD management ([99dbbb1](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/99dbbb1))
- Swagger API docs button in header toolbar ([2b1e09d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/2b1e09d))
- Update-available notification system ([795a15c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/795a15c))
- Persist audio capture device selection to config ([fb56e6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/fb56e6c))
- UI animations: dialogs, tabs, settings, browser stagger, banner pulse ([3cfc437](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/3cfc437))
- Tabbed UI with browse caching and bottom mini player ([98a33bc](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/98a33bc))
- Slider tracks tinted with accent color ([1c0a011](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/1c0a011))
### Bug Fixes
- Tray restart uses `python -m` for reliable process respawn ([415231f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/415231f))
- Tray main-thread message loop, numpy <2.0 pin, installer config copy ([4021837](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4021837))
- Loopback device status showing 'Unavailable' after change ([0eca829](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/0eca829))
- Error handling for unavailable network shares ([d1ec27c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d1ec27c))
- HTTPException handling in folder endpoints ([c5f8c7a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/c5f8c7a))
- FOUC (Flash of Untranslated Content) issues ([4f8f59d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4f8f59d))
- Windows Task Scheduler auto-start ([8077181](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/8077181))
- Vinyl angle persistence on toggle ([00d313d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/00d313d))
---
### Development / Internal
#### CI/Build
- CI/CD pipelines with Gitea Actions, NSIS installer, ES module bundling, ruff linting ([5439af1](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/5439af1))
- Linux build in release workflow ([ddd8788](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddd8788))
- NSIS installer with custom icon, launch-after-install, running-instance detection ([26b5f74](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/26b5f74))
- CI/build improvements and version detection ([4ef11c8](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4ef11c8))
- Warning annotation for existing release fallback ([d0830cb](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d0830cb))
#### Refactoring
- Modular frontend: refactor monolithic app.js into 8 modules ([92d6709](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/92d6709))
- Codebase audit: stability, performance, accessibility fixes ([9404b37](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/9404b37))
---
<details>
<summary>All Commits (82)</summary>
| Hash | Message | Author |
|------|---------|--------|
| [d0830cb](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d0830cb) | ci: use warning annotation for existing release fallback | alexei.dolgolyov |
| [4ef11c8](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4ef11c8) | chore: CI/build improvements and version detection | alexei.dolgolyov |
| [fb56e6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/fb56e6c) | feat: persist audio capture device selection to config.yaml | alexei.dolgolyov |
| [ff67126](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ff67126) | chore: bump version to 1.0.1 | alexei.dolgolyov |
| [795a15c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/795a15c) | feat: add update-available notification system | alexei.dolgolyov |
| [1410a8d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/1410a8d) | feat: typed script parameters with validation and icon-grid selector | alexei.dolgolyov |
| [1c0a011](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/1c0a011) | feat: tint slider tracks with 15% accent color | alexei.dolgolyov |
| [2b1e09d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/2b1e09d) | feat: add Swagger API docs button to header toolbar | alexei.dolgolyov |
| [415231f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/415231f) | fix: tray restart uses python -m for reliable process respawn | alexei.dolgolyov |
| [32e2ff5](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/32e2ff5) | fix: add --only-binary to pip download fallback (CI compatibility) | alexei.dolgolyov |
| [309f547](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/309f547) | feat: add default MDI icons to example config scripts | alexei.dolgolyov |
| [4021837](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4021837) | fix: tray main-thread message loop, numpy <2.0 pin, installer config copy | alexei.dolgolyov |
| [d7e10b1](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d7e10b1) | fix: interpolate tag in release body template (f-string) | alexei.dolgolyov |
| [3f14512](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/3f14512) | feat: add Restart and Shutdown tray actions with confirmation dialogs | alexei.dolgolyov |
| [26b5f74](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/26b5f74) | feat: improve installer with custom icon, launch-after-install, and running-instance detection | alexei.dolgolyov |
| [1f6e4f6](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/1f6e4f6) | feat: add Launch option to installer finish page | alexei.dolgolyov |
| [6500d6f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/6500d6f) | feat: add system tray icon with Show UI and Exit actions | alexei.dolgolyov |
| [4d1bb78](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4d1bb78) | feat: make authentication optional — no tokens = no auth | alexei.dolgolyov |
| [f80f6e9](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/f80f6e9) | fix: correct ._pth path in Windows build script | alexei.dolgolyov |
| [0216851](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/0216851) | docs: comprehensive README update with all API endpoints and features | alexei.dolgolyov |
| [c76ffb9](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/c76ffb9) | fix: handle existing release in create-release job | alexei.dolgolyov |
| [ddd8788](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddd8788) | Add Linux build to release workflow, fix pytest exit code 5 | alexei.dolgolyov |
| [5439af1](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/5439af1) | Add CI/CD pipelines, NSIS installer, ES module bundling, and ruff linting | alexei.dolgolyov |
| [be48318](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/be48318) | Add dynamic WebGL background with audio reactivity | alexei.dolgolyov |
| [0eca829](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/0eca829) | Fix loopback device status showing 'Unavailable' after change | alexei.dolgolyov |
| [3cfc437](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/3cfc437) | Add UI animations: dialogs, tabs, settings, browser stagger, banner pulse | alexei.dolgolyov |
| [a20812e](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/a20812e) | Add PWA support: installable standalone app with safe area handling | alexei.dolgolyov |
| [652f10f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/652f10f) | Reduce visualizer latency, tighten UI paddings, fix mobile browser toolbar | alexei.dolgolyov |
| [3846610](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/3846610) | On-demand audio visualizer capture + UI fixes | alexei.dolgolyov |
| [92d6709](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/92d6709) | Refactor monolithic app.js into 8 modular files | alexei.dolgolyov |
| [9404b37](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/9404b37) | Codebase audit fixes: stability, performance, accessibility | alexei.dolgolyov |
| [73a6f38](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/73a6f38) | Add friendly media source names with brand icons | alexei.dolgolyov |
| [b11edc2](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/b11edc2) | Redesign header as pill-shaped toolbar group | alexei.dolgolyov |
| [3d01d98](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/3d01d98) | Style audio device select, hide mini player volume on tablet | alexei.dolgolyov |
| [4112367](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4112367) | Add 3D album art rotation and vinyl desaturation effect | alexei.dolgolyov |
| [00d313d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/00d313d) | Fix vinyl angle persistence on toggle, group player toggle buttons | alexei.dolgolyov |
| [0691e3d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/0691e3d) | Add audio visualizer with spectrogram, beat-reactive art, and device selection | alexei.dolgolyov |
| [8a8f00f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/8a8f00f) | Persist vinyl rotation angle across page reloads | alexei.dolgolyov |
| [397d38a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/397d38a) | Add primary display indicator, custom accent color picker, restart script | alexei.dolgolyov |
| [adf2d93](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/adf2d93) | Consolidate tabs, Quick Access links, mini player nav, link descriptions | alexei.dolgolyov |
| [99dbbb1](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/99dbbb1) | Add header quick links with CRUD management and icon enhancements | alexei.dolgolyov |
| [6f6a4e4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/6f6a4e4) | Improve slider track visibility in both themes | alexei.dolgolyov |
| [a568608](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/a568608) | Add display brightness and power control | alexei.dolgolyov |
| [03a1b30](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/03a1b30) | Comprehensive WebUI improvements: security, UX, accessibility, performance | alexei.dolgolyov |
| [ef1935c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ef1935c) | Update README with current features | alexei.dolgolyov |
| [7ee0a60](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/7ee0a60) | Update media browser screenshot | alexei.dolgolyov |
| [7f28145](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/7f28145) | Update documentation screenshots | alexei.dolgolyov |
| [80d4dbc](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/80d4dbc) | Fix browser grid card sizing | alexei.dolgolyov |
| [caf24db](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/caf24db) | Compact browser grid cards | alexei.dolgolyov |
| [babdb61](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/babdb61) | Update media-server: Vinyl record mode and accent color picker | alexei.dolgolyov |
| [65b513c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/65b513c) | Update media-server: UI polish and bug fixes | alexei.dolgolyov |
| [84b985e](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/84b985e) | Backend optimizations, frontend optimizations, and UI design improvements | alexei.dolgolyov |
| [d1ec27c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d1ec27c) | Improve error handling for unavailable network shares | alexei.dolgolyov |
| [13df69a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/13df69a) | Show media title (Artist Title) instead of filename when available | alexei.dolgolyov |
| [4c13322](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4c13322) | Show bitrate in browser, remove type labels and Play All text | alexei.dolgolyov |
| [5f474d6](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/5f474d6) | Add browser search/filter for media items | alexei.dolgolyov |
| [98a33bc](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/98a33bc) | Tabbed UI, browse caching, and bottom mini player | alexei.dolgolyov |
| [8db40d3](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/8db40d3) | UI polish: refresh button, negative thumbnail cache, and style fixes | alexei.dolgolyov |
| [f275240](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/f275240) | Add Play All, home navigation, and UI improvements | alexei.dolgolyov |
| [e16674c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/e16674c) | Add media browser with grid/compact/list views and single-click playback | alexei.dolgolyov |
| [32b058c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/32b058c) | Add low-latency volume control via WebSocket | alexei.dolgolyov |
| [c5f8c7a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/c5f8c7a) | Fix HTTPException handling in folder endpoints and install script path | alexei.dolgolyov |
| [8d15a2a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/8d15a2a) | Update Web UI: Header redesign, thumbnail fix, and title fallback | alexei.dolgolyov |
| [1cb83ea](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/1cb83ea) | Add screenshots to README | alexei.dolgolyov |
| [62c42f7](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/62c42f7) | Move install_task_windows.ps1 to scripts folder | alexei.dolgolyov |
| [eb2aed4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/eb2aed4) | Update media browser UI with fade-in animations and improvements | alexei.dolgolyov |
| [7c631d0](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/7c631d0) | Add media browser feature with UI improvements | alexei.dolgolyov |
| [d5ec5c6](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d5ec5c6) | Update Web UI: Improve volume slider responsiveness | alexei.dolgolyov |
| [29e0618](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/29e0618) | Update Web UI: Add server management scripts and improve UX | alexei.dolgolyov |
| [4f8f59d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4f8f59d) | Update media-server: Fix FOUC (Flash of Untranslated Content) issues | alexei.dolgolyov |
| [40c2c11](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/40c2c11) | Update media-server: Improve script/callback table layout and command editor UX | alexei.dolgolyov |
| [4635cac](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4635cac) | Update media-server: Add execution timing and improve script/callback execution UI | alexei.dolgolyov |
| [957a177](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/957a177) | Update media-server: Add backdrop click-to-close for dialogs | alexei.dolgolyov |
| [8077181](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/8077181) | Fix Windows Task Scheduler auto-start | alexei.dolgolyov |
| [9bbb8e1](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/9bbb8e1) | Add internationalization (i18n) support with English and Russian locales | alexei.dolgolyov |
| [a0af855](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/a0af855) | Add callback management API/UI and theme support | alexei.dolgolyov |
| [d7c5994](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d7c5994) | Add runtime script management with Home Assistant integration | alexei.dolgolyov |
| [71a0a6e](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/71a0a6e) | Add multi-token authentication with client labels | alexei.dolgolyov |
| [5342cff](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/5342cff) | Add script execution to Web UI | alexei.dolgolyov |
| [a0d138b](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/a0d138b) | Add built-in Web UI for media control and monitoring | alexei.dolgolyov |
| [1a1cfba](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/1a1cfba) | Add callbacks support for all media actions | alexei.dolgolyov |
| [83acf5f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/83acf5f) | Initial commit: Media Server for remote media control | alexei.dolgolyov |
</details>
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# build-common.sh — shared functions for platform build scripts
# Source this file, do not execute directly.
# --- Version detection ---
# Fallback chain: CLI arg → git tag → CI env var → pyproject.toml
detect_version() {
local arg="${1:-}"
VERSION="${arg}"
if [ -z "$VERSION" ]; then
VERSION=$(git describe --tags --exact-match 2>/dev/null || true)
fi
if [ -z "$VERSION" ]; then
VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
fi
if [ -z "$VERSION" ]; then
VERSION=$(grep -oP '^version\s*=\s*"\K[^"]+' \
pyproject.toml 2>/dev/null || echo "0.0.0")
fi
VERSION_CLEAN="${VERSION#v}"
# Stamp version into pyproject.toml (single source of truth)
sed -i "s/^version = .*/version = \"${VERSION_CLEAN}\"/" pyproject.toml
}
# --- Clean dist/build directories ---
clean_dist() {
rm -rf dist build
mkdir -p "$@"
}
# --- Verify frontend bundle exists ---
verify_frontend() {
if [ ! -f "media_server/static/dist/app.bundle.js" ]; then
echo "ERROR: Frontend bundle not found. Run 'npm ci && npm run build' first."
exit 1
fi
}
# --- Copy application files into dist ---
# Args: $1 = DIST_DIR
copy_app_files() {
local dist_dir="$1"
echo "Copying application files..."
mkdir -p "${dist_dir}/app"
cp -r media_server "${dist_dir}/app/"
# Remove source JS (bundle is in dist/)
rm -rf "${dist_dir}/app/media_server/static/js"
# Remove source maps from release
rm -f "${dist_dir}/app/media_server/static/dist/"*.map
# Copy config example
cp config.example.yaml "${dist_dir}/"
# Write version file
echo "$VERSION_CLEAN" > "${dist_dir}/VERSION"
}
# --- Clean up site-packages for smaller distribution ---
# Args: $1 = site-packages path, $2 = ext suffix (pyd|so), $3 = lib suffix (dll|so)
# Windows: cleanup_site_packages "$SP" "pyd" "dll"
# Linux: cleanup_site_packages "$SP" "so" "so"
cleanup_site_packages() {
local sp_dir="$1"
local ext_suffix="${2:-so}"
local lib_suffix="${3:-so}"
echo "Optimizing size..."
# Generic cleanup
find "$sp_dir" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find "$sp_dir" -type d -name tests -exec rm -rf {} + 2>/dev/null || true
find "$sp_dir" -type d -name "*.dist-info" -exec rm -rf {} + 2>/dev/null || true
find "$sp_dir" -name "*.pyi" -delete 2>/dev/null || true
rm -rf "$sp_dir"/{pip,setuptools,pkg_resources,_distutils_hack}* 2>/dev/null || true
# Trim numpy if present
for mod in polynomial linalg ma lib distutils f2py typing _pyinstaller; do
rm -rf "$sp_dir/numpy/$mod" 2>/dev/null || true
done
# Trim OpenCV if present
rm -f "$sp_dir"/cv2/opencv_videoio_ffmpeg*."$lib_suffix" 2>/dev/null || true
rm -rf "$sp_dir"/cv2/{data,gapi,misc,utils,typing_stubs,typing} 2>/dev/null || true
# Trim Pillow unused plugins if present
rm -rf "$sp_dir"/PIL/{FpxImagePlugin,MicImagePlugin,McIdasImagePlugin}* 2>/dev/null || true
# Trim zeroconf service DB if present
rm -rf "$sp_dir"/zeroconf/_services 2>/dev/null || true
# Strip debug symbols from native extensions
find "$sp_dir" -name "*.$ext_suffix" -exec strip --strip-debug {} \; 2>/dev/null || true
# Remove .py source files (keep .pyc only) — saves ~30-40% on pure-Python packages
find "$sp_dir" -name "*.py" ! -name "__init__.py" -delete 2>/dev/null || true
}
+8 -38
View File
@@ -4,37 +4,17 @@ set -euo pipefail
# Build Linux distribution (self-contained venv + tarball)
# Usage: ./build-dist-linux.sh [VERSION]
# --- Version detection ---
VERSION="${1:-}"
source "$(dirname "$0")/build-common.sh"
if [ -z "$VERSION" ]; then
VERSION=$(git describe --tags --exact-match 2>/dev/null || true)
fi
if [ -z "$VERSION" ]; then
VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
fi
if [ -z "$VERSION" ]; then
VERSION=$(grep -oP '__version__\s*=\s*"\K[^"]+' \
media_server/__init__.py 2>/dev/null || echo "0.0.0")
fi
VERSION_CLEAN="${VERSION#v}"
detect_version "${1:-}"
echo "Building Media Server v${VERSION_CLEAN} for Linux"
# --- Configuration ---
DIST_DIR="dist/media-server"
BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-linux-x64"
rm -rf dist build
mkdir -p "${DIST_DIR}" build
# --- Verify frontend bundle ---
if [ ! -f "media_server/static/dist/app.bundle.js" ]; then
echo "ERROR: Frontend bundle not found. Run 'npm ci && npm run build' first."
exit 1
fi
clean_dist "${DIST_DIR}" build
verify_frontend
# --- Create self-contained virtualenv ---
echo "Creating virtualenv..."
@@ -49,21 +29,11 @@ rm -rf "${DIST_DIR}"/venv/lib/python*/site-packages/media_server*.dist-info
deactivate
# --- Copy application ---
echo "Copying application files..."
mkdir -p "${DIST_DIR}/app"
cp -r media_server "${DIST_DIR}/app/"
# Trim venv site-packages
LINUX_SP=$(echo "${DIST_DIR}"/venv/lib/python*/site-packages)
cleanup_site_packages "$LINUX_SP" "so" "so"
# Remove source JS (bundle is in dist/)
rm -rf "${DIST_DIR}/app/media_server/static/js"
# Remove source maps from release
rm -f "${DIST_DIR}/app/media_server/static/dist/"*.map
# Copy config example
cp config.example.yaml "${DIST_DIR}/"
# --- Write version ---
echo "$VERSION_CLEAN" > "${DIST_DIR}/VERSION"
copy_app_files "$DIST_DIR"
# --- Create launcher ---
cat > "${DIST_DIR}/media-server.sh" << 'LAUNCHER'
+17 -53
View File
@@ -4,23 +4,9 @@ set -euo pipefail
# Cross-build Windows distribution on Linux
# Usage: ./build-dist-windows.sh [VERSION]
# --- Version detection ---
VERSION="${1:-}"
source "$(dirname "$0")/build-common.sh"
if [ -z "$VERSION" ]; then
VERSION=$(git describe --tags --exact-match 2>/dev/null || true)
fi
if [ -z "$VERSION" ]; then
VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
fi
if [ -z "$VERSION" ]; then
VERSION=$(grep -oP '__version__\s*=\s*"\K[^"]+' \
media_server/__init__.py 2>/dev/null || echo "0.0.0")
fi
VERSION_CLEAN="${VERSION#v}"
detect_version "${1:-}"
echo "Building Media Server v${VERSION_CLEAN} for Windows"
# --- Configuration ---
@@ -31,8 +17,7 @@ WHEEL_DIR="build/win-wheels"
SITE_PACKAGES="${DIST_DIR}/python/Lib/site-packages"
BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-win-x64"
rm -rf dist build
mkdir -p "${DIST_DIR}" "${WHEEL_DIR}" "${SITE_PACKAGES}"
clean_dist "${DIST_DIR}" "${WHEEL_DIR}" "${SITE_PACKAGES}"
# --- Download embedded Python ---
echo "Downloading embedded Python ${PYTHON_VERSION}..."
@@ -58,6 +43,7 @@ CORE_DEPS=(
"pyyaml>=6.0"
"mutagen>=1.47.0"
"pillow>=10.0.0"
"packaging>=23.0"
)
# Windows-specific dependencies
@@ -74,17 +60,24 @@ WIN_DEPS=(
# Visualizer dependencies
VIS_DEPS=(
"soundcard>=0.4.0"
"numpy>=1.24.0"
"numpy>=1.24.0,<2.0"
)
ALL_DEPS=("${CORE_DEPS[@]}" "${WIN_DEPS[@]}" "${VIS_DEPS[@]}")
for dep in "${ALL_DEPS[@]}"; do
pip download --quiet --dest "$WHEEL_DIR" \
pip download --quiet --no-cache-dir --dest "$WHEEL_DIR" \
--platform win_amd64 --python-version "${PYTHON_SHORT}" \
--implementation cp --only-binary :all: \
"$dep" 2>/dev/null || \
pip download --quiet --dest "$WHEEL_DIR" "$dep"
pip download --quiet --no-cache-dir --dest "$WHEEL_DIR" \
--only-binary :all: \
"$dep"
done
# Remove numpy 2.x wheels pulled as transitive deps (soundcard requires <2.0)
for f in "$WHEEL_DIR"/numpy-2*; do
[ -f "$f" ] && echo "Removing incompatible: $(basename "$f")" && rm "$f"
done
# Install wheels into site-packages
@@ -93,43 +86,14 @@ for whl in "$WHEEL_DIR"/*.whl; do
unzip -qo "$whl" -d "$SITE_PACKAGES"
done
# --- Size optimization ---
echo "Optimizing size..."
find "$SITE_PACKAGES" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find "$SITE_PACKAGES" -type d -name tests -exec rm -rf {} + 2>/dev/null || true
find "$SITE_PACKAGES" -type d -name "*.dist-info" -exec rm -rf {} + 2>/dev/null || true
find "$SITE_PACKAGES" -name "*.pyi" -delete 2>/dev/null || true
rm -rf "$SITE_PACKAGES"/{pip,setuptools,pkg_resources}* 2>/dev/null || true
# Trim numpy if present
rm -rf "$SITE_PACKAGES"/numpy/{tests,f2py,typing} 2>/dev/null || true
# --- Verify frontend bundle ---
if [ ! -f "media_server/static/dist/app.bundle.js" ]; then
echo "ERROR: Frontend bundle not found. Run 'npm ci && npm run build' first."
exit 1
fi
# --- Copy application ---
echo "Copying application files..."
mkdir -p "${DIST_DIR}/app"
cp -r media_server "${DIST_DIR}/app/"
# Remove source JS (bundle is in dist/)
rm -rf "${DIST_DIR}/app/media_server/static/js"
# Remove source maps from release
rm -f "${DIST_DIR}/app/media_server/static/dist/"*.map
# Copy config example
cp config.example.yaml "${DIST_DIR}/"
cleanup_site_packages "$SITE_PACKAGES" "pyd" "dll"
verify_frontend
copy_app_files "$DIST_DIR"
# Copy scripts needed for auto-start
mkdir -p "${DIST_DIR}/scripts"
cp scripts/start-hidden.vbs "${DIST_DIR}/scripts/"
# --- Write version ---
echo "$VERSION_CLEAN" > "${DIST_DIR}/VERSION"
# --- Create launcher ---
cat > "${DIST_DIR}/media-server.bat" << 'LAUNCHER'
@echo off
+5
View File
@@ -20,6 +20,7 @@ scripts:
command: "rundll32.exe user32.dll,LockWorkStation"
label: "Lock Screen"
description: "Lock the workstation"
icon: "mdi:lock"
timeout: 5
shell: true
@@ -27,6 +28,7 @@ scripts:
command: "shutdown /h"
label: "Hibernate"
description: "Hibernate the PC"
icon: "mdi:power-sleep"
timeout: 10
shell: true
@@ -34,6 +36,7 @@ scripts:
command: "rundll32.exe powrprof.dll,SetSuspendState 0,1,0"
label: "Sleep"
description: "Put PC to sleep"
icon: "mdi:sleep"
timeout: 10
shell: true
@@ -41,6 +44,7 @@ scripts:
command: "shutdown /s /t 0"
label: "Shutdown"
description: "Shutdown the PC immediately"
icon: "mdi:power"
timeout: 10
shell: true
@@ -48,6 +52,7 @@ scripts:
command: "shutdown /r /t 0"
label: "Restart"
description: "Restart the PC immediately"
icon: "mdi:restart"
timeout: 10
shell: true
+4
View File
@@ -73,6 +73,10 @@ Section "!Core (required)" SecCore
; Copy entire distribution
File /r "dist\media-server\*.*"
; Create config.yaml from example if it doesn't already exist (preserve user config on upgrade)
IfFileExists "$INSTDIR\config.yaml" +2
CopyFiles /SILENT "$INSTDIR\config.example.yaml" "$INSTDIR\config.yaml"
; Create uninstaller
WriteUninstaller "$INSTDIR\uninstall.exe"
+21 -1
View File
@@ -1,3 +1,23 @@
"""Media Server - REST API for controlling system media playback."""
__version__ = "1.0.0"
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
def _detect_version() -> str:
# 1. Package metadata (works when pip-installed in dev)
try:
return version("media-server")
except PackageNotFoundError:
pass
# 2. VERSION file written by build scripts (production builds)
# Located at install root, two levels up from this package
version_file = Path(__file__).resolve().parent.parent.parent / "VERSION"
if version_file.is_file():
return version_file.read_text().strip()
return "0.0.0-dev"
__version__ = _detect_version()
+34
View File
@@ -26,6 +26,26 @@ class CallbackConfig(BaseModel):
shell: bool = Field(default=True, description="Run command in shell")
class ScriptParameterConfig(BaseModel):
"""Configuration for a script parameter."""
type: str = Field(
...,
description="Parameter type: string, integer, float, boolean, select",
pattern=r"^(string|integer|float|boolean|select)$",
)
description: str = Field(default="", description="Parameter description")
required: bool = Field(default=False, description="Whether the parameter is required")
default: Optional[str | int | float | bool] = Field(
default=None, description="Default value if not provided"
)
min: Optional[float] = Field(default=None, description="Minimum value (numeric types only)")
max: Optional[float] = Field(default=None, description="Maximum value (numeric types only)")
options: Optional[list[str]] = Field(
default=None, description="Allowed values (select type only)"
)
class ScriptConfig(BaseModel):
"""Configuration for a custom script."""
@@ -36,6 +56,9 @@ class ScriptConfig(BaseModel):
timeout: int = Field(default=30, description="Execution timeout in seconds", ge=1, le=300)
working_dir: Optional[str] = Field(default=None, description="Working directory")
shell: bool = Field(default=True, description="Run command in shell")
parameters: dict[str, ScriptParameterConfig] = Field(
default_factory=dict, description="Named parameters with type and validation rules"
)
class LinkConfig(BaseModel):
@@ -136,6 +159,17 @@ class Settings(BaseSettings):
description="Loopback audio device name for visualizer (None = auto-detect)",
)
# Update checker
update_check_enabled: bool = Field(
default=True,
description="Check for new versions on startup and periodically",
)
update_check_interval: int = Field(
default=21600,
description="Update check interval in seconds (default: 6 hours)",
ge=600,
)
@classmethod
def load_from_yaml(cls, path: Optional[Path] = None) -> "Settings":
"""Load settings from a YAML configuration file."""
+28
View File
@@ -451,6 +451,34 @@ class ConfigManager:
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:
data.pop(key, None)
else:
data[key] = value
self._config_path.parent.mkdir(parents=True, exist_ok=True)
with open(self._config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
# Update in-memory settings
if hasattr(settings, key):
setattr(settings, key, value)
logger.info("Setting '%s' updated to: %s", key, value)
# Global config manager instance
config_manager = ConfigManager()
+66 -9
View File
@@ -51,6 +51,9 @@ def setup_logging():
handlers=[handler],
)
# Suppress noisy third-party loggers
logging.getLogger("screen_brightness_control").setLevel(logging.ERROR)
@asynccontextmanager
async def lifespan(app: FastAPI):
@@ -71,6 +74,18 @@ async def lifespan(app: FastAPI):
await ws_manager.start_status_monitor(controller.get_status)
logger.info("WebSocket status monitor started")
# Start update checker
update_checker = None
if settings.update_check_enabled:
from .services.gitea_release_provider import GiteaReleaseProvider
from .services.update_checker import UpdateChecker
provider = GiteaReleaseProvider()
update_checker = UpdateChecker(provider, __version__)
await update_checker.start(settings.update_check_interval)
# Store globally so health endpoint can access cached result
app.state.update_checker = update_checker
# Register audio visualizer (capture starts on-demand when clients subscribe)
analyzer = None
if settings.visualizer_enabled:
@@ -89,6 +104,10 @@ async def lifespan(app: FastAPI):
yield
# Stop update checker
if update_checker is not None:
await update_checker.stop()
# Stop audio visualizer
await ws_manager.stop_audio_monitor()
if analyzer and analyzer.running:
@@ -240,23 +259,61 @@ def main():
print("\nAuthentication is DISABLED (no tokens configured)")
return
# Start system tray icon (unless disabled)
tray_icon = None
if not args.no_tray:
from .tray import start_tray
from .tray import PYSTRAY_AVAILABLE, TrayManager
tray_icon = start_tray(args.host, args.port)
use_tray = PYSTRAY_AVAILABLE and not args.no_tray
try:
if use_tray:
import asyncio
import threading
# Run uvicorn in a background thread so tray owns the main thread message loop
uv_config = uvicorn.Config(
"media_server.main:app",
host=args.host,
port=args.port,
log_level=settings.log_level.lower(),
)
server = uvicorn.Server(uv_config)
def run_server():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(server.serve())
server_thread = threading.Thread(target=run_server, daemon=True)
server_thread.start()
# Tray on main thread (blocking)
tray = TrayManager(
port=args.port,
on_exit=lambda: setattr(server, "should_exit", True),
)
tray.run()
# Tray exited — wait for server to finish graceful shutdown
server_thread.join(timeout=10)
if tray.restart_requested:
import subprocess
# Always restart via `python -m media_server.main` — this works
# regardless of how we were originally started (console_script,
# python -m, or direct script invocation).
cmd = [sys.executable, "-m", "media_server.main"]
subprocess.Popen(
cmd,
cwd=Path.cwd(),
start_new_session=True,
)
else:
uvicorn.run(
"media_server.main:app",
host=args.host,
port=args.port,
reload=False,
)
finally:
if tray_icon is not None:
tray_icon.stop()
if __name__ == "__main__":
+12 -4
View File
@@ -3,23 +3,31 @@
import platform
from typing import Any
from fastapi import APIRouter
from fastapi import APIRouter, Request
from .. import __version__
from ..auth import auth_enabled
router = APIRouter(prefix="/api", tags=["health"])
@router.get("/health")
async def health_check() -> dict[str, Any]:
async def health_check(request: Request) -> dict[str, Any]:
"""Health check endpoint - no authentication required.
Returns:
Health status and server information
"""
return {
result: dict[str, Any] = {
"status": "healthy",
"platform": platform.system(),
"version": "1.0.0",
"version": __version__,
"auth_required": auth_enabled(),
}
# Include cached update info if available
checker = getattr(request.app.state, "update_checker", None)
if checker is not None and checker.cached_update is not None:
result["update_available"] = checker.cached_update
return result
+6
View File
@@ -307,6 +307,12 @@ async def set_visualizer_device(
# set_device() handles stop/start internally if capture was running
success = analyzer.set_device(device_name)
# Persist selection to config.yaml so it survives server restarts
if success:
from ..config_manager import config_manager
config_manager.set_setting("visualizer_device", device_name)
return {
"success": success,
"current_device": analyzer.current_device,
+239 -16
View File
@@ -12,7 +12,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from ..auth import verify_token
from ..config import ScriptConfig, settings
from ..config import ScriptConfig, ScriptParameterConfig, settings
from ..config_manager import config_manager
from ..services.websocket_manager import ws_manager
@@ -24,9 +24,11 @@ logger = logging.getLogger(__name__)
class ScriptExecuteRequest(BaseModel):
"""Request model for script execution with optional arguments."""
"""Request model for script execution with optional parameters."""
args: list[str] = Field(default_factory=list, description="Additional arguments")
params: dict[str, str | int | float | bool] = Field(
default_factory=dict, description="Named parameters (validated against script schema)"
)
class ScriptExecuteResponse(BaseModel):
@@ -41,6 +43,18 @@ class ScriptExecuteResponse(BaseModel):
execution_time: float | None = None
class ScriptParameterInfo(BaseModel):
"""Information about a script parameter."""
type: str
description: str = ""
required: bool = False
default: str | int | float | bool | None = None
min: float | None = None
max: float | None = None
options: list[str] | None = None
class ScriptInfo(BaseModel):
"""Information about an available script."""
@@ -50,6 +64,7 @@ class ScriptInfo(BaseModel):
description: str
icon: str | None = None
timeout: int
parameters: dict[str, ScriptParameterInfo] = Field(default_factory=dict)
@router.get("/list")
@@ -67,11 +82,126 @@ async def list_scripts(_: str = Depends(verify_token)) -> list[ScriptInfo]:
description=config.description,
icon=config.icon,
timeout=config.timeout,
parameters={
pname: ScriptParameterInfo(**pconfig.model_dump())
for pname, pconfig in config.parameters.items()
},
)
for name, config in settings.scripts.items()
]
def _validate_params(
params: dict[str, str | int | float | bool],
param_defs: dict[str, ScriptParameterConfig],
) -> dict[str, str]:
"""Validate parameters against script schema and return env vars.
Args:
params: User-supplied parameter values.
param_defs: Parameter definitions from script config.
Returns:
Dict of environment variables (SCRIPT_PARAM_<NAME> -> str value).
Raises:
HTTPException: On validation failure.
"""
# Reject unknown parameters
unknown = set(params.keys()) - set(param_defs.keys())
if unknown:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unknown parameters: {', '.join(sorted(unknown))}",
)
env_vars: dict[str, str] = {}
for pname, pdef in param_defs.items():
value = params.get(pname)
# Apply default if missing
if value is None and pdef.default is not None:
value = pdef.default
# Check required
if value is None:
if pdef.required:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Required parameter '{pname}' is missing",
)
continue
# Type validation and coercion
if pdef.type == "integer":
try:
value = int(value)
except (TypeError, ValueError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' must be an integer, got: {value!r}",
)
if pdef.min is not None and value < pdef.min:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' must be >= {pdef.min}, got: {value}",
)
if pdef.max is not None and value > pdef.max:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' must be <= {pdef.max}, got: {value}",
)
elif pdef.type == "float":
try:
value = float(value)
except (TypeError, ValueError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' must be a number, got: {value!r}",
)
if pdef.min is not None and value < pdef.min:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' must be >= {pdef.min}, got: {value}",
)
if pdef.max is not None and value > pdef.max:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' must be <= {pdef.max}, got: {value}",
)
elif pdef.type == "boolean":
if isinstance(value, str):
if value.lower() in ("true", "1", "yes"):
value = True
elif value.lower() in ("false", "0", "no"):
value = False
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' must be a boolean, got: {value!r}",
)
elif not isinstance(value, bool):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' must be a boolean, got: {value!r}",
)
elif pdef.type == "select":
value = str(value)
if pdef.options and value not in pdef.options:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' must be one of {pdef.options}, got: {value!r}",
)
else:
# string — just convert to str
value = str(value)
env_vars[f"SCRIPT_PARAM_{pname.upper()}"] = str(value)
return env_vars
@router.post("/execute/{script_name}")
async def execute_script(
script_name: str,
@@ -82,7 +212,7 @@ async def execute_script(
Args:
script_name: Name of the script to execute (must be defined in config)
request: Optional arguments to pass to the script
request: Optional parameters to pass to the script
Returns:
Execution result including stdout, stderr, and exit code
@@ -94,26 +224,24 @@ async def execute_script(
detail=f"Script '{script_name}' not found. Use /api/scripts/list to see available scripts.",
)
script_config = settings.scripts[script_name]
args = request.args if request else []
params = request.params if request else {}
# Validate parameters and build env vars
extra_env = _validate_params(params, script_config.parameters)
logger.info(f"Executing script: {script_name}")
try:
# Build command
command = script_config.command
if args:
# Append arguments to command
command = f"{command} {' '.join(args)}"
# Execute in dedicated thread pool to not block the default executor
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
_script_executor,
lambda: _run_script(
command=command,
command=script_config.command,
timeout=script_config.timeout,
shell=script_config.shell,
working_dir=script_config.working_dir,
extra_env=extra_env,
),
)
@@ -140,6 +268,7 @@ def _run_script(
timeout: int,
shell: bool,
working_dir: str | None,
extra_env: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Run a script synchronously.
@@ -148,11 +277,16 @@ def _run_script(
timeout: Timeout in seconds
shell: Whether to run in shell
working_dir: Working directory
extra_env: Additional environment variables (e.g. SCRIPT_PARAM_*)
Returns:
Dict with exit_code, stdout, stderr, execution_time
"""
start_time = time.time()
env = None
if extra_env:
import os
env = {**os.environ, **extra_env}
try:
result = subprocess.run(
command,
@@ -161,6 +295,7 @@ def _run_script(
capture_output=True,
text=True,
timeout=timeout,
env=env,
)
execution_time = time.time() - start_time
return {
@@ -190,6 +325,24 @@ def _run_script(
# Script management endpoints
class ScriptParameterCreateRequest(BaseModel):
"""Request model for a script parameter definition."""
type: str = Field(
..., description="Parameter type: string, integer, float, boolean, select"
)
description: str = Field(default="", description="Parameter description")
required: bool = Field(default=False, description="Whether the parameter is required")
default: str | int | float | bool | None = Field(
default=None, description="Default value if not provided"
)
min: float | None = Field(default=None, description="Minimum value (numeric types only)")
max: float | None = Field(default=None, description="Maximum value (numeric types only)")
options: list[str] | None = Field(
default=None, description="Allowed values (select type only)"
)
class ScriptCreateRequest(BaseModel):
"""Request model for creating or updating a script."""
@@ -200,6 +353,60 @@ class ScriptCreateRequest(BaseModel):
timeout: int = Field(default=30, description="Execution timeout in seconds", ge=1, le=300)
working_dir: str | None = Field(default=None, description="Working directory")
shell: bool = Field(default=True, description="Run command in shell")
parameters: dict[str, ScriptParameterCreateRequest] = Field(
default_factory=dict, description="Named parameters with type and validation rules"
)
def _validate_parameter_definitions(
parameters: dict[str, ScriptParameterCreateRequest],
) -> None:
"""Validate parameter definitions are well-formed.
Args:
parameters: Parameter definitions to validate.
Raises:
HTTPException: If any definition is invalid.
"""
valid_types = {"string", "integer", "float", "boolean", "select"}
for pname, pdef in parameters.items():
if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]*$", pname):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"Parameter name '{pname}' must start with a letter"
" and contain only alphanumeric characters and underscores"
),
)
if pdef.type not in valid_types:
allowed = ", ".join(sorted(valid_types))
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' has invalid type '{pdef.type}'. Must be one of: {allowed}",
)
if pdef.type == "select":
if not pdef.options or len(pdef.options) == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}' of type 'select' must have a non-empty 'options' list",
)
if pdef.type not in ("integer", "float"):
if pdef.min is not None or pdef.max is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}': 'min'/'max' are only valid for integer/float types",
)
if pdef.min is not None and pdef.max is not None and pdef.min > pdef.max:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter '{pname}': 'min' ({pdef.min}) must be <= 'max' ({pdef.max})",
)
def _validate_script_name(name: str) -> None:
@@ -258,8 +465,16 @@ async def create_script(
detail=f"Script '{script_name}' already exists. Use PUT /api/scripts/update/{script_name} to update it.",
)
# Create script config
script_config = ScriptConfig(**request.model_dump())
# Validate parameter definitions
_validate_parameter_definitions(request.parameters)
# Build ScriptConfig with ScriptParameterConfig instances
data = request.model_dump()
data["parameters"] = {
pname: ScriptParameterConfig(**pdef)
for pname, pdef in data.get("parameters", {}).items()
}
script_config = ScriptConfig(**data)
# Add to config file and in-memory
try:
@@ -306,8 +521,16 @@ async def update_script(
detail=f"Script '{script_name}' not found. Use POST /api/scripts/create/{script_name} to create it.",
)
# Create updated script config
script_config = ScriptConfig(**request.model_dump())
# Validate parameter definitions
_validate_parameter_definitions(request.parameters)
# Build ScriptConfig with ScriptParameterConfig instances
data = request.model_dump()
data["parameters"] = {
pname: ScriptParameterConfig(**pdef)
for pname, pdef in data.get("parameters", {}).items()
}
script_config = ScriptConfig(**data)
# Update config file and in-memory
try:
@@ -0,0 +1,77 @@
"""Gitea release provider implementation."""
import json
import logging
import urllib.error
import urllib.request
from typing import Optional
from .release_provider import ReleaseInfo, ReleaseProvider
logger = logging.getLogger(__name__)
# Default repository coordinates
_DEFAULT_BASE_URL = "https://git.dolgolyov-family.by"
_DEFAULT_OWNER = "alexei.dolgolyov"
_DEFAULT_REPO = "media-player-server"
class GiteaReleaseProvider(ReleaseProvider):
"""Fetches the latest release from a Gitea repository."""
def __init__(
self,
base_url: str = _DEFAULT_BASE_URL,
owner: str = _DEFAULT_OWNER,
repo: str = _DEFAULT_REPO,
timeout: float = 10.0,
) -> None:
self._api_url = f"{base_url}/api/v1/repos/{owner}/{repo}/releases"
self._release_page_url = f"{base_url}/{owner}/{repo}/releases/tag"
self._timeout = timeout
async def get_latest_release(self) -> Optional[ReleaseInfo]:
"""Fetch the latest stable release from Gitea API.
Returns:
ReleaseInfo for the latest non-prerelease, or None on failure.
"""
import asyncio
try:
data = await asyncio.to_thread(self._fetch_releases)
except Exception as e:
logger.warning("Failed to check for updates: %s", e)
return None
if not data:
return None
# Find the first non-prerelease, non-draft release
for release in data:
if release.get("draft") or release.get("prerelease"):
continue
tag = release.get("tag_name", "")
version = tag.lstrip("v")
if not version:
continue
return ReleaseInfo(
version=version,
url=f"{self._release_page_url}/{tag}",
prerelease=False,
)
logger.debug("No stable releases found")
return None
def _fetch_releases(self) -> list[dict]:
"""Synchronous HTTP fetch of releases (run in thread)."""
url = f"{self._api_url}?limit=5"
req = urllib.request.Request(url, headers={"Accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
return json.loads(resp.read().decode())
except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError, OSError) as e:
raise RuntimeError(f"Gitea API request failed: {e}") from e
+29
View File
@@ -0,0 +1,29 @@
"""Abstract release provider interface for version checking."""
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True)
class ReleaseInfo:
"""Version-provider-agnostic release metadata."""
version: str # e.g. "1.1.0" (no "v" prefix)
url: str # release page URL
prerelease: bool
class ReleaseProvider(Protocol):
"""Abstract interface for fetching the latest release.
Implement this protocol to support different hosting platforms
(Gitea, GitHub, GitLab, etc.).
"""
async def get_latest_release(self) -> ReleaseInfo | None:
"""Fetch the latest stable release.
Returns:
ReleaseInfo if a release was found, None on failure.
"""
...
+126
View File
@@ -0,0 +1,126 @@
"""Provider-agnostic update checker service."""
import asyncio
import logging
import re
from typing import Any, Optional
from packaging.version import Version
from .release_provider import ReleaseProvider
from .websocket_manager import ws_manager
logger = logging.getLogger(__name__)
_PRE_PATTERN = re.compile(
r"^(\d+\.\d+\.\d+)[-.]?(alpha|beta|rc)[.-]?(\d+)$", re.IGNORECASE
)
_PRE_MAP = {"alpha": "a", "beta": "b", "rc": "rc"}
def _parse_version(raw: str) -> Version:
"""Normalize a version tag to PEP 440 for correct comparison.
Examples:
v0.3.0-alpha.1 → 0.3.0a1 (pre-release, sorts below 0.3.0)
v0.3.0-rc.3 → 0.3.0rc3
v1.0.0 → 1.0.0
"""
cleaned = raw.lstrip("v").strip()
m = _PRE_PATTERN.match(cleaned)
if m:
base, pre_label, pre_num = m.group(1), m.group(2).lower(), m.group(3)
cleaned = f"{base}{_PRE_MAP[pre_label]}{pre_num}"
return Version(cleaned)
class UpdateChecker:
"""Periodically checks for new releases using a ReleaseProvider."""
def __init__(self, provider: ReleaseProvider, current_version: str) -> None:
self._provider = provider
self._current_version = current_version
self._current_parsed = _parse_version(current_version)
self._task: Optional[asyncio.Task] = None
self._cached_update: Optional[dict[str, Any]] = None
@property
def cached_update(self) -> Optional[dict[str, Any]]:
"""Return the cached update info, or None if up-to-date."""
return self._cached_update
async def check_for_update(self) -> Optional[dict[str, Any]]:
"""Check for a newer release.
Returns:
Dict with current/latest/url if an update exists, None otherwise.
"""
release = await self._provider.get_latest_release()
if release is None:
return None
latest_parsed = _parse_version(release.version)
if latest_parsed <= self._current_parsed:
return None
return {
"current": self._current_version,
"latest": release.version,
"url": release.url,
}
async def start(self, interval: int) -> None:
"""Start periodic update checking.
Checks immediately on start, then every `interval` seconds.
"""
if self._task is not None:
return
self._task = asyncio.create_task(self._check_loop(interval))
logger.info("Update checker started (interval: %ds)", interval)
async def stop(self) -> None:
"""Stop periodic update checking."""
if self._task is not None:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
logger.info("Update checker stopped")
async def _check_loop(self, interval: int) -> None:
"""Background loop that checks for updates periodically."""
# Initial check with a small delay to let the server finish starting
await asyncio.sleep(5)
while True:
try:
update = await self.check_for_update()
if update and update != self._cached_update:
self._cached_update = update
logger.info(
"New version available: %s%s (%s)",
update["current"],
update["latest"],
update["url"],
)
await ws_manager.broadcast(
{"type": "update_available", "data": update}
)
elif update is None and self._cached_update is not None:
# Version was updated (or release removed) — clear cache
self._cached_update = None
except asyncio.CancelledError:
break
except Exception as e:
logger.warning("Update check failed: %s", e)
try:
await asyncio.sleep(interval)
except asyncio.CancelledError:
break
+376 -3
View File
@@ -897,7 +897,7 @@ button:disabled {
height: 6px;
-webkit-appearance: none;
appearance: none;
background: var(--border);
background: color-mix(in srgb, var(--accent) 15%, var(--border));
border-radius: 3px;
outline: none;
}
@@ -1448,7 +1448,7 @@ button:disabled {
appearance: none;
height: 6px;
border-radius: 3px;
background: var(--border);
background: color-mix(in srgb, var(--accent) 15%, var(--border));
outline: none;
cursor: pointer;
}
@@ -1699,6 +1699,11 @@ dialog {
margin: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8);
animation: dialogIn 0.25s ease-out;
overflow: visible;
}
dialog form {
overflow: visible;
}
dialog.dialog-closing {
@@ -1871,6 +1876,319 @@ dialog.dialog-closing::backdrop {
background: var(--border);
}
/* Parameters editor (CRUD dialog) */
.params-section {
margin-top: 0.5rem;
}
.params-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
color: var(--text-secondary);
font-size: 0.875rem;
}
.btn-small {
padding: 0.25rem 0.75rem;
font-size: 0.75rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-tertiary);
color: var(--text-primary);
cursor: pointer;
transition: background 0.2s;
}
.btn-small:hover {
background: var(--border);
}
.param-row {
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.5rem;
margin-bottom: 0.5rem;
background: var(--bg-tertiary);
}
.param-row-header {
display: flex;
gap: 0.375rem;
align-items: center;
}
.param-row-header .param-name {
flex: 1;
min-width: 0;
}
.param-row-header .param-type {
width: 100px;
flex-shrink: 0;
}
.param-required-label {
display: flex !important;
align-items: center;
gap: 0.125rem;
margin-bottom: 0 !important;
cursor: pointer;
color: var(--accent);
font-weight: 700;
font-size: 1rem;
flex-shrink: 0;
}
.param-required-label input {
width: auto !important;
margin: 0 !important;
}
.param-remove-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 1.25rem;
line-height: 1;
padding: 0 0.25rem;
flex-shrink: 0;
transition: color 0.2s;
}
.param-remove-btn:hover {
color: var(--error);
}
.param-row-details {
margin-top: 0.375rem;
}
.param-row-details .param-description {
width: 100%;
margin-bottom: 0.25rem;
font-size: 0.8rem;
color: var(--text-muted);
}
.param-row-extra {
display: flex;
gap: 0.375rem;
}
.param-row-extra input {
flex: 1;
min-width: 0;
}
.param-row-header input,
.param-row-header select,
.param-row-details input,
.param-row-extra input {
padding: 0.35rem 0.5rem;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-primary);
font-family: inherit;
font-size: 0.8rem;
}
.param-row-header input:focus,
.param-row-header select:focus,
.param-row-details input:focus,
.param-row-extra input:focus {
outline: none;
border-color: var(--accent);
}
/* Parameter hint in execution dialog */
.param-hint {
display: block;
color: var(--text-muted);
font-size: 0.75rem;
margin-top: 0.125rem;
}
/* ── Icon Select ──────────────────────────────────────────── */
.icon-select-trigger {
display: flex;
align-items: center;
gap: 0.375rem;
width: 100%;
padding: 0.35rem 0.5rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 0.875rem;
cursor: pointer;
transition: border-color 0.15s;
text-align: left;
font-family: inherit;
box-sizing: border-box;
}
/* Match dialog input height when inside a dialog */
.dialog-body .icon-select-trigger {
padding: 0.5rem;
margin-top: 0.25rem;
}
/* Compact trigger inside param rows */
.param-row-header .icon-select-trigger {
padding: 0.3rem 0.4rem;
margin-top: 0;
font-size: 0.75rem;
width: 110px;
flex-shrink: 0;
}
.icon-select-trigger:hover {
border-color: var(--accent);
}
.icon-select-trigger-icon {
display: flex;
align-items: center;
flex-shrink: 0;
}
.icon-select-trigger-icon svg {
width: 16px;
height: 16px;
fill: currentColor;
}
.param-row-header .icon-select-trigger-icon svg {
width: 14px;
height: 14px;
}
.icon-select-trigger-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon-select-trigger-arrow {
flex-shrink: 0;
font-size: 0.8rem;
opacity: 0.5;
margin-left: auto;
}
.icon-select-popup {
position: absolute;
z-index: 100;
max-height: 260px;
overflow: hidden;
opacity: 0;
transform: translateY(-4px) scale(0.97);
transition: opacity 0.12s ease-out, transform 0.12s cubic-bezier(0.16, 1, 0.3, 1);
pointer-events: none;
box-sizing: border-box;
}
.icon-select-popup.open {
opacity: 1;
transform: translateY(0) scale(1);
overflow-y: auto;
pointer-events: auto;
}
.icon-select-grid {
display: grid;
grid-auto-rows: 1fr;
gap: 6px;
padding: 6px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-secondary);
}
.icon-select-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
padding: 6px 4px;
border: 2px solid transparent;
border-radius: 6px;
background: var(--bg-tertiary);
cursor: pointer;
transition: border-color 0.15s, background 0.15s, transform 0.1s;
text-align: center;
}
.icon-select-cell:hover {
border-color: var(--accent);
transform: scale(1.03);
}
.icon-select-cell.active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, var(--bg-tertiary));
}
.icon-select-cell-icon {
display: flex;
align-items: center;
justify-content: center;
}
.icon-select-cell-icon svg {
width: 20px;
height: 20px;
fill: currentColor;
}
.icon-select-cell.active .icon-select-cell-icon svg {
fill: var(--accent);
}
.icon-select-cell-label {
font-size: 0.75rem;
font-weight: 600;
line-height: 1.2;
}
.icon-select-cell-desc {
font-size: 0.7rem;
opacity: 0.6;
line-height: 1.3;
}
/* Horizontal layout for single-column grids (e.g. audio device list) */
.icon-select-grid--horizontal .icon-select-cell {
flex-direction: row;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
text-align: left;
}
.icon-select-grid--horizontal .icon-select-cell-icon svg {
width: 16px;
height: 16px;
}
.icon-select-grid--horizontal .icon-select-cell-label {
font-size: 0.8rem;
}
@media (max-width: 480px) {
.icon-select-cell-desc { display: none; }
.icon-select-cell { padding: 6px 4px; }
.icon-select-grid { gap: 4px; padding: 4px; }
}
.empty-state {
text-align: center;
padding: 2rem;
@@ -2237,7 +2555,7 @@ dialog.dialog-closing::backdrop {
height: 4px;
-webkit-appearance: none;
appearance: none;
background: var(--border);
background: color-mix(in srgb, var(--accent) 15%, var(--border));
border-radius: 2px;
outline: none;
cursor: pointer;
@@ -3172,6 +3490,61 @@ footer .separator {
}
}
/* Update Banner */
.update-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1001;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 10px 16px;
background: var(--accent);
color: #fff;
font-size: 0.85rem;
font-weight: 500;
text-align: center;
transition: transform 0.3s ease;
}
.update-banner:not(.hidden) {
animation: bannerSlideIn 0.4s ease-out;
}
.update-banner.hidden {
transform: translateY(-100%);
pointer-events: none;
}
.update-banner a {
color: #fff;
text-decoration: underline;
font-weight: 600;
}
.update-banner a:hover {
opacity: 0.85;
}
.update-banner-close {
background: none;
color: #fff;
font-size: 1.2rem;
padding: 0 4px;
opacity: 0.7;
cursor: pointer;
line-height: 1;
display: flex;
align-items: center;
}
.update-banner-close:hover {
opacity: 1;
}
/* Connection Banner */
.connection-banner {
position: fixed;
+34
View File
@@ -83,6 +83,9 @@
</div>
<div class="header-toolbar">
<div id="headerLinks" class="header-links"></div>
<a class="header-btn" href="/docs" target="_blank" title="API Documentation" aria-label="API Documentation">
<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>
<div class="accent-picker">
<button class="header-btn" onclick="toggleAccentPicker()" title="Accent color" aria-label="Accent color">
<span class="accent-dot" id="accentDot"></span>
@@ -111,6 +114,13 @@
</div>
</header>
<!-- Update Banner -->
<div class="update-banner hidden" id="updateBanner">
<span id="updateBannerText"></span>
<a id="updateBannerLink" href="#" target="_blank" rel="noopener noreferrer" data-i18n="update.view_release">View Release</a>
<button class="update-banner-close" id="updateBannerClose">&times;</button>
</div>
<!-- Connection Banner -->
<div class="connection-banner hidden" id="connectionBanner">
<span id="connectionBannerText"></span>
@@ -462,6 +472,14 @@
<span data-i18n="scripts.field.timeout">Timeout (seconds)</span>
<input type="number" id="scriptTimeout" value="30" min="1" max="300">
</label>
<div class="params-section">
<div class="params-header">
<span data-i18n="scripts.field.parameters">Parameters</span>
<button type="button" class="btn-small" onclick="addParameterRow()" data-i18n="scripts.params.add">+ Add</button>
</div>
<div id="scriptParamsContainer"></div>
</div>
</div>
<div class="dialog-footer">
<button type="button" class="btn-secondary" onclick="closeScriptDialog()" data-i18n="scripts.button.cancel">Cancel</button>
@@ -470,6 +488,22 @@
</form>
</dialog>
<!-- Script Parameters Input Dialog (shown before executing scripts with params) -->
<dialog id="scriptParamsDialog">
<div class="dialog-header">
<h3 id="scriptParamsDialogTitle">Execute Script</h3>
</div>
<form id="scriptParamsForm" onsubmit="submitScriptWithParams(event)">
<div class="dialog-body">
<div id="scriptParamsInputs"></div>
</div>
<div class="dialog-footer">
<button type="button" class="btn-secondary" onclick="closeScriptParamsDialog()" data-i18n="scripts.button.cancel">Cancel</button>
<button type="submit" class="btn-primary" data-i18n="scripts.params.execute">Execute</button>
</div>
</form>
</dialog>
<!-- Add/Edit Callback Dialog -->
<dialog id="callbackDialog">
<div class="dialog-header">
+2
View File
@@ -40,6 +40,7 @@ import {
showAddScriptDialog, showEditScriptDialog, closeScriptDialog, saveScript,
deleteScriptConfirm, executeScriptDebug, executeCallbackDebug,
closeExecutionDialog, scriptFormDirty, setScriptFormDirty,
addParameterRow, closeScriptParamsDialog, submitScriptWithParams,
} from './scripts.js';
import {
@@ -106,6 +107,7 @@ Object.assign(window, {
showAddScriptDialog, showEditScriptDialog, closeScriptDialog, saveScript,
deleteScriptConfirm, executeScriptDebug, executeCallbackDebug,
closeExecutionDialog,
addParameterRow, closeScriptParamsDialog, submitScriptWithParams,
// Callbacks
showAddCallbackDialog, showEditCallbackDialog, closeCallbackDialog,
saveCallback, deleteCallbackConfirm,
+30
View File
@@ -3,10 +3,34 @@
// ============================================================
import { t, showToast, escapeHtml, closeDialog, showConfirm, getAuthHeaders, hasCredentials } from './core.js';
import { IconSelect } from './icon-select.js';
import { callbackEventIcons } from './icons.js';
export let callbackFormDirty = false;
export function setCallbackFormDirty(value) { callbackFormDirty = value; }
let _callbackEventIconSelect = null;
function _ensureCallbackEventIconSelect() {
if (_callbackEventIconSelect) return;
const select = document.getElementById('callbackName');
if (!select) return;
const items = Object.entries(callbackEventIcons).map(([value, icon]) => ({
value,
icon,
label: value,
}));
_callbackEventIconSelect = new IconSelect({
target: select,
items,
columns: 3,
placeholder: t('callbacks.placeholder.event'),
onChange: () => { callbackFormDirty = true; },
});
}
let _loadCallbacksPromise = null;
export async function loadCallbacksTable() {
if (_loadCallbacksPromise) return _loadCallbacksPromise;
@@ -71,6 +95,9 @@ export function showAddCallbackDialog() {
document.getElementById('callbackName').disabled = false;
title.textContent = t('callbacks.dialog.add');
_ensureCallbackEventIconSelect();
if (_callbackEventIconSelect) _callbackEventIconSelect.setValue('', false);
callbackFormDirty = false;
document.body.classList.add('dialog-open');
@@ -101,6 +128,9 @@ export async function showEditCallbackDialog(callbackName) {
document.getElementById('callbackIsEdit').value = 'true';
document.getElementById('callbackName').value = callbackName;
document.getElementById('callbackName').disabled = true;
_ensureCallbackEventIconSelect();
if (_callbackEventIconSelect) _callbackEventIconSelect.setValue(callbackName, false);
document.getElementById('callbackCommand').value = callback.command;
document.getElementById('callbackTimeout').value = callback.timeout;
document.getElementById('callbackWorkingDir').value = callback.working_dir || '';
+23
View File
@@ -318,12 +318,35 @@ export async function fetchVersion() {
if (data.version) {
label.textContent = `v${data.version}`;
}
if (data.update_available) {
showUpdateBanner(data.update_available);
}
}
} catch (error) {
console.error('Error fetching version:', error);
}
}
export function showUpdateBanner(update) {
const dismissed = sessionStorage.getItem('update_dismissed');
if (dismissed === update.latest) return;
const banner = document.getElementById('updateBanner');
const text = document.getElementById('updateBannerText');
const link = document.getElementById('updateBannerLink');
const closeBtn = document.getElementById('updateBannerClose');
text.textContent = t('update.available', { version: update.latest });
link.href = update.url;
link.textContent = t('update.view_release');
banner.classList.remove('hidden');
closeBtn.onclick = () => {
banner.classList.add('hidden');
sessionStorage.setItem('update_dismissed', update.latest);
};
}
// ============================================================
// Shared Utilities
// ============================================================
+160
View File
@@ -0,0 +1,160 @@
// ============================================================
// IconSelect: visual icon-grid selector (replaces <select>)
// Ported from wled-screen-controller (TypeScript → vanilla JS)
//
// Trigger replaces the <select> inline. Popup is absolutely
// positioned inside a wrapper that sits next to the trigger.
// Works inside <dialog showModal()> — dialog must have
// overflow: visible.
// ============================================================
const POPUP_CLASS = 'icon-select-popup';
let _globalListenerAdded = false;
export function closeAllIconSelects() {
document.querySelectorAll(`.${POPUP_CLASS}.open`).forEach(p => {
p.classList.remove('open');
});
}
function _ensureGlobalListener() {
if (_globalListenerAdded) return;
_globalListenerAdded = true;
document.addEventListener('click', (e) => {
if (!e.target.closest(`.${POPUP_CLASS}`) && !e.target.closest('.icon-select-trigger')) {
closeAllIconSelects();
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeAllIconSelects();
});
}
export class IconSelect {
constructor({ target, items, onChange, columns = 2, placeholder = '', horizontal = false }) {
_ensureGlobalListener();
this._select = target;
this._items = items;
this._onChange = onChange;
this._columns = columns;
this._placeholder = placeholder;
this._horizontal = horizontal;
// Hide native select
this._select.style.display = 'none';
// Trigger button (replaces select visually)
this._trigger = document.createElement('button');
this._trigger.type = 'button';
this._trigger.className = 'icon-select-trigger';
this._trigger.addEventListener('click', (e) => {
e.stopPropagation();
this._toggle();
});
this._select.parentNode.insertBefore(this._trigger, this._select.nextSibling);
// Popup — absolutely positioned, appended to dialog (overflow:visible)
// or body, escaping any scrollable ancestors
this._popup = document.createElement('div');
this._popup.className = POPUP_CLASS;
this._popup.addEventListener('click', (e) => e.stopPropagation());
this._popup.innerHTML = this._buildGrid();
const portal = this._select.closest('dialog') || document.body;
portal.appendChild(this._popup);
this._bindCells();
this._syncTrigger();
}
_buildGrid() {
const cells = this._items.map(item =>
`<div class="icon-select-cell" data-value="${item.value}">
<span class="icon-select-cell-icon">${item.icon}</span>
<span class="icon-select-cell-label">${item.label}</span>
${item.desc ? `<span class="icon-select-cell-desc">${item.desc}</span>` : ''}
</div>`
).join('');
const cls = 'icon-select-grid' + (this._horizontal ? ' icon-select-grid--horizontal' : '');
return `<div class="${cls}" style="grid-template-columns:repeat(${this._columns},1fr)">${cells}</div>`;
}
_bindCells() {
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
cell.addEventListener('click', () => {
this.setValue(cell.dataset.value, true);
this._popup.classList.remove('open');
});
});
}
_syncTrigger() {
const val = this._select.value;
const item = this._items.find(i => i.value === val);
if (item) {
this._trigger.innerHTML =
`<span class="icon-select-trigger-icon">${item.icon}</span>` +
`<span class="icon-select-trigger-label">${item.label}</span>` +
`<span class="icon-select-trigger-arrow">&#x25BE;</span>`;
} else if (this._placeholder) {
this._trigger.innerHTML =
`<span class="icon-select-trigger-label">${this._placeholder}</span>` +
`<span class="icon-select-trigger-arrow">&#x25BE;</span>`;
}
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
cell.classList.toggle('active', cell.dataset.value === val);
});
}
_positionPopup() {
// Get trigger position relative to the popup's offset parent
// (the dialog or body). Use getBoundingClientRect for both and
// compute the offset.
const triggerRect = this._trigger.getBoundingClientRect();
const parentRect = this._popup.offsetParent
? this._popup.offsetParent.getBoundingClientRect()
: { left: 0, top: 0 };
const relTop = triggerRect.bottom - parentRect.top;
const relLeft = triggerRect.left - parentRect.left;
const popupW = Math.max(triggerRect.width, 200);
this._popup.style.left = relLeft + 'px';
this._popup.style.top = (relTop + 4) + 'px';
this._popup.style.width = popupW + 'px';
}
_toggle() {
const wasOpen = this._popup.classList.contains('open');
closeAllIconSelects();
if (!wasOpen) {
this._positionPopup();
this._popup.classList.add('open');
}
}
setValue(value, fireChange = false) {
this._select.value = value;
this._syncTrigger();
if (fireChange) {
this._select.dispatchEvent(new Event('change', { bubbles: true }));
if (this._onChange) this._onChange(value);
}
}
updateItems(items) {
this._items = items;
this._popup.innerHTML = this._buildGrid();
this._bindCells();
this._syncTrigger();
}
destroy() {
this._trigger.remove();
this._popup.remove();
this._select.style.display = '';
}
}
+31
View File
@@ -0,0 +1,31 @@
// ============================================================
// SVG icon library for icon-select grids
// Simple inline SVGs (24x24 viewBox, fill="currentColor")
// ============================================================
const _svg = (path) =>
`<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">${path}</svg>`;
// Parameter types
export const paramTypeIcons = {
string: _svg('<path d="M3 7V5h18v2H3zm0 12v-2h12v2H3zm0-6v-2h18v2H3z"/>'),
integer: _svg('<path d="M4 17V7h2v4h3V7h2v10h-2v-4H6v4H4zm10-1h2v1h2v-4h-2v1h-2V9h6v8h-6v-1z"/>'),
float: _svg('<path d="M5 17V7h2v4h3V7h2v10H9v-4H7v4H5zm9.5 0v-2a1 1 0 1 1 0-2h1v-2h-1a3 3 0 0 0 0 6h1v2h-1zm3-6v2h1a1 1 0 1 1 0 2h-1v2h1a3 3 0 0 0 0-6h-1z"/>'),
boolean: _svg('<path d="M17 7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h10c2.76 0 5-2.24 5-5s-2.24-5-5-5zm0 8c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3z"/>'),
select: _svg('<path d="M3 5h18v2H3V5zm4 6h10v2H7v-2zm-4 6h18v2H3v-2z"/><path d="M7 7l5 5 5-5"/>'),
};
// Callback events
export const callbackEventIcons = {
on_play: _svg('<path d="M8 5v14l11-7z"/>'),
on_pause: _svg('<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>'),
on_stop: _svg('<path d="M6 6h12v12H6z"/>'),
on_next: _svg('<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>'),
on_previous: _svg('<path d="M6 6h2v12H6V6zm3.5 6l8.5 6V6l-8.5 6z"/>'),
on_volume: _svg('<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 8.5v7a4.5 4.5 0 0 0 2.5-3.5zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>'),
on_mute: _svg('<path d="M16.5 12A4.5 4.5 0 0 0 14 8.5v2.09l2.41 2.41c.06-.31.09-.65.09-1zM19 12c0 .94-.2 1.82-.54 2.64l1.51 1.51A8.796 8.796 0 0 0 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06a8.99 8.99 0 0 0 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>'),
on_seek: _svg('<path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>'),
on_turn_on: _svg('<path d="M13 3h-2v10h2V3zm4.83 2.17l-1.42 1.42A6.92 6.92 0 0 1 19 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.96 8.96 0 0 0 3 12c0 4.97 4.03 9 9 9s9-4.03 9-9a8.96 8.96 0 0 0-3.17-6.83z"/>'),
on_turn_off: _svg('<path d="M13 3h-2v10h2V3zm4.83 2.17l-1.42 1.42A6.92 6.92 0 0 1 19 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.96 8.96 0 0 0 3 12c0 4.97 4.03 9 9 9s9-4.03 9-9a8.96 8.96 0 0 0-3.17-6.83z"/>'),
on_toggle: _svg('<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>'),
};
+19
View File
@@ -13,6 +13,7 @@ import {
} from './core.js';
import { updateBackgroundColors } from './background.js';
import { loadDisplayMonitors } from './links.js';
import { IconSelect } from './icon-select.js';
// Tab management
export let activeTab = 'player';
@@ -422,6 +423,8 @@ function renderVisualizerFrame() {
}
// Audio device selection
let _audioDeviceIconSelect = null;
export async function loadAudioDevices() {
const section = document.getElementById('audioDeviceSection');
const select = document.getElementById('audioDeviceSelect');
@@ -466,6 +469,22 @@ export async function loadAudioDevices() {
}
}
// Enhance with icon grid
const audioSvg = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 8.5v7a4.5 4.5 0 0 0 2.5-3.5z"/></svg>';
const items = [
{ value: '', icon: audioSvg, label: t('settings.audio.auto') },
...devices.map(dev => ({ value: dev.name, icon: audioSvg, label: dev.name })),
];
if (_audioDeviceIconSelect) _audioDeviceIconSelect.destroy();
_audioDeviceIconSelect = new IconSelect({
target: select,
items,
columns: 1,
horizontal: true,
onChange: () => onAudioDeviceChanged(),
});
_audioDeviceIconSelect.setValue(select.value, false);
updateAudioDeviceStatus(status);
} catch (e) {
section.style.display = 'none';
+347 -10
View File
@@ -8,6 +8,8 @@ import {
scripts, setScripts,
getAuthHeaders, hasCredentials,
} from './core.js';
import { IconSelect } from './icon-select.js';
import { paramTypeIcons } from './icons.js';
export let scriptFormDirty = false;
export function setScriptFormDirty(value) { scriptFormDirty = value; }
@@ -119,28 +121,219 @@ export async function displayQuickAccess() {
resolveMdiIcons(grid);
}
async function executeScript(scriptName, buttonElement) {
buttonElement.classList.add('executing');
function _getScriptParams(scriptName) {
const script = scripts.find(s => s.name === scriptName);
return (script && script.parameters) ? script.parameters : {};
}
async function executeScript(scriptName, buttonElement) {
const paramDefs = _getScriptParams(scriptName);
if (Object.keys(paramDefs).length > 0) {
_showParamsInputDialog(scriptName, paramDefs, async (params) => {
buttonElement.classList.add('executing');
try {
await _doExecuteScript(scriptName, params);
} finally {
buttonElement.classList.remove('executing');
}
});
return;
}
buttonElement.classList.add('executing');
try {
await _doExecuteScript(scriptName, {});
} finally {
buttonElement.classList.remove('executing');
}
}
async function _doExecuteScript(scriptName, params) {
try {
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ args: [] })
body: JSON.stringify({ params })
});
const result = await response.json();
if (response.ok && result.success) {
showToast(`${scriptName} executed successfully`, 'success');
showToast(t('scripts.msg.executed', { name: scriptName }), 'success');
} else {
showToast(`Failed to execute ${scriptName}`, 'error');
showToast(result.detail || t('scripts.msg.execute_failed', { name: scriptName }), 'error');
}
} catch (error) {
console.error(`Error executing script ${scriptName}:`, error);
showToast(`Error executing ${scriptName}`, 'error');
} finally {
buttonElement.classList.remove('executing');
showToast(t('scripts.msg.execute_error', { name: scriptName }), 'error');
}
}
// ============================================================
// Script Parameters Input Dialog (execution-time)
// ============================================================
let _paramsCallback = null;
let _paramsScriptName = null;
let _paramsIconSelects = null;
function _showParamsInputDialog(scriptName, paramDefs, callback) {
_paramsCallback = callback;
_paramsScriptName = scriptName;
const dialog = document.getElementById('scriptParamsDialog');
const title = document.getElementById('scriptParamsDialogTitle');
const container = document.getElementById('scriptParamsInputs');
const script = scripts.find(s => s.name === scriptName);
title.textContent = script ? (script.label || scriptName) : scriptName;
container.innerHTML = '';
// Track IconSelect instances for cleanup
if (!_paramsIconSelects) _paramsIconSelects = [];
for (const [pname, pdef] of Object.entries(paramDefs)) {
const wrapper = document.createElement('label');
const labelText = document.createElement('span');
labelText.textContent = pname + (pdef.required ? ' *' : '');
wrapper.appendChild(labelText);
if (pdef.description) {
const hint = document.createElement('small');
hint.className = 'param-hint';
hint.textContent = pdef.description;
wrapper.appendChild(hint);
}
let input;
if (pdef.type === 'select' && pdef.options) {
input = document.createElement('select');
if (!pdef.required) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = '—';
input.appendChild(opt);
}
for (const optVal of pdef.options) {
const opt = document.createElement('option');
opt.value = optVal;
opt.textContent = optVal;
if (pdef.default !== undefined && pdef.default !== null && String(pdef.default) === optVal) {
opt.selected = true;
}
input.appendChild(opt);
}
input.dataset.paramName = pname;
input.dataset.paramType = pdef.type;
if (pdef.required) input.required = true;
wrapper.appendChild(input);
// Enhance with icon grid if few options
if (pdef.options.length <= 10) {
const selItems = pdef.options.map(o => ({ value: o, icon: '', label: o }));
const cols = Math.min(pdef.options.length, 4);
const isel = new IconSelect({ target: input, items: selItems, columns: cols });
_paramsIconSelects.push(isel);
}
} else if (pdef.type === 'boolean') {
const boolSvgTrue = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>';
const boolSvgFalse = '<svg viewBox="0 0 24 24" fill="currentColor"><path 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>';
input = document.createElement('select');
const optTrue = document.createElement('option');
optTrue.value = 'true';
optTrue.textContent = 'true';
const optFalse = document.createElement('option');
optFalse.value = 'false';
optFalse.textContent = 'false';
input.appendChild(optTrue);
input.appendChild(optFalse);
if (pdef.default !== undefined && pdef.default !== null) {
input.value = String(pdef.default);
}
input.dataset.paramName = pname;
input.dataset.paramType = pdef.type;
if (pdef.required) input.required = true;
wrapper.appendChild(input);
// Enhance with icon grid
const isel = new IconSelect({
target: input,
items: [
{ value: 'true', icon: boolSvgTrue, label: 'True' },
{ value: 'false', icon: boolSvgFalse, label: 'False' },
],
columns: 2,
});
_paramsIconSelects.push(isel);
} else if (pdef.type === 'integer' || pdef.type === 'float') {
input = document.createElement('input');
input.type = 'number';
if (pdef.type === 'float') input.step = 'any';
if (pdef.min !== undefined && pdef.min !== null) input.min = pdef.min;
if (pdef.max !== undefined && pdef.max !== null) input.max = pdef.max;
if (pdef.default !== undefined && pdef.default !== null) input.value = pdef.default;
input.dataset.paramName = pname;
input.dataset.paramType = pdef.type;
if (pdef.required) input.required = true;
wrapper.appendChild(input);
} else {
input = document.createElement('input');
input.type = 'text';
if (pdef.default !== undefined && pdef.default !== null) input.value = pdef.default;
input.dataset.paramName = pname;
input.dataset.paramType = pdef.type;
if (pdef.required) input.required = true;
wrapper.appendChild(input);
}
container.appendChild(wrapper);
}
document.body.classList.add('dialog-open');
dialog.showModal();
}
export function closeScriptParamsDialog() {
const dialog = document.getElementById('scriptParamsDialog');
_paramsCallback = null;
_paramsScriptName = null;
// Destroy icon selects from execution dialog
if (_paramsIconSelects) {
_paramsIconSelects.forEach(isel => isel.destroy());
_paramsIconSelects = null;
}
closeDialog(dialog);
document.body.classList.remove('dialog-open');
}
export async function submitScriptWithParams(event) {
event.preventDefault();
const container = document.getElementById('scriptParamsInputs');
const inputs = container.querySelectorAll('[data-param-name]');
const params = {};
for (const input of inputs) {
const name = input.dataset.paramName;
const type = input.dataset.paramType;
let val = input.value;
if (val === '' && !input.required) continue;
if (val === '') continue;
if (type === 'integer') val = parseInt(val, 10);
else if (type === 'float') val = parseFloat(val);
else if (type === 'boolean') val = val === 'true';
params[name] = val;
}
const callback = _paramsCallback;
closeScriptParamsDialog();
if (callback) {
await callback(params);
}
}
@@ -214,6 +407,7 @@ export function showAddScriptDialog() {
document.getElementById('scriptIsEdit').value = 'false';
document.getElementById('scriptName').disabled = false;
document.getElementById('scriptIconPreview').innerHTML = '';
document.getElementById('scriptParamsContainer').innerHTML = '';
title.textContent = t('scripts.dialog.add');
scriptFormDirty = false;
@@ -260,6 +454,15 @@ export async function showEditScriptDialog(scriptName) {
preview.innerHTML = '';
}
// Populate parameters
const paramsContainer = document.getElementById('scriptParamsContainer');
paramsContainer.innerHTML = '';
if (script.parameters) {
for (const [pname, pdef] of Object.entries(script.parameters)) {
_addParameterRowWithData(pname, pdef);
}
}
title.textContent = t('scripts.dialog.edit');
scriptFormDirty = false;
@@ -301,7 +504,8 @@ export async function saveScript(event) {
description: document.getElementById('scriptDescription').value || '',
icon: document.getElementById('scriptIcon').value || null,
timeout: parseInt(document.getElementById('scriptTimeout').value) || 30,
shell: true
shell: true,
parameters: _collectParameterDefinitions(),
};
const endpoint = isEdit ?
@@ -425,6 +629,15 @@ function showExecutionResult(name, result, type = 'script') {
}
export async function executeScriptDebug(scriptName) {
const paramDefs = _getScriptParams(scriptName);
if (Object.keys(paramDefs).length > 0) {
_showParamsInputDialog(scriptName, paramDefs, (params) => _executeScriptDebugWithParams(scriptName, params));
return;
}
await _executeScriptDebugWithParams(scriptName, {});
}
async function _executeScriptDebugWithParams(scriptName, params) {
const dialog = document.getElementById('executionDialog');
const title = document.getElementById('executionDialogTitle');
const statusDiv = document.getElementById('executionStatus');
@@ -445,7 +658,7 @@ export async function executeScriptDebug(scriptName) {
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ args: [] })
body: JSON.stringify({ params })
});
const result = await response.json();
@@ -471,6 +684,130 @@ export async function executeScriptDebug(scriptName) {
}
}
// ============================================================
// Parameter Definition Editor (CRUD dialog)
// ============================================================
const PARAM_TYPES = ['string', 'integer', 'float', 'boolean', 'select'];
export function addParameterRow() {
_addParameterRowWithData('', {});
scriptFormDirty = true;
}
const _paramTypeItems = PARAM_TYPES.map(pt => ({
value: pt,
icon: paramTypeIcons[pt] || '',
label: pt.charAt(0).toUpperCase() + pt.slice(1),
}));
function _addParameterRowWithData(name, def) {
const container = document.getElementById('scriptParamsContainer');
const row = document.createElement('div');
row.className = 'param-row';
row.innerHTML = `
<div class="param-row-header">
<input type="text" class="param-name" value="${escapeHtml(name)}"
placeholder="${t('scripts.params.name_placeholder')}" pattern="[a-zA-Z][a-zA-Z0-9_]*">
<select class="param-type">
${PARAM_TYPES.map(pt => `<option value="${pt}" ${def.type === pt ? 'selected' : ''}>${pt}</option>`).join('')}
</select>
<label class="param-required-label" title="${t('scripts.params.required')}">
<input type="checkbox" class="param-required" ${def.required ? 'checked' : ''}>
<span>*</span>
</label>
<button type="button" class="param-remove-btn" title="${t('scripts.params.remove')}">&times;</button>
</div>
<div class="param-row-details">
<input type="text" class="param-description" value="${escapeHtml(def.description || '')}"
placeholder="${t('scripts.params.description_placeholder')}">
<div class="param-row-extra">
<input type="text" class="param-default" value="${def.default !== undefined && def.default !== null ? escapeHtml(String(def.default)) : ''}"
placeholder="${t('scripts.params.default_placeholder')}">
<input type="text" class="param-min" value="${def.min !== undefined && def.min !== null ? def.min : ''}"
placeholder="Min" style="display:${def.type === 'integer' || def.type === 'float' ? '' : 'none'}">
<input type="text" class="param-max" value="${def.max !== undefined && def.max !== null ? def.max : ''}"
placeholder="Max" style="display:${def.type === 'integer' || def.type === 'float' ? '' : 'none'}">
<input type="text" class="param-options" value="${def.options ? def.options.join(', ') : ''}"
placeholder="${t('scripts.params.options_placeholder')}" style="display:${def.type === 'select' ? '' : 'none'}">
</div>
</div>
`;
// Enhance the type <select> with icon grid
const typeSelect = row.querySelector('.param-type');
const iconSelect = new IconSelect({
target: typeSelect,
items: _paramTypeItems,
columns: 5,
onChange: () => {
const isNumeric = typeSelect.value === 'integer' || typeSelect.value === 'float';
const isSelect = typeSelect.value === 'select';
row.querySelector('.param-min').style.display = isNumeric ? '' : 'none';
row.querySelector('.param-max').style.display = isNumeric ? '' : 'none';
row.querySelector('.param-options').style.display = isSelect ? '' : 'none';
scriptFormDirty = true;
},
});
row.querySelector('.param-remove-btn').addEventListener('click', () => {
iconSelect.destroy();
row.remove();
scriptFormDirty = true;
});
// Mark dirty on any input change
row.querySelectorAll('input').forEach(el => {
el.addEventListener('input', () => { scriptFormDirty = true; });
});
container.appendChild(row);
}
function _collectParameterDefinitions() {
const container = document.getElementById('scriptParamsContainer');
const rows = container.querySelectorAll('.param-row');
const params = {};
for (const row of rows) {
const name = row.querySelector('.param-name').value.trim();
if (!name) continue;
const type = row.querySelector('.param-type').value;
const def = { type };
const description = row.querySelector('.param-description').value.trim();
if (description) def.description = description;
if (row.querySelector('.param-required').checked) def.required = true;
const defaultVal = row.querySelector('.param-default').value.trim();
if (defaultVal !== '') {
if (type === 'integer') def.default = parseInt(defaultVal, 10);
else if (type === 'float') def.default = parseFloat(defaultVal);
else if (type === 'boolean') def.default = defaultVal.toLowerCase() === 'true';
else def.default = defaultVal;
}
if (type === 'integer' || type === 'float') {
const minVal = row.querySelector('.param-min').value.trim();
const maxVal = row.querySelector('.param-max').value.trim();
if (minVal !== '') def.min = parseFloat(minVal);
if (maxVal !== '') def.max = parseFloat(maxVal);
}
if (type === 'select') {
const optStr = row.querySelector('.param-options').value.trim();
if (optStr) def.options = optStr.split(',').map(s => s.trim()).filter(Boolean);
}
params[name] = def;
}
return params;
}
export async function executeCallbackDebug(callbackName) {
const dialog = document.getElementById('executionDialog');
const title = document.getElementById('executionDialogTitle');
+3 -1
View File
@@ -6,7 +6,7 @@ import {
dom, t, showToast, setWs,
WS_BACKOFF_BASE_MS, WS_BACKOFF_MAX_MS,
WS_MAX_RECONNECT_ATTEMPTS, WS_PING_INTERVAL_MS,
authRequired,
authRequired, showUpdateBanner,
} from './core.js';
import { updateUI, visualizerEnabled, visualizerAvailable, setFrequencyData, stopPositionInterpolation, loadAudioDevices } from './player.js';
import { loadScripts, loadScriptsTable, displayQuickAccess } from './scripts.js';
@@ -100,6 +100,8 @@ export function connectWebSocket(token) {
loadHeaderLinks();
loadLinksTable();
displayQuickAccess();
} else if (msg.type === 'update_available') {
showUpdateBanner(msg.data);
} else if (msg.type === 'audio_data') {
setFrequencyData(msg.data);
} else if (msg.type === 'error') {
+12 -1
View File
@@ -74,6 +74,15 @@
"scripts.execution.error_output": "Error Output",
"scripts.execution.close": "Close",
"scripts.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?",
"scripts.field.parameters": "Parameters",
"scripts.params.add": "+ Add",
"scripts.params.remove": "Remove parameter",
"scripts.params.required": "Required",
"scripts.params.name_placeholder": "param_name",
"scripts.params.description_placeholder": "Parameter description",
"scripts.params.default_placeholder": "Default",
"scripts.params.options_placeholder": "option1, option2, ...",
"scripts.params.execute": "Execute",
"callbacks.management": "Callback Management",
"callbacks.description": "Callbacks are scripts triggered automatically by media control events (play, pause, stop, etc.)",
"callbacks.add": "Add",
@@ -215,5 +224,7 @@
"links.confirm.delete": "Are you sure you want to delete the link \"{name}\"?",
"links.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?",
"footer.created_by": "Created by",
"footer.source_code": "Source Code"
"footer.source_code": "Source Code",
"update.available": "Update available: v{version}",
"update.view_release": "View Release"
}
+12 -1
View File
@@ -74,6 +74,15 @@
"scripts.execution.error_output": "Вывод ошибок",
"scripts.execution.close": "Закрыть",
"scripts.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
"scripts.field.parameters": "Параметры",
"scripts.params.add": "+ Добавить",
"scripts.params.remove": "Удалить параметр",
"scripts.params.required": "Обязательный",
"scripts.params.name_placeholder": "имя_параметра",
"scripts.params.description_placeholder": "Описание параметра",
"scripts.params.default_placeholder": "По умолчанию",
"scripts.params.options_placeholder": "вариант1, вариант2, ...",
"scripts.params.execute": "Выполнить",
"callbacks.management": "Управление Обратными Вызовами",
"callbacks.description": "Обратные вызовы - это скрипты, автоматически запускаемые при событиях управления медиа (воспроизведение, пауза, остановка и т.д.)",
"callbacks.add": "Добавить",
@@ -215,5 +224,7 @@
"links.confirm.delete": "Вы уверены, что хотите удалить ссылку \"{name}\"?",
"links.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
"footer.created_by": "Создано",
"footer.source_code": "Исходный код"
"footer.source_code": "Исходный код",
"update.available": "Доступно обновление: v{version}",
"update.view_release": "Перейти к релизу"
}
+85 -39
View File
@@ -1,11 +1,11 @@
"""System tray icon for Media Server."""
import ctypes
import io
import logging
import os
import signal
import threading
import webbrowser
from pathlib import Path
from typing import Callable
from PIL import Image, ImageDraw
@@ -14,8 +14,27 @@ logger = logging.getLogger(__name__)
# pystray is optional — tray silently disabled when missing
try:
import pystray
PYSTRAY_AVAILABLE = True
except ImportError:
pystray = None
PYSTRAY_AVAILABLE = False
# Windows-native confirmation (no tkinter needed)
_MB_YESNO = 0x04
_MB_ICONQUESTION = 0x20
_MB_TOPMOST = 0x40000
_MB_SETFOREGROUND = 0x10000
_IDYES = 6
def _confirm(title: str, message: str) -> bool:
"""Show a Yes/No dialog using native Windows MessageBox."""
result = ctypes.windll.user32.MessageBoxW(
0, message, title, _MB_YESNO | _MB_ICONQUESTION | _MB_TOPMOST | _MB_SETFOREGROUND
)
return result == _IDYES
def _create_icon_image(size: int = 64) -> Image.Image:
@@ -44,15 +63,24 @@ def _create_icon_image(size: int = 64) -> Image.Image:
def _load_icon_image() -> Image.Image:
"""Load the SVG app icon, falling back to a generated image."""
"""Load the ICO/SVG app icon, falling back to a generated image."""
icons_dir = Path(__file__).parent / "static" / "icons"
# Try .ico first (best for Windows tray)
ico_path = icons_dir / "icon.ico"
if ico_path.exists():
try:
return Image.open(ico_path)
except Exception:
pass
# Try SVG via cairosvg
try:
import cairosvg
svg_path = os.path.join(
os.path.dirname(__file__), "static", "icons", "icon.svg"
)
if os.path.exists(svg_path):
png_data = cairosvg.svg2png(url=svg_path, output_width=64, output_height=64)
svg_path = icons_dir / "icon.svg"
if svg_path.exists():
png_data = cairosvg.svg2png(url=str(svg_path), output_width=64, output_height=64)
return Image.open(io.BytesIO(png_data))
except Exception:
pass
@@ -60,42 +88,60 @@ def _load_icon_image() -> Image.Image:
return _create_icon_image()
def start_tray(host: str, port: int) -> "pystray.Icon | None":
"""Start system tray icon in a background thread.
class TrayManager:
"""Manages the system tray icon and its context menu.
Returns the Icon instance (call icon.stop() to remove), or None if
pystray is not installed.
Call ``run()`` on the **main thread** — it blocks until ``stop()``
is called (from any thread) or the user picks *Shutdown* from the menu.
"""
if pystray is None:
logger.info("pystray not installed — tray icon disabled")
return None
url = f"http://{'localhost' if host == '0.0.0.0' else host}:{port}"
def __init__(self, port: int, on_exit: Callable[[], None]) -> None:
if not PYSTRAY_AVAILABLE:
raise ImportError("pystray is required for system tray support")
def on_show_ui(_icon, _item):
webbrowser.open(url)
self._port = port
self._on_exit = on_exit
def on_exit(_icon, _item):
logger.info("Exit requested from tray")
_icon.stop()
# Signal the main process to shut down gracefully
os.kill(os.getpid(), signal.SIGINT)
menu = pystray.Menu(
pystray.MenuItem("Show UI", self._show_ui, default=True),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Restart", self._restart),
pystray.MenuItem("Shutdown", self._shutdown),
)
menu = pystray.Menu(
pystray.MenuItem("Show UI", on_show_ui, default=True),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Exit", on_exit),
)
self._icon = pystray.Icon(
name="media-server",
icon=_load_icon_image(),
title="Media Server",
menu=menu,
)
icon = pystray.Icon(
name="media-server",
icon=_load_icon_image(),
title="Media Server",
menu=menu,
)
def _show_ui(self, icon: "pystray.Icon", item: "pystray.MenuItem") -> None:
webbrowser.open(f"http://localhost:{self._port}")
thread = threading.Thread(target=icon.run, daemon=True)
thread.start()
logger.info("System tray icon started")
def _restart(self, icon: "pystray.Icon", item: "pystray.MenuItem") -> None:
if not _confirm("Media Server", "Restart the server?"):
return
logger.info("Restart requested from tray")
self._restart_requested = True
self._on_exit()
self._icon.stop()
return icon
@property
def restart_requested(self) -> bool:
return getattr(self, "_restart_requested", False)
def _shutdown(self, icon: "pystray.Icon", item: "pystray.MenuItem") -> None:
if not _confirm("Media Server", "Shut down the server?"):
return
logger.info("Shutdown requested from tray")
self._on_exit()
self._icon.stop()
def run(self) -> None:
"""Block the calling thread running the tray message loop."""
self._icon.run()
def stop(self) -> None:
"""Stop the tray icon from any thread."""
self._icon.stop()
+3 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "media-server"
version = "1.0.0"
version = "0.1.0"
description = "REST API server for controlling system-wide media playback"
readme = "README.md"
license = { text = "MIT" }
@@ -32,6 +32,7 @@ dependencies = [
"pyyaml>=6.0",
"mutagen>=1.47.0",
"pillow>=10.0.0",
"packaging>=23.0",
]
[project.optional-dependencies]
@@ -46,7 +47,7 @@ windows = [
]
visualizer = [
"soundcard>=0.4.0",
"numpy>=1.24.0",
"numpy>=1.24.0,<2.0",
]
dev = [
"pytest>=7.0",