Compare commits

...

29 Commits

Author SHA1 Message Date
alexei.dolgolyov 2961f8eaec chore: release v0.1.2
Release / create-release (push) Successful in 4s
Lint & Test / test (push) Successful in 11s
Release / build-linux (push) Successful in 29s
Release / build-windows (push) Successful in 1m11s
2026-03-29 20:00:38 +03:00
alexei.dolgolyov c50a8f472c fix: make folder status visible with dot + text label
Lint & Test / test (push) Successful in 10s
Status dot was 8x8px with no text, nearly invisible in the table.
Now renders as a colored dot with an adjacent text label
(Available / Unavailable).
2026-03-29 15:07:46 +03:00
alexei.dolgolyov cad6e8a1fe feat: redesign media browser UI
Lint & Test / test (push) Successful in 9s
- Root folder cards with hero-style layout and SVG icons
- Full-width thumbnails with aspect-ratio grid items
- List view column headers (Name, Bitrate, Duration, Size)
- Modernized breadcrumb with pill segments and overflow handling
- Proper skeleton shimmer replacing emoji hourglass on thumbnails
- Pagination shows "Showing X-Y of Z" item count
- Refined hover effects, animations, and visual hierarchy
- Download button revealed on row hover in list view
- Type badges hidden by default, shown on hover
- Localized new keys in en.json and ru.json
2026-03-29 14:59:43 +03:00
alexei.dolgolyov c9ee41ad35 feat: add media folder management from WebUI
Lint & Test / test (push) Successful in 10s
- Add media_folders_management config flag (enabled by default)
- Guard folder CRUD endpoints with 403 when management disabled
- Wire up frontend folder add/edit/delete in Settings tab
- Add per-folder availability check (for network shares)
- Show unavailable badge on offline folders in browser view
- Expose management flag via /api/health endpoint
- Add EN/RU locale keys for folder management UI
2026-03-29 14:44:03 +03:00
alexei.dolgolyov 0256be816e chore: update release notes for v0.1.1
Release / create-release (push) Successful in 3s
Lint & Test / test (push) Successful in 9s
Release / build-linux (push) Successful in 32s
Release / build-windows (push) Successful in 1m10s
2026-03-28 18:53:13 +03:00
alexei.dolgolyov 5219263388 fix: port-in-use check and remove packaging dependency 2026-03-28 18:52:46 +03:00
alexei.dolgolyov 98163ea5a9 chore: update release notes and version for v0.1.1
Release / create-release (push) Successful in 5s
Lint & Test / test (push) Successful in 11s
Release / build-linux (push) Successful in 33s
Release / build-windows (push) Successful in 1m11s
2026-03-28 18:37:56 +03:00
alexei.dolgolyov 5e5e5036c0 fix: use custom icon for Windows shortcuts instead of python.exe
Lint & Test / test (push) Successful in 10s
2026-03-28 18:36:53 +03:00
alexei.dolgolyov 4f9e99e10b ci: add manual build workflow for testing artifacts
Lint & Test / test (push) Failing after 11m21s
workflow_dispatch-triggered build.yml that produces Windows
installer/portable and Linux tarball as CI artifacts without
creating a release. Trigger from Gitea UI → Actions → Run.
2026-03-27 23:44:21 +03:00
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
alexei.dolgolyov 26b5f74c24 feat: improve installer with custom icon, launch-after-install, and running-instance detection
Lint & Test / test (push) Successful in 9s
- Use custom icon.ico for installer/uninstaller UI
- LaunchApp opens server then browser after install
- .onInit detects running instance and offers to stop it
- Use WMIC-based process kill targeting embedded Python path
- start-hidden.vbs prefers embedded Python over system Python
- Add pystray dependency to build script
- CLAUDE.md: note to consult CI/CD guide for build changes
2026-03-24 12:48:31 +03:00
alexei.dolgolyov 1f6e4f6d55 feat: add Launch option to installer finish page
Lint & Test / test (push) Successful in 9s
2026-03-23 14:05:57 +03:00
alexei.dolgolyov 6500d6f615 feat: add system tray icon with Show UI and Exit actions
Lint & Test / test (push) Successful in 9s
Adds pystray-based tray icon (green play button) that runs alongside
uvicorn. Double-click opens the web UI in the browser, Exit triggers
graceful shutdown. Disabled with --no-tray flag for headless/service mode.
2026-03-23 14:05:13 +03:00
39 changed files with 2948 additions and 305 deletions
+72
View File
@@ -0,0 +1,72 @@
name: Build Artifacts
on:
workflow_dispatch:
inputs:
version:
description: 'Version label (e.g. dev, 0.3.0-test)'
required: false
default: 'dev'
jobs:
build-windows:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build frontend
run: npm ci && npm run build
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install build tools
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends nsis zip
- name: Build Windows distribution
run: |
chmod +x build-dist-windows.sh
./build-dist-windows.sh "v${{ inputs.version }}"
- name: Build NSIS installer
run: makensis -DVERSION="${{ inputs.version }}" installer.nsi
- uses: actions/upload-artifact@v3
with:
name: MediaServer-${{ inputs.version }}-win-x64
path: |
build/MediaServer-*.zip
build/MediaServer-*-setup.exe
retention-days: 90
build-linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build frontend
run: npm ci && npm run build
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Build Linux distribution
run: |
chmod +x build-dist-linux.sh
./build-dist-linux.sh "v${{ inputs.version }}"
- uses: actions/upload-artifact@v3
with:
name: MediaServer-${{ inputs.version }}-linux-x64
path: build/MediaServer-*-linux-x64.tar.gz
retention-days: 90
+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"
+22 -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.
@@ -166,6 +181,8 @@ Uninstall preserves `config.yaml` (user data).
Reference: [gitea-python-ci-cd.md](https://git.dolgolyov-family.by/alexei.dolgolyov/claude-code-facts/src/branch/main/gitea-python-ci-cd.md)
**IMPORTANT:** When modifying CI/CD workflows, `installer.nsi`, or build scripts (`build-dist-*.sh`), always fetch and consult the guide above first to ensure changes stay in sync with established patterns.
### Before Pushing
Ensure CI will pass locally:
+21
View File
@@ -0,0 +1,21 @@
## v0.1.2 (2026-03-29)
### Features
- Redesign media browser UI ([cad6e8a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/cad6e8a))
- Add media folder management from WebUI ([c9ee41a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/c9ee41a))
### Bug Fixes
- Make folder status visible with dot + text label ([c50a8f4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/c50a8f4))
---
<details>
<summary>All Commits</summary>
| Hash | Message | Author |
|------|---------|--------|
| [c50a8f4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/c50a8f4) | fix: make folder status visible with dot + text label | alexei.dolgolyov |
| [cad6e8a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/cad6e8a) | feat: redesign media browser UI | alexei.dolgolyov |
| [c9ee41a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/c9ee41a) | feat: add media folder management from WebUI | 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}..."
@@ -68,22 +53,30 @@ WIN_DEPS=(
"pycaw>=20230407"
"screen-brightness-control>=0.20.0"
"monitorcontrol>=3.0.0"
"pystray>=0.19.0"
)
# 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
@@ -92,43 +85,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
+10
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,9 +52,15 @@ scripts:
command: "shutdown /r /t 0"
label: "Restart"
description: "Restart the PC immediately"
icon: "mdi:restart"
timeout: 10
shell: true
# Media folder management from Web UI (default: true)
# When enabled, media folders can be added, edited, and deleted from the Settings tab.
# Set to false to disable folder management from the UI.
# media_folders_management: false
# Callback scripts (executed after media actions)
# All callbacks are optional - if not defined, the action runs without callback
callbacks:
+43 -11
View File
@@ -18,10 +18,12 @@ InstallDir "$LOCALAPPDATA\${APPNAME}"
RequestExecutionLevel user
; --- UI ---
; To use a custom icon, convert icon.svg to icon.ico and uncomment:
; !define MUI_ICON "media_server\static\icons\icon.ico"
; !define MUI_UNICON "media_server\static\icons\icon.ico"
!define MUI_ICON "media_server\static\icons\icon.ico"
!define MUI_UNICON "media_server\static\icons\icon.ico"
!define MUI_ABORTWARNING
!define MUI_FINISHPAGE_RUN ""
!define MUI_FINISHPAGE_RUN_TEXT "Launch ${APPNAME}"
!define MUI_FINISHPAGE_RUN_FUNCTION LaunchApp
!insertmacro MUI_PAGE_WELCOME
!insertmacro MUI_PAGE_DIRECTORY
@@ -34,18 +36,47 @@ RequestExecutionLevel user
!insertmacro MUI_LANGUAGE "English"
; --- Functions ---
Function LaunchApp
ExecShell "open" "wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"'
; Give the server a moment to start, then open the UI in the default browser
Sleep 2000
ExecShell "open" "http://localhost:8765/"
FunctionEnd
Function .onInit
; Check if server is running by trying to open its Python executable exclusively
IfFileExists "$INSTDIR\python\python.exe" 0 done
ClearErrors
FileOpen $0 "$INSTDIR\python\python.exe" a
IfErrors locked
; File opened fine — server is not running
FileClose $0
Goto done
locked:
MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION \
"${APPNAME} is currently running.$\n$\nYes = Stop the server and continue$\nNo = Continue without stopping (may cause errors)$\nCancel = Abort installation" \
IDYES kill IDNO done
Abort
kill:
nsExec::ExecToLog 'wmic process where "ExecutablePath like $\'%Media Server%python%$\'" call terminate'
Sleep 2000
done:
FunctionEnd
; --- Sections ---
Section "!Core (required)" SecCore
SectionIn RO
; Stop running instance if any
nsExec::ExecToLog 'taskkill /F /IM python.exe /FI "WINDOWTITLE eq media_server*"'
SetOutPath "$INSTDIR"
; 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"
@@ -53,10 +84,10 @@ Section "!Core (required)" SecCore
CreateDirectory "$SMPROGRAMS\${APPNAME}"
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
"$INSTDIR\python\python.exe" 0
"$INSTDIR\app\media_server\static\icons\icon.ico" 0
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME} (Console).lnk" \
"$INSTDIR\${EXENAME}" "" \
"$INSTDIR\python\python.exe" 0
"$INSTDIR\app\media_server\static\icons\icon.ico" 0
CreateShortcut "$SMPROGRAMS\${APPNAME}\Uninstall.lnk" \
"$INSTDIR\uninstall.exe"
@@ -86,14 +117,14 @@ SectionEnd
Section "Desktop shortcut" SecDesktop
CreateShortcut "$DESKTOP\${APPNAME}.lnk" \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
"$INSTDIR\python\python.exe" 0
"$INSTDIR\app\media_server\static\icons\icon.ico" 0
SectionEnd
Section "Start with Windows" SecAutostart
; Create Startup folder shortcut (runs hidden via VBS)
CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" \
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
"$INSTDIR\python\python.exe" 0
"$INSTDIR\app\media_server\static\icons\icon.ico" 0
SectionEnd
; --- Section descriptions ---
@@ -109,7 +140,8 @@ SectionEnd
; --- Uninstaller ---
Section "Uninstall"
; Stop running instance
nsExec::ExecToLog 'taskkill /F /IM python.exe /FI "WINDOWTITLE eq media_server*"'
nsExec::ExecToLog 'wmic process where "ExecutablePath like $\'%Media Server%python%$\'" call terminate'
nsExec::ExecToLog 'taskkill /F /IM media-server.exe'
; Remove application files
RMDir /r "$INSTDIR\python"
+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()
+38
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):
@@ -101,6 +124,10 @@ class Settings(BaseSettings):
default_factory=dict,
description="Media folders available for browsing in the media browser",
)
media_folders_management: bool = Field(
default=True,
description="Allow adding, editing, and deleting media folders from the Web UI",
)
# Thumbnail settings
thumbnail_size: str = Field(
@@ -136,6 +163,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()
+93 -6
View File
@@ -2,6 +2,7 @@
import argparse
import logging
import socket
import sys
from contextlib import asynccontextmanager
from pathlib import Path
@@ -51,6 +52,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 +75,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 +105,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:
@@ -216,6 +236,11 @@ def main():
action="store_true",
help="Show the current API token and exit",
)
parser.add_argument(
"--no-tray",
action="store_true",
help="Disable system tray icon (for headless/service mode)",
)
args = parser.parse_args()
@@ -235,12 +260,74 @@ def main():
print("\nAuthentication is DISABLED (no tokens configured)")
return
uvicorn.run(
"media_server.main:app",
host=args.host,
port=args.port,
reload=False,
)
# Check if port is available before starting
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
try:
sock.bind((args.host if args.host != "0.0.0.0" else "127.0.0.1", args.port))
except OSError:
print(
f"ERROR: Port {args.port} is already in use. "
f"Another instance of Media Server may be running.\n"
f"Stop the other process or use --port to pick a different port.",
file=sys.stderr,
)
sys.exit(1)
from .tray import PYSTRAY_AVAILABLE, TrayManager
use_tray = PYSTRAY_AVAILABLE and not args.no_tray
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,
)
if __name__ == "__main__":
+19 -2
View File
@@ -24,6 +24,15 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/browser", tags=["browser"])
def _require_folder_management() -> None:
"""Raise 403 if media folder management is disabled in config."""
if not settings.media_folders_management:
raise HTTPException(
status_code=403,
detail="Media folder management is disabled. Set media_folders_management: true in config.yaml to enable.",
)
async def _broadcast_after_open(controller, label: str, max_wait: float = 2.0) -> None:
"""Poll until media session registers, then broadcast status update.
@@ -83,17 +92,22 @@ async def list_folders(_: str = Depends(verify_token)):
"""List all configured media folders.
Returns:
Dictionary of folder configurations.
Dictionary with folder configurations and management flag.
"""
folders = {}
for folder_id, config in settings.media_folders.items():
folder_path = Path(config.path)
folders[folder_id] = {
"id": folder_id,
"label": config.label,
"path": config.path,
"enabled": config.enabled,
"available": folder_path.is_dir(),
}
return folders
return {
"folders": folders,
"management_enabled": settings.media_folders_management,
}
@router.post("/folders/create")
@@ -112,6 +126,7 @@ async def create_folder(
Raises:
HTTPException: If folder already exists or validation fails.
"""
_require_folder_management()
try:
# Validate folder_id format (alphanumeric and underscore only)
if not request.folder_id.replace("_", "").isalnum():
@@ -169,6 +184,7 @@ async def update_folder(
Raises:
HTTPException: If folder doesn't exist or validation fails.
"""
_require_folder_management()
try:
# Validate path exists
path = Path(request.path)
@@ -217,6 +233,7 @@ async def delete_folder(
Raises:
HTTPException: If folder doesn't exist.
"""
_require_folder_management()
try:
config_manager.delete_media_folder(folder_id)
+14 -4
View File
@@ -3,23 +3,33 @@
import platform
from typing import Any
from fastapi import APIRouter
from fastapi import APIRouter, Request
from .. import __version__
from ..auth import auth_enabled
from ..config import settings
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(),
"media_folders_management": settings.media_folders_management,
}
# 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.
"""
...
+169
View File
@@ -0,0 +1,169 @@
"""Provider-agnostic update checker service."""
import asyncio
import logging
import re
from functools import total_ordering
from typing import Any, Optional
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_ORDER = {"alpha": 0, "beta": 1, "rc": 2}
@total_ordering
class _Version:
"""Lightweight PEP 440-ish version for comparison without packaging dep.
Supports: X.Y.Z and X.Y.Z-{alpha,beta,rc}.N
Pre-releases sort before the corresponding stable release.
"""
__slots__ = ("_release", "_pre")
def __init__(self, release: tuple[int, ...], pre: Optional[tuple[int, int]]) -> None:
self._release = release
self._pre = pre
def __eq__(self, other: object) -> bool:
if not isinstance(other, _Version):
return NotImplemented
return self._release == other._release and self._pre == other._pre
def __lt__(self, other: object) -> bool:
if not isinstance(other, _Version):
return NotImplemented
if self._release != other._release:
return self._release < other._release
# No pre-release (stable) is greater than any pre-release
if self._pre is None and other._pre is None:
return False
if self._pre is not None and other._pre is None:
return True
if self._pre is None and other._pre is not None:
return False
return self._pre < other._pre # type: ignore[operator]
def __repr__(self) -> str:
v = ".".join(str(p) for p in self._release)
if self._pre is not None:
labels = {0: "alpha", 1: "beta", 2: "rc"}
v += f"-{labels[self._pre[0]]}.{self._pre[1]}"
return f"_Version('{v}')"
def _parse_version(raw: str) -> _Version:
"""Parse a version tag for comparison.
Examples:
v0.3.0-alpha.1 → (0,3,0) pre=(0,1) (sorts below 0.3.0)
v0.3.0-rc.3 → (0,3,0) pre=(2,3)
v1.0.0 → (1,0,0) pre=None
"""
cleaned = raw.lstrip("v").strip()
m = _PRE_PATTERN.match(cleaned)
if m:
base = tuple(int(x) for x in m.group(1).split("."))
pre_label = m.group(2).lower()
pre_num = int(m.group(3))
return _Version(base, (_PRE_ORDER[pre_label], pre_num))
release = tuple(int(x) for x in cleaned.split("."))
return _Version(release, None)
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
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 208 B

+68
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>
@@ -280,6 +290,7 @@
<span id="pageTotal">/ 1</span>
</div>
<button id="nextPage" onclick="nextPage()" data-i18n="browser.next">Next</button>
<span class="pagination-showing" id="paginationShowing"></span>
</div>
</div>
@@ -313,6 +324,39 @@
</div>
</details>
<details class="settings-section" open id="mediaFoldersSection" style="display: none;">
<summary data-i18n="settings.section.media_folders">Media Folders</summary>
<div class="settings-section-content">
<p class="settings-section-description" data-i18n="browser.folders_description">
Media folders available for browsing. Folders on network shares show availability status.
</p>
<table class="scripts-table">
<thead>
<tr>
<th data-i18n="browser.folders_table.id">ID</th>
<th data-i18n="browser.folders_table.label">Label</th>
<th data-i18n="browser.folders_table.path">Path</th>
<th data-i18n="browser.folders_table.status">Status</th>
<th data-i18n="browser.folders_table.actions">Actions</th>
</tr>
</thead>
<tbody id="foldersTableBody">
<tr>
<td colspan="5" class="empty-state">
<div class="empty-state-illustration">
<svg viewBox="0 0 64 64"><path d="M8 16h20l4-6h20a4 4 0 014 4v36a4 4 0 01-4 4H8a4 4 0 01-4-4V20a4 4 0 014-4z"/><path d="M4 24h56" stroke-dasharray="4 3" opacity="0.4"/></svg>
<p data-i18n="browser.folders_empty">No media folders configured. Click "+" to add one.</p>
</div>
</td>
</tr>
</tbody>
</table>
<div class="add-card" onclick="showAddFolderDialog()">
<span class="add-card-icon">+</span>
</div>
</div>
</details>
<details class="settings-section" open>
<summary data-i18n="settings.section.scripts">Scripts</summary>
<div class="settings-section-content">
@@ -462,6 +506,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 +522,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">
+23 -1
View File
@@ -40,6 +40,7 @@ import {
showAddScriptDialog, showEditScriptDialog, closeScriptDialog, saveScript,
deleteScriptConfirm, executeScriptDebug, executeCallbackDebug,
closeExecutionDialog, scriptFormDirty, setScriptFormDirty,
addParameterRow, closeScriptParamsDialog, submitScriptWithParams,
} from './scripts.js';
import {
@@ -56,6 +57,7 @@ import {
onBrowserSearch, clearBrowserSearch, onItemsPerPageChanged,
downloadFile, closeFolderDialog, saveFolder,
showManageFoldersDialog,
showAddFolderDialog, showEditFolderDialog, deleteFolderConfirm,
} from './browser.js';
import {
@@ -106,6 +108,7 @@ Object.assign(window, {
showAddScriptDialog, showEditScriptDialog, closeScriptDialog, saveScript,
deleteScriptConfirm, executeScriptDebug, executeCallbackDebug,
closeExecutionDialog,
addParameterRow, closeScriptParamsDialog, submitScriptWithParams,
// Callbacks
showAddCallbackDialog, showEditCallbackDialog, closeCallbackDialog,
saveCallback, deleteCallbackConfirm,
@@ -115,6 +118,7 @@ Object.assign(window, {
onBrowserSearch, clearBrowserSearch, onItemsPerPageChanged,
downloadFile, closeFolderDialog, saveFolder,
showManageFoldersDialog,
showAddFolderDialog, showEditFolderDialog, deleteFolderConfirm,
// Links
showAddLinkDialog, showEditLinkDialog, closeLinkDialog,
saveLink, deleteLinkConfirm,
@@ -321,6 +325,24 @@ window.addEventListener('DOMContentLoaded', async () => {
else if (action === 'delete') deleteCallbackConfirm(name);
});
// Folder dialog backdrop click to close
const folderDialog = document.getElementById('folderDialog');
folderDialog.addEventListener('click', (e) => {
if (e.target === folderDialog) {
closeFolderDialog();
}
});
// Delegated click handlers for folder table actions
document.getElementById('foldersTableBody').addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const folderId = btn.dataset.folderId;
if (action === 'edit') showEditFolderDialog(folderId);
else if (action === 'delete') deleteFolderConfirm(folderId);
});
// Link dialog backdrop click to close
const linkDialog = document.getElementById('linkDialog');
linkDialog.addEventListener('click', (e) => {
@@ -350,7 +372,7 @@ window.addEventListener('DOMContentLoaded', async () => {
// Initialize browser toolbar and load folders
initBrowserToolbar();
if (token) {
if (!authReq || token) {
loadMediaFolders();
}
+213 -24
View File
@@ -3,7 +3,7 @@
// ============================================================
import {
t, showToast, escapeHtml, closeDialog,
t, showToast, showConfirm, escapeHtml, closeDialog,
SEARCH_DEBOUNCE_MS, EMPTY_SVG_FILE, EMPTY_SVG_FOLDER, emptyStateHtml,
getAuthHeaders, hasCredentials,
} from './core.js';
@@ -15,6 +15,7 @@ let currentOffset = 0;
let itemsPerPage = parseInt(localStorage.getItem('mediaBrowser.itemsPerPage')) || 100;
let totalItems = 0;
let mediaFolders = {};
let managementEnabled = false;
let viewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid';
let cachedItems = null;
let browserSearchTerm = '';
@@ -33,7 +34,20 @@ export async function loadMediaFolders() {
if (!response.ok) throw new Error('Failed to load folders');
mediaFolders = await response.json();
const data = await response.json();
mediaFolders = data.folders || {};
managementEnabled = data.management_enabled || false;
// Show/hide the media folders settings section
const section = document.getElementById('mediaFoldersSection');
if (section) {
section.style.display = managementEnabled ? '' : 'none';
}
// Render folders table in settings if management is enabled
if (managementEnabled) {
loadFoldersTable();
}
// Load last browsed path or show root folder list
loadLastBrowserPath();
@@ -69,41 +83,48 @@ function showRootFolders() {
revokeBlobUrls(container);
if (viewMode === 'list') {
container.className = 'browser-list';
} else if (viewMode === 'compact') {
container.className = 'browser-grid browser-grid-compact';
} else {
container.className = 'browser-grid';
container.className = 'browser-grid browser-root-grid';
}
container.innerHTML = '';
const folderSvg = '<svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/></svg>';
Object.entries(mediaFolders).forEach(([id, folder]) => {
if (!folder.enabled) return;
const unavailable = folder.available === false;
const unavailableClass = unavailable ? ' unavailable' : '';
if (viewMode === 'list') {
const row = document.createElement('div');
row.className = 'browser-list-item';
row.onclick = () => {
currentFolderId = id;
browsePath(id, '');
};
row.className = 'browser-list-item' + unavailableClass;
if (!unavailable) {
row.onclick = () => {
currentFolderId = id;
browsePath(id, '');
};
}
row.innerHTML = `
<div class="browser-list-icon">\u{1F4C1}</div>
<div class="browser-list-name">${folder.label}</div>
<div class="browser-list-icon" style="color: var(--accent)">${folderSvg}</div>
<div class="browser-list-name">${escapeHtml(folder.label)}${unavailable ? ' <span class="folder-unavailable-badge">' + t('browser.unavailable') + '</span>' : ''}</div>
`;
container.appendChild(row);
} else {
const card = document.createElement('div');
card.className = 'browser-item';
card.onclick = () => {
currentFolderId = id;
browsePath(id, '');
};
card.className = 'browser-item browser-root-folder' + unavailableClass;
if (!unavailable) {
card.onclick = () => {
currentFolderId = id;
browsePath(id, '');
};
}
card.innerHTML = `
<div class="browser-thumb-wrapper">
<div class="browser-icon">\u{1F4C1}</div>
<div class="browser-icon" style="color: var(--accent)">${folderSvg}</div>
</div>
<div class="browser-item-info">
<div class="browser-item-name">${folder.label}</div>
<div class="browser-item-name">${escapeHtml(folder.label)}</div>
${unavailable ? '<div class="browser-item-meta folder-unavailable-badge">' + t('browser.unavailable') + '</div>' : ''}
</div>
`;
container.appendChild(card);
@@ -248,6 +269,19 @@ function renderBrowserList(items, container) {
return;
}
// Column header row
const header = document.createElement('div');
header.className = 'browser-list-header';
header.innerHTML = `
<span></span>
<span>${t('browser.list_header.name')}</span>
<span>${t('browser.list_header.bitrate')}</span>
<span>${t('browser.list_header.duration')}</span>
<span>${t('browser.list_header.size')}</span>
<span></span>
`;
container.appendChild(header);
items.forEach((item, idx) => {
const row = document.createElement('div');
row.className = 'browser-list-item';
@@ -662,6 +696,7 @@ function renderPagination() {
const nextBtn = document.getElementById('nextPage');
const pageInput = document.getElementById('pageInput');
const pageTotal = document.getElementById('pageTotal');
const showingEl = document.getElementById('paginationShowing');
const totalPages = Math.ceil(totalItems / itemsPerPage);
const currentPage = Math.floor(currentOffset / itemsPerPage) + 1;
@@ -676,6 +711,13 @@ function renderPagination() {
pageInput.max = totalPages;
pageTotal.textContent = `/ ${totalPages}`;
// "Showing X-Y of Z"
if (showingEl) {
const from = currentOffset + 1;
const to = Math.min(currentOffset + itemsPerPage, totalItems);
showingEl.textContent = t('browser.showing_items', { from, to, total: totalItems });
}
prevBtn.disabled = currentPage === 1;
nextBtn.disabled = currentPage === totalPages;
}
@@ -845,10 +887,72 @@ function loadLastBrowserPath() {
}
}
// Folder Management
export function showManageFoldersDialog() {
// TODO: Implement folder management UI
showToast(t('browser.manage_folders_hint'), 'info');
// Folder Management — Settings table
export function loadFoldersTable() {
const tbody = document.getElementById('foldersTableBody');
if (!tbody) return;
const entries = Object.entries(mediaFolders);
if (entries.length === 0) {
tbody.innerHTML = `<tr><td colspan="5" class="empty-state">
<div class="empty-state-illustration">
<svg viewBox="0 0 64 64"><path d="M8 16h20l4-6h20a4 4 0 014 4v36a4 4 0 01-4 4H8a4 4 0 01-4-4V20a4 4 0 014-4z"/><path d="M4 24h56" stroke-dasharray="4 3" opacity="0.4"/></svg>
<p data-i18n="browser.folders_empty">${t('browser.folders_empty')}</p>
</div></td></tr>`;
return;
}
tbody.innerHTML = entries.map(([id, folder]) => {
const available = folder.available !== false;
const statusIcon = available
? '<span class="status-dot status-online">' + t('browser.folder_available') + '</span>'
: '<span class="status-dot status-offline">' + t('browser.folder_unavailable') + '</span>';
const enabledBadge = folder.enabled
? ''
: ' <span class="folder-disabled-badge">' + t('browser.folder_disabled') + '</span>';
return `<tr>
<td>${escapeHtml(id)}${enabledBadge}</td>
<td>${escapeHtml(folder.label)}</td>
<td class="path-cell" title="${escapeHtml(folder.path)}">${escapeHtml(folder.path)}</td>
<td>${statusIcon}</td>
<td class="actions-cell">
<button class="btn-icon" data-action="edit" data-folder-id="${escapeHtml(id)}" title="${t('browser.folder_edit')}">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
</button>
<button class="btn-icon btn-danger-icon" data-action="delete" data-folder-id="${escapeHtml(id)}" title="${t('browser.folder_delete')}">
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
</td>
</tr>`;
}).join('');
}
export function showAddFolderDialog() {
document.getElementById('folderDialogTitle').textContent = t('browser.folder_dialog.title_add');
document.getElementById('folderIsEdit').value = '';
document.getElementById('folderOriginalId').value = '';
document.getElementById('folderId').value = '';
document.getElementById('folderId').disabled = false;
document.getElementById('folderLabel').value = '';
document.getElementById('folderPath').value = '';
document.getElementById('folderEnabled').checked = true;
document.getElementById('folderDialog').showModal();
}
export function showEditFolderDialog(folderId) {
const folder = mediaFolders[folderId];
if (!folder) return;
document.getElementById('folderDialogTitle').textContent = t('browser.folder_dialog.title_edit');
document.getElementById('folderIsEdit').value = '1';
document.getElementById('folderOriginalId').value = folderId;
document.getElementById('folderId').value = folderId;
document.getElementById('folderId').disabled = true;
document.getElementById('folderLabel').value = folder.label;
document.getElementById('folderPath').value = folder.path;
document.getElementById('folderEnabled').checked = folder.enabled;
document.getElementById('folderDialog').showModal();
}
export function closeFolderDialog() {
@@ -857,5 +961,90 @@ export function closeFolderDialog() {
export async function saveFolder(event) {
event.preventDefault();
closeFolderDialog();
const isEdit = document.getElementById('folderIsEdit').value === '1';
const folderId = isEdit
? document.getElementById('folderOriginalId').value
: document.getElementById('folderId').value.trim();
const label = document.getElementById('folderLabel').value.trim();
const path = document.getElementById('folderPath').value.trim();
const enabled = document.getElementById('folderEnabled').checked;
if (!folderId || !label || !path) return;
const submitBtn = document.querySelector('#folderForm button[type="submit"]');
if (submitBtn) submitBtn.disabled = true;
try {
let response;
if (isEdit) {
response = await fetch(`/api/browser/folders/update/${encodeURIComponent(folderId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ label, path, enabled }),
});
} else {
response = await fetch('/api/browser/folders/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify({ folder_id: folderId, label, path, enabled }),
});
}
if (response.ok) {
closeFolderDialog();
showToast(t(isEdit ? 'browser.folder_updated' : 'browser.folder_created'), 'success');
await loadMediaFolders();
} else {
const result = await response.json().catch(() => ({}));
showToast(result.detail || t('browser.folder_save_error'), 'error');
}
} catch (error) {
console.error('Error saving folder:', error);
showToast(t('browser.folder_save_error'), 'error');
} finally {
if (submitBtn) submitBtn.disabled = false;
}
}
export async function deleteFolderConfirm(folderId) {
if (!await showConfirm(t('browser.folder_confirm_delete', { name: folderId }))) {
return;
}
try {
const response = await fetch(`/api/browser/folders/delete/${encodeURIComponent(folderId)}`, {
method: 'DELETE',
headers: getAuthHeaders(),
});
if (response.ok) {
showToast(t('browser.folder_deleted'), 'success');
await loadMediaFolders();
} else {
const result = await response.json().catch(() => ({}));
showToast(result.detail || t('browser.folder_delete_error'), 'error');
}
} catch (error) {
console.error('Error deleting folder:', error);
showToast(t('browser.folder_delete_error'), 'error');
}
}
// Legacy stub — now handled via settings table
export function showManageFoldersDialog() {
if (managementEnabled) {
// Switch to settings tab and scroll to the folders section
const switchTabFn = window.switchTab;
if (switchTabFn) switchTabFn('settings');
setTimeout(() => {
const section = document.getElementById('mediaFoldersSection');
if (section) {
section.setAttribute('open', '');
section.scrollIntoView({ behavior: 'smooth' });
}
}, 100);
} else {
showToast(t('browser.manage_folders_hint'), 'info');
}
}
+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') {
+38 -2
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",
@@ -164,7 +173,27 @@
"browser.play_all_error": "Failed to play folder",
"browser.error_loading": "Error loading directory",
"browser.error_loading_folders": "Failed to load media folders",
"browser.manage_folders_hint": "Folder management coming soon! For now, edit config.yaml to add media folders.",
"browser.manage_folders_hint": "Folder management is disabled. Set media_folders_management: true in config.yaml to enable.",
"browser.unavailable": "Unavailable",
"browser.folder_available": "Available",
"browser.folder_unavailable": "Unavailable (path not reachable)",
"browser.folder_disabled": "disabled",
"browser.folder_edit": "Edit folder",
"browser.folder_delete": "Delete folder",
"browser.folder_created": "Media folder created successfully",
"browser.folder_updated": "Media folder updated successfully",
"browser.folder_deleted": "Media folder deleted successfully",
"browser.folder_save_error": "Failed to save media folder",
"browser.folder_delete_error": "Failed to delete media folder",
"browser.folder_confirm_delete": "Are you sure you want to delete the folder \"{name}\"?",
"browser.folders_description": "Media folders available for browsing. Folders on network shares show availability status.",
"browser.folders_empty": "No media folders configured. Click \"+\" to add one.",
"browser.folders_table.id": "ID",
"browser.folders_table.label": "Label",
"browser.folders_table.path": "Path",
"browser.folders_table.status": "Status",
"browser.folders_table.actions": "Actions",
"settings.section.media_folders": "Media Folders",
"browser.folder_dialog.title_add": "Add Media Folder",
"browser.folder_dialog.title_edit": "Edit Media Folder",
"browser.folder_dialog.folder_id": "Folder ID *",
@@ -176,6 +205,11 @@
"browser.folder_dialog.enabled": "Enabled",
"browser.folder_dialog.cancel": "Cancel",
"browser.folder_dialog.save": "Save",
"browser.list_header.name": "Name",
"browser.list_header.bitrate": "Bitrate",
"browser.list_header.duration": "Duration",
"browser.list_header.size": "Size",
"browser.showing_items": "Showing {from}\u2013{to} of {total}",
"browser.download_error": "Failed to download file",
"connection.reconnecting": "Connection lost. Reconnecting (attempt {attempt})...",
"connection.lost": "Connection lost. Server may be unavailable.",
@@ -215,5 +249,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"
}
+38 -2
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": "Добавить",
@@ -164,7 +173,27 @@
"browser.play_all_error": "Не удалось воспроизвести папку",
"browser.error_loading": "Ошибка загрузки каталога",
"browser.error_loading_folders": "Не удалось загрузить медиа папки",
"browser.manage_folders_hint": "Управление папками скоро появится! Пока редактируйте config.yaml для добавления медиа папок.",
"browser.manage_folders_hint": "Управление папками отключено. Установите media_folders_management: true в config.yaml для включения.",
"browser.unavailable": "Недоступна",
"browser.folder_available": "Доступна",
"browser.folder_unavailable": "Недоступна (путь не найден)",
"browser.folder_disabled": "отключена",
"browser.folder_edit": "Редактировать папку",
"browser.folder_delete": "Удалить папку",
"browser.folder_created": "Медиа папка успешно создана",
"browser.folder_updated": "Медиа папка успешно обновлена",
"browser.folder_deleted": "Медиа папка успешно удалена",
"browser.folder_save_error": "Не удалось сохранить медиа папку",
"browser.folder_delete_error": "Не удалось удалить медиа папку",
"browser.folder_confirm_delete": "Вы уверены, что хотите удалить папку \"{name}\"?",
"browser.folders_description": "Медиа папки для просмотра. Для сетевых ресурсов показан статус доступности.",
"browser.folders_empty": "Медиа папки не настроены. Нажмите \"+\" для добавления.",
"browser.folders_table.id": "ID",
"browser.folders_table.label": "Метка",
"browser.folders_table.path": "Путь",
"browser.folders_table.status": "Статус",
"browser.folders_table.actions": "Действия",
"settings.section.media_folders": "Медиа папки",
"browser.folder_dialog.title_add": "Добавить медиа папку",
"browser.folder_dialog.title_edit": "Редактировать медиа папку",
"browser.folder_dialog.folder_id": "ID папки *",
@@ -176,6 +205,11 @@
"browser.folder_dialog.enabled": "Включено",
"browser.folder_dialog.cancel": "Отмена",
"browser.folder_dialog.save": "Сохранить",
"browser.list_header.name": "Название",
"browser.list_header.bitrate": "Битрейт",
"browser.list_header.duration": "Длительность",
"browser.list_header.size": "Размер",
"browser.showing_items": "Показано {from}\u2013{to} из {total}",
"browser.download_error": "Не удалось скачать файл",
"connection.reconnecting": "Соединение потеряно. Переподключение (попытка {attempt})...",
"connection.lost": "Соединение потеряно. Сервер может быть недоступен.",
@@ -215,5 +249,7 @@
"links.confirm.delete": "Вы уверены, что хотите удалить ссылку \"{name}\"?",
"links.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
"footer.created_by": "Создано",
"footer.source_code": "Исходный код"
"footer.source_code": "Исходный код",
"update.available": "Доступно обновление: v{version}",
"update.view_release": "Перейти к релизу"
}
+147
View File
@@ -0,0 +1,147 @@
"""System tray icon for Media Server."""
import ctypes
import io
import logging
import webbrowser
from pathlib import Path
from typing import Callable
from PIL import Image, ImageDraw
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:
"""Create a tray icon: green circle with white play triangle."""
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# Green circle background
padding = 2
draw.ellipse(
[padding, padding, size - padding, size - padding],
fill=(29, 185, 84, 255),
)
# White play triangle
cx, cy = size // 2, size // 2
r = size * 0.28
triangle = [
(cx - r * 0.6, cy - r),
(cx - r * 0.6, cy + r),
(cx + r * 0.9, cy),
]
draw.polygon(triangle, fill=(255, 255, 255, 255))
return img
def _load_icon_image() -> Image.Image:
"""Load the ICO/SVG app icon, falling back to a generated image."""
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 = icons_dir / "icon.svg"
if svg_path.exists():
png_data = cairosvg.svg2png(url=str(svg_path), output_width=64, output_height=64)
return Image.open(io.BytesIO(png_data))
except Exception:
pass
return _create_icon_image()
class TrayManager:
"""Manages the system tray icon and its context menu.
Call ``run()`` on the **main thread** — it blocks until ``stop()``
is called (from any thread) or the user picks *Shutdown* from the menu.
"""
def __init__(self, port: int, on_exit: Callable[[], None]) -> None:
if not PYSTRAY_AVAILABLE:
raise ImportError("pystray is required for system tray support")
self._port = port
self._on_exit = on_exit
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),
)
self._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}")
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()
@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()
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "media-server-frontend",
"version": "1.0.0",
"version": "0.1.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "media-server-frontend",
"version": "1.0.0",
"version": "0.1.2",
"devDependencies": {
"esbuild": "^0.27.4"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "media-server-frontend",
"version": "1.0.0",
"version": "0.1.2",
"private": true,
"description": "Frontend build tooling for media server WebUI",
"scripts": {
+3 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "media-server"
version = "1.0.0"
version = "0.1.2"
description = "REST API server for controlling system-wide media playback"
readme = "README.md"
license = { text = "MIT" }
@@ -42,10 +42,11 @@ windows = [
"pycaw>=20230407",
"screen-brightness-control>=0.20.0",
"monitorcontrol>=3.0.0",
"pystray>=0.19.0",
]
visualizer = [
"soundcard>=0.4.0",
"numpy>=1.24.0",
"numpy>=1.24.0,<2.0",
]
dev = [
"pytest>=7.0",
+10 -4
View File
@@ -1,7 +1,13 @@
Set fso = CreateObject("Scripting.FileSystemObject")
Set WshShell = CreateObject("WScript.Shell")
' Get the directory of this script (scripts\), then go up to media-server root
scriptDir = CreateObject("Scripting.FileSystemObject").GetParentFolderName(WScript.ScriptFullName)
serverRoot = CreateObject("Scripting.FileSystemObject").GetParentFolderName(scriptDir)
scriptDir = fso.GetParentFolderName(WScript.ScriptFullName)
serverRoot = fso.GetParentFolderName(scriptDir)
WshShell.CurrentDirectory = serverRoot
' Run python completely hidden (0 = hidden, False = don't wait)
WshShell.Run "python -m media_server.main", 0, False
' Use embedded Python if present (installed distribution), otherwise system Python
embeddedPython = serverRoot & "\python\python.exe"
If fso.FileExists(embeddedPython) Then
WshShell.Run """" & embeddedPython & """ -m media_server.main", 0, False
Else
WshShell.Run "python -m media_server.main", 0, False
End If