Compare commits
8 Commits
1410a8d2cb
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 81d5b0a402 | |||
| d67e61ae39 | |||
| e795d224a8 | |||
| d0830cbbe5 | |||
| 4ef11c8f00 | |||
| fb56e6cdc0 | |||
| ff6712620e | |||
| 795a15cb8b |
@@ -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 = f'''## 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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
## v0.1.0 (2026-03-25)
|
||||
|
||||
Initial public release of Media Server — a standalone REST API server (FastAPI) for controlling system-wide media playback on Windows, Linux, macOS, and Android.
|
||||
|
||||
### Features
|
||||
- Remote media control: play, pause, stop, next, previous, volume, seek ([83acf5f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/83acf5f))
|
||||
- Built-in Web UI for media control and monitoring ([a0d138b](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/a0d138b))
|
||||
- Media browser with grid/compact/list views and single-click playback ([e16674c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/e16674c))
|
||||
- Low-latency volume control via WebSocket ([32b058c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/32b058c))
|
||||
- Audio visualizer with spectrogram, beat-reactive art, and device selection ([0691e3d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/0691e3d))
|
||||
- Dynamic WebGL background with audio reactivity ([be48318](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/be48318))
|
||||
- Runtime script management with Home Assistant integration ([d7c5994](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d7c5994))
|
||||
- Typed script parameters with validation and icon-grid selector ([1410a8d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/1410a8d))
|
||||
- Callback management API/UI and theme support ([a0af855](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/a0af855))
|
||||
- Multi-token authentication with client labels ([71a0a6e](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/71a0a6e))
|
||||
- Optional authentication — no tokens = no auth ([4d1bb78](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4d1bb78))
|
||||
- Internationalization (i18n) with English and Russian locales ([9bbb8e1](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/9bbb8e1))
|
||||
- PWA support: installable standalone app with safe area handling ([a20812e](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/a20812e))
|
||||
- System tray icon with Show UI, Restart, and Shutdown actions ([6500d6f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/6500d6f), [3f14512](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/3f14512))
|
||||
- Display brightness and power control ([a568608](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/a568608))
|
||||
- Custom accent color picker and primary display indicator ([397d38a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/397d38a))
|
||||
- 3D album art rotation and vinyl desaturation effect ([4112367](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4112367))
|
||||
- Friendly media source names with brand icons ([73a6f38](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/73a6f38))
|
||||
- Browser search/filter for media items ([5f474d6](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/5f474d6))
|
||||
- Header quick links with CRUD management ([99dbbb1](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/99dbbb1))
|
||||
- Swagger API docs button in header toolbar ([2b1e09d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/2b1e09d))
|
||||
- Update-available notification system ([795a15c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/795a15c))
|
||||
- Persist audio capture device selection to config ([fb56e6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/fb56e6c))
|
||||
- UI animations: dialogs, tabs, settings, browser stagger, banner pulse ([3cfc437](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/3cfc437))
|
||||
- Tabbed UI with browse caching and bottom mini player ([98a33bc](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/98a33bc))
|
||||
- Slider tracks tinted with accent color ([1c0a011](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/1c0a011))
|
||||
|
||||
### Bug Fixes
|
||||
- Tray restart uses `python -m` for reliable process respawn ([415231f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/415231f))
|
||||
- Tray main-thread message loop, numpy <2.0 pin, installer config copy ([4021837](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4021837))
|
||||
- Loopback device status showing 'Unavailable' after change ([0eca829](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/0eca829))
|
||||
- Error handling for unavailable network shares ([d1ec27c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d1ec27c))
|
||||
- HTTPException handling in folder endpoints ([c5f8c7a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/c5f8c7a))
|
||||
- FOUC (Flash of Untranslated Content) issues ([4f8f59d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4f8f59d))
|
||||
- Windows Task Scheduler auto-start ([8077181](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/8077181))
|
||||
- Vinyl angle persistence on toggle ([00d313d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/00d313d))
|
||||
|
||||
---
|
||||
|
||||
### Development / Internal
|
||||
|
||||
#### CI/Build
|
||||
- CI/CD pipelines with Gitea Actions, NSIS installer, ES module bundling, ruff linting ([5439af1](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/5439af1))
|
||||
- Linux build in release workflow ([ddd8788](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddd8788))
|
||||
- NSIS installer with custom icon, launch-after-install, running-instance detection ([26b5f74](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/26b5f74))
|
||||
- CI/build improvements and version detection ([4ef11c8](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4ef11c8))
|
||||
- Warning annotation for existing release fallback ([d0830cb](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d0830cb))
|
||||
|
||||
#### Refactoring
|
||||
- Modular frontend: refactor monolithic app.js into 8 modules ([92d6709](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/92d6709))
|
||||
- Codebase audit: stability, performance, accessibility fixes ([9404b37](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/9404b37))
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>All Commits (82)</summary>
|
||||
|
||||
| Hash | Message | Author |
|
||||
|------|---------|--------|
|
||||
| [d0830cb](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d0830cb) | ci: use warning annotation for existing release fallback | alexei.dolgolyov |
|
||||
| [4ef11c8](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4ef11c8) | chore: CI/build improvements and version detection | alexei.dolgolyov |
|
||||
| [fb56e6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/fb56e6c) | feat: persist audio capture device selection to config.yaml | alexei.dolgolyov |
|
||||
| [ff67126](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ff67126) | chore: bump version to 1.0.1 | alexei.dolgolyov |
|
||||
| [795a15c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/795a15c) | feat: add update-available notification system | alexei.dolgolyov |
|
||||
| [1410a8d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/1410a8d) | feat: typed script parameters with validation and icon-grid selector | alexei.dolgolyov |
|
||||
| [1c0a011](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/1c0a011) | feat: tint slider tracks with 15% accent color | alexei.dolgolyov |
|
||||
| [2b1e09d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/2b1e09d) | feat: add Swagger API docs button to header toolbar | alexei.dolgolyov |
|
||||
| [415231f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/415231f) | fix: tray restart uses python -m for reliable process respawn | alexei.dolgolyov |
|
||||
| [32e2ff5](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/32e2ff5) | fix: add --only-binary to pip download fallback (CI compatibility) | alexei.dolgolyov |
|
||||
| [309f547](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/309f547) | feat: add default MDI icons to example config scripts | alexei.dolgolyov |
|
||||
| [4021837](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4021837) | fix: tray main-thread message loop, numpy <2.0 pin, installer config copy | alexei.dolgolyov |
|
||||
| [d7e10b1](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d7e10b1) | fix: interpolate tag in release body template (f-string) | alexei.dolgolyov |
|
||||
| [3f14512](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/3f14512) | feat: add Restart and Shutdown tray actions with confirmation dialogs | alexei.dolgolyov |
|
||||
| [26b5f74](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/26b5f74) | feat: improve installer with custom icon, launch-after-install, and running-instance detection | alexei.dolgolyov |
|
||||
| [1f6e4f6](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/1f6e4f6) | feat: add Launch option to installer finish page | alexei.dolgolyov |
|
||||
| [6500d6f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/6500d6f) | feat: add system tray icon with Show UI and Exit actions | alexei.dolgolyov |
|
||||
| [4d1bb78](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4d1bb78) | feat: make authentication optional — no tokens = no auth | alexei.dolgolyov |
|
||||
| [f80f6e9](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/f80f6e9) | fix: correct ._pth path in Windows build script | alexei.dolgolyov |
|
||||
| [0216851](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/0216851) | docs: comprehensive README update with all API endpoints and features | alexei.dolgolyov |
|
||||
| [c76ffb9](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/c76ffb9) | fix: handle existing release in create-release job | alexei.dolgolyov |
|
||||
| [ddd8788](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddd8788) | Add Linux build to release workflow, fix pytest exit code 5 | alexei.dolgolyov |
|
||||
| [5439af1](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/5439af1) | Add CI/CD pipelines, NSIS installer, ES module bundling, and ruff linting | alexei.dolgolyov |
|
||||
| [be48318](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/be48318) | Add dynamic WebGL background with audio reactivity | alexei.dolgolyov |
|
||||
| [0eca829](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/0eca829) | Fix loopback device status showing 'Unavailable' after change | alexei.dolgolyov |
|
||||
| [3cfc437](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/3cfc437) | Add UI animations: dialogs, tabs, settings, browser stagger, banner pulse | alexei.dolgolyov |
|
||||
| [a20812e](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/a20812e) | Add PWA support: installable standalone app with safe area handling | alexei.dolgolyov |
|
||||
| [652f10f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/652f10f) | Reduce visualizer latency, tighten UI paddings, fix mobile browser toolbar | alexei.dolgolyov |
|
||||
| [3846610](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/3846610) | On-demand audio visualizer capture + UI fixes | alexei.dolgolyov |
|
||||
| [92d6709](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/92d6709) | Refactor monolithic app.js into 8 modular files | alexei.dolgolyov |
|
||||
| [9404b37](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/9404b37) | Codebase audit fixes: stability, performance, accessibility | alexei.dolgolyov |
|
||||
| [73a6f38](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/73a6f38) | Add friendly media source names with brand icons | alexei.dolgolyov |
|
||||
| [b11edc2](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/b11edc2) | Redesign header as pill-shaped toolbar group | alexei.dolgolyov |
|
||||
| [3d01d98](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/3d01d98) | Style audio device select, hide mini player volume on tablet | alexei.dolgolyov |
|
||||
| [4112367](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4112367) | Add 3D album art rotation and vinyl desaturation effect | alexei.dolgolyov |
|
||||
| [00d313d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/00d313d) | Fix vinyl angle persistence on toggle, group player toggle buttons | alexei.dolgolyov |
|
||||
| [0691e3d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/0691e3d) | Add audio visualizer with spectrogram, beat-reactive art, and device selection | alexei.dolgolyov |
|
||||
| [8a8f00f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/8a8f00f) | Persist vinyl rotation angle across page reloads | alexei.dolgolyov |
|
||||
| [397d38a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/397d38a) | Add primary display indicator, custom accent color picker, restart script | alexei.dolgolyov |
|
||||
| [adf2d93](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/adf2d93) | Consolidate tabs, Quick Access links, mini player nav, link descriptions | alexei.dolgolyov |
|
||||
| [99dbbb1](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/99dbbb1) | Add header quick links with CRUD management and icon enhancements | alexei.dolgolyov |
|
||||
| [6f6a4e4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/6f6a4e4) | Improve slider track visibility in both themes | alexei.dolgolyov |
|
||||
| [a568608](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/a568608) | Add display brightness and power control | alexei.dolgolyov |
|
||||
| [03a1b30](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/03a1b30) | Comprehensive WebUI improvements: security, UX, accessibility, performance | alexei.dolgolyov |
|
||||
| [ef1935c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ef1935c) | Update README with current features | alexei.dolgolyov |
|
||||
| [7ee0a60](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/7ee0a60) | Update media browser screenshot | alexei.dolgolyov |
|
||||
| [7f28145](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/7f28145) | Update documentation screenshots | alexei.dolgolyov |
|
||||
| [80d4dbc](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/80d4dbc) | Fix browser grid card sizing | alexei.dolgolyov |
|
||||
| [caf24db](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/caf24db) | Compact browser grid cards | alexei.dolgolyov |
|
||||
| [babdb61](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/babdb61) | Update media-server: Vinyl record mode and accent color picker | alexei.dolgolyov |
|
||||
| [65b513c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/65b513c) | Update media-server: UI polish and bug fixes | alexei.dolgolyov |
|
||||
| [84b985e](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/84b985e) | Backend optimizations, frontend optimizations, and UI design improvements | alexei.dolgolyov |
|
||||
| [d1ec27c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d1ec27c) | Improve error handling for unavailable network shares | alexei.dolgolyov |
|
||||
| [13df69a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/13df69a) | Show media title (Artist – Title) instead of filename when available | alexei.dolgolyov |
|
||||
| [4c13322](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4c13322) | Show bitrate in browser, remove type labels and Play All text | alexei.dolgolyov |
|
||||
| [5f474d6](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/5f474d6) | Add browser search/filter for media items | alexei.dolgolyov |
|
||||
| [98a33bc](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/98a33bc) | Tabbed UI, browse caching, and bottom mini player | alexei.dolgolyov |
|
||||
| [8db40d3](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/8db40d3) | UI polish: refresh button, negative thumbnail cache, and style fixes | alexei.dolgolyov |
|
||||
| [f275240](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/f275240) | Add Play All, home navigation, and UI improvements | alexei.dolgolyov |
|
||||
| [e16674c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/e16674c) | Add media browser with grid/compact/list views and single-click playback | alexei.dolgolyov |
|
||||
| [32b058c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/32b058c) | Add low-latency volume control via WebSocket | alexei.dolgolyov |
|
||||
| [c5f8c7a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/c5f8c7a) | Fix HTTPException handling in folder endpoints and install script path | alexei.dolgolyov |
|
||||
| [8d15a2a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/8d15a2a) | Update Web UI: Header redesign, thumbnail fix, and title fallback | alexei.dolgolyov |
|
||||
| [1cb83ea](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/1cb83ea) | Add screenshots to README | alexei.dolgolyov |
|
||||
| [62c42f7](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/62c42f7) | Move install_task_windows.ps1 to scripts folder | alexei.dolgolyov |
|
||||
| [eb2aed4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/eb2aed4) | Update media browser UI with fade-in animations and improvements | alexei.dolgolyov |
|
||||
| [7c631d0](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/7c631d0) | Add media browser feature with UI improvements | alexei.dolgolyov |
|
||||
| [d5ec5c6](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d5ec5c6) | Update Web UI: Improve volume slider responsiveness | alexei.dolgolyov |
|
||||
| [29e0618](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/29e0618) | Update Web UI: Add server management scripts and improve UX | alexei.dolgolyov |
|
||||
| [4f8f59d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4f8f59d) | Update media-server: Fix FOUC (Flash of Untranslated Content) issues | alexei.dolgolyov |
|
||||
| [40c2c11](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/40c2c11) | Update media-server: Improve script/callback table layout and command editor UX | alexei.dolgolyov |
|
||||
| [4635cac](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4635cac) | Update media-server: Add execution timing and improve script/callback execution UI | alexei.dolgolyov |
|
||||
| [957a177](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/957a177) | Update media-server: Add backdrop click-to-close for dialogs | alexei.dolgolyov |
|
||||
| [8077181](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/8077181) | Fix Windows Task Scheduler auto-start | alexei.dolgolyov |
|
||||
| [9bbb8e1](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/9bbb8e1) | Add internationalization (i18n) support with English and Russian locales | alexei.dolgolyov |
|
||||
| [a0af855](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/a0af855) | Add callback management API/UI and theme support | alexei.dolgolyov |
|
||||
| [d7c5994](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d7c5994) | Add runtime script management with Home Assistant integration | alexei.dolgolyov |
|
||||
| [71a0a6e](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/71a0a6e) | Add multi-token authentication with client labels | alexei.dolgolyov |
|
||||
| [5342cff](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/5342cff) | Add script execution to Web UI | alexei.dolgolyov |
|
||||
| [a0d138b](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/a0d138b) | Add built-in Web UI for media control and monitoring | alexei.dolgolyov |
|
||||
| [1a1cfba](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/1a1cfba) | Add callbacks support for all media actions | alexei.dolgolyov |
|
||||
| [83acf5f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/83acf5f) | Initial commit: Media Server for remote media control | alexei.dolgolyov |
|
||||
|
||||
</details>
|
||||
+103
@@ -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
@@ -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'
|
||||
|
||||
+7
-50
@@ -4,23 +4,9 @@ set -euo pipefail
|
||||
# Cross-build Windows distribution on Linux
|
||||
# Usage: ./build-dist-windows.sh [VERSION]
|
||||
|
||||
# --- Version detection ---
|
||||
VERSION="${1:-}"
|
||||
source "$(dirname "$0")/build-common.sh"
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(git describe --tags --exact-match 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
|
||||
fi
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(grep -oP '__version__\s*=\s*"\K[^"]+' \
|
||||
media_server/__init__.py 2>/dev/null || echo "0.0.0")
|
||||
fi
|
||||
|
||||
VERSION_CLEAN="${VERSION#v}"
|
||||
detect_version "${1:-}"
|
||||
echo "Building Media Server v${VERSION_CLEAN} for Windows"
|
||||
|
||||
# --- Configuration ---
|
||||
@@ -31,8 +17,7 @@ WHEEL_DIR="build/win-wheels"
|
||||
SITE_PACKAGES="${DIST_DIR}/python/Lib/site-packages"
|
||||
BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-win-x64"
|
||||
|
||||
rm -rf dist build
|
||||
mkdir -p "${DIST_DIR}" "${WHEEL_DIR}" "${SITE_PACKAGES}"
|
||||
clean_dist "${DIST_DIR}" "${WHEEL_DIR}" "${SITE_PACKAGES}"
|
||||
|
||||
# --- Download embedded Python ---
|
||||
echo "Downloading embedded Python ${PYTHON_VERSION}..."
|
||||
@@ -58,6 +43,7 @@ CORE_DEPS=(
|
||||
"pyyaml>=6.0"
|
||||
"mutagen>=1.47.0"
|
||||
"pillow>=10.0.0"
|
||||
"packaging>=23.0"
|
||||
)
|
||||
|
||||
# Windows-specific dependencies
|
||||
@@ -100,43 +86,14 @@ for whl in "$WHEEL_DIR"/*.whl; do
|
||||
unzip -qo "$whl" -d "$SITE_PACKAGES"
|
||||
done
|
||||
|
||||
# --- Size optimization ---
|
||||
echo "Optimizing size..."
|
||||
find "$SITE_PACKAGES" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$SITE_PACKAGES" -type d -name tests -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$SITE_PACKAGES" -type d -name "*.dist-info" -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$SITE_PACKAGES" -name "*.pyi" -delete 2>/dev/null || true
|
||||
rm -rf "$SITE_PACKAGES"/{pip,setuptools,pkg_resources}* 2>/dev/null || true
|
||||
|
||||
# Trim numpy if present
|
||||
rm -rf "$SITE_PACKAGES"/numpy/{tests,f2py,typing} 2>/dev/null || true
|
||||
|
||||
# --- Verify frontend bundle ---
|
||||
if [ ! -f "media_server/static/dist/app.bundle.js" ]; then
|
||||
echo "ERROR: Frontend bundle not found. Run 'npm ci && npm run build' first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Copy application ---
|
||||
echo "Copying application files..."
|
||||
mkdir -p "${DIST_DIR}/app"
|
||||
cp -r media_server "${DIST_DIR}/app/"
|
||||
|
||||
# Remove source JS (bundle is in dist/)
|
||||
rm -rf "${DIST_DIR}/app/media_server/static/js"
|
||||
# Remove source maps from release
|
||||
rm -f "${DIST_DIR}/app/media_server/static/dist/"*.map
|
||||
|
||||
# Copy config example
|
||||
cp config.example.yaml "${DIST_DIR}/"
|
||||
cleanup_site_packages "$SITE_PACKAGES" "pyd" "dll"
|
||||
verify_frontend
|
||||
copy_app_files "$DIST_DIR"
|
||||
|
||||
# Copy scripts needed for auto-start
|
||||
mkdir -p "${DIST_DIR}/scripts"
|
||||
cp scripts/start-hidden.vbs "${DIST_DIR}/scripts/"
|
||||
|
||||
# --- Write version ---
|
||||
echo "$VERSION_CLEAN" > "${DIST_DIR}/VERSION"
|
||||
|
||||
# --- Create launcher ---
|
||||
cat > "${DIST_DIR}/media-server.bat" << 'LAUNCHER'
|
||||
@echo off
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -159,6 +159,17 @@ class Settings(BaseSettings):
|
||||
description="Loopback audio device name for visualizer (None = auto-detect)",
|
||||
)
|
||||
|
||||
# Update checker
|
||||
update_check_enabled: bool = Field(
|
||||
default=True,
|
||||
description="Check for new versions on startup and periodically",
|
||||
)
|
||||
update_check_interval: int = Field(
|
||||
default=21600,
|
||||
description="Update check interval in seconds (default: 6 hours)",
|
||||
ge=600,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def load_from_yaml(cls, path: Optional[Path] = None) -> "Settings":
|
||||
"""Load settings from a YAML configuration file."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -74,6 +74,18 @@ async def lifespan(app: FastAPI):
|
||||
await ws_manager.start_status_monitor(controller.get_status)
|
||||
logger.info("WebSocket status monitor started")
|
||||
|
||||
# Start update checker
|
||||
update_checker = None
|
||||
if settings.update_check_enabled:
|
||||
from .services.gitea_release_provider import GiteaReleaseProvider
|
||||
from .services.update_checker import UpdateChecker
|
||||
|
||||
provider = GiteaReleaseProvider()
|
||||
update_checker = UpdateChecker(provider, __version__)
|
||||
await update_checker.start(settings.update_check_interval)
|
||||
# Store globally so health endpoint can access cached result
|
||||
app.state.update_checker = update_checker
|
||||
|
||||
# Register audio visualizer (capture starts on-demand when clients subscribe)
|
||||
analyzer = None
|
||||
if settings.visualizer_enabled:
|
||||
@@ -92,6 +104,10 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
yield
|
||||
|
||||
# Stop update checker
|
||||
if update_checker is not None:
|
||||
await update_checker.stop()
|
||||
|
||||
# Stop audio visualizer
|
||||
await ws_manager.stop_audio_monitor()
|
||||
if analyzer and analyzer.running:
|
||||
|
||||
@@ -3,23 +3,31 @@
|
||||
import platform
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from .. import __version__
|
||||
from ..auth import auth_enabled
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check() -> dict[str, Any]:
|
||||
async def health_check(request: Request) -> dict[str, Any]:
|
||||
"""Health check endpoint - no authentication required.
|
||||
|
||||
Returns:
|
||||
Health status and server information
|
||||
"""
|
||||
return {
|
||||
result: dict[str, Any] = {
|
||||
"status": "healthy",
|
||||
"platform": platform.system(),
|
||||
"version": "1.0.0",
|
||||
"version": __version__,
|
||||
"auth_required": auth_enabled(),
|
||||
}
|
||||
|
||||
# Include cached update info if available
|
||||
checker = getattr(request.app.state, "update_checker", None)
|
||||
if checker is not None and checker.cached_update is not None:
|
||||
result["update_available"] = checker.cached_update
|
||||
|
||||
return result
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Provider-agnostic update checker service."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
from packaging.version import Version
|
||||
|
||||
from .release_provider import ReleaseProvider
|
||||
from .websocket_manager import ws_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PRE_PATTERN = re.compile(
|
||||
r"^(\d+\.\d+\.\d+)[-.]?(alpha|beta|rc)[.-]?(\d+)$", re.IGNORECASE
|
||||
)
|
||||
_PRE_MAP = {"alpha": "a", "beta": "b", "rc": "rc"}
|
||||
|
||||
|
||||
def _parse_version(raw: str) -> Version:
|
||||
"""Normalize a version tag to PEP 440 for correct comparison.
|
||||
|
||||
Examples:
|
||||
v0.3.0-alpha.1 → 0.3.0a1 (pre-release, sorts below 0.3.0)
|
||||
v0.3.0-rc.3 → 0.3.0rc3
|
||||
v1.0.0 → 1.0.0
|
||||
"""
|
||||
cleaned = raw.lstrip("v").strip()
|
||||
m = _PRE_PATTERN.match(cleaned)
|
||||
if m:
|
||||
base, pre_label, pre_num = m.group(1), m.group(2).lower(), m.group(3)
|
||||
cleaned = f"{base}{_PRE_MAP[pre_label]}{pre_num}"
|
||||
return Version(cleaned)
|
||||
|
||||
|
||||
class UpdateChecker:
|
||||
"""Periodically checks for new releases using a ReleaseProvider."""
|
||||
|
||||
def __init__(self, provider: ReleaseProvider, current_version: str) -> None:
|
||||
self._provider = provider
|
||||
self._current_version = current_version
|
||||
self._current_parsed = _parse_version(current_version)
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._cached_update: Optional[dict[str, Any]] = None
|
||||
|
||||
@property
|
||||
def cached_update(self) -> Optional[dict[str, Any]]:
|
||||
"""Return the cached update info, or None if up-to-date."""
|
||||
return self._cached_update
|
||||
|
||||
async def check_for_update(self) -> Optional[dict[str, Any]]:
|
||||
"""Check for a newer release.
|
||||
|
||||
Returns:
|
||||
Dict with current/latest/url if an update exists, None otherwise.
|
||||
"""
|
||||
release = await self._provider.get_latest_release()
|
||||
if release is None:
|
||||
return None
|
||||
|
||||
latest_parsed = _parse_version(release.version)
|
||||
if latest_parsed <= self._current_parsed:
|
||||
return None
|
||||
|
||||
return {
|
||||
"current": self._current_version,
|
||||
"latest": release.version,
|
||||
"url": release.url,
|
||||
}
|
||||
|
||||
async def start(self, interval: int) -> None:
|
||||
"""Start periodic update checking.
|
||||
|
||||
Checks immediately on start, then every `interval` seconds.
|
||||
"""
|
||||
if self._task is not None:
|
||||
return
|
||||
|
||||
self._task = asyncio.create_task(self._check_loop(interval))
|
||||
logger.info("Update checker started (interval: %ds)", interval)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop periodic update checking."""
|
||||
if self._task is not None:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
logger.info("Update checker stopped")
|
||||
|
||||
async def _check_loop(self, interval: int) -> None:
|
||||
"""Background loop that checks for updates periodically."""
|
||||
# Initial check with a small delay to let the server finish starting
|
||||
await asyncio.sleep(5)
|
||||
|
||||
while True:
|
||||
try:
|
||||
update = await self.check_for_update()
|
||||
|
||||
if update and update != self._cached_update:
|
||||
self._cached_update = update
|
||||
logger.info(
|
||||
"New version available: %s → %s (%s)",
|
||||
update["current"],
|
||||
update["latest"],
|
||||
update["url"],
|
||||
)
|
||||
await ws_manager.broadcast(
|
||||
{"type": "update_available", "data": update}
|
||||
)
|
||||
elif update is None and self._cached_update is not None:
|
||||
# Version was updated (or release removed) — clear cache
|
||||
self._cached_update = None
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning("Update check failed: %s", e)
|
||||
|
||||
try:
|
||||
await asyncio.sleep(interval)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
@@ -3490,6 +3490,61 @@ footer .separator {
|
||||
}
|
||||
}
|
||||
|
||||
/* Update Banner */
|
||||
.update-banner {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1001;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.update-banner:not(.hidden) {
|
||||
animation: bannerSlideIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
.update-banner.hidden {
|
||||
transform: translateY(-100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.update-banner a {
|
||||
color: #fff;
|
||||
text-decoration: underline;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.update-banner a:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.update-banner-close {
|
||||
background: none;
|
||||
color: #fff;
|
||||
font-size: 1.2rem;
|
||||
padding: 0 4px;
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.update-banner-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Connection Banner */
|
||||
.connection-banner {
|
||||
position: fixed;
|
||||
|
||||
@@ -114,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">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Connection Banner -->
|
||||
<div class="connection-banner hidden" id="connectionBanner">
|
||||
<span id="connectionBannerText"></span>
|
||||
|
||||
@@ -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
|
||||
// ============================================================
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -224,5 +224,7 @@
|
||||
"links.confirm.delete": "Are you sure you want to delete the link \"{name}\"?",
|
||||
"links.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?",
|
||||
"footer.created_by": "Created by",
|
||||
"footer.source_code": "Source Code"
|
||||
"footer.source_code": "Source Code",
|
||||
"update.available": "Update available: v{version}",
|
||||
"update.view_release": "View Release"
|
||||
}
|
||||
|
||||
@@ -224,5 +224,7 @@
|
||||
"links.confirm.delete": "Вы уверены, что хотите удалить ссылку \"{name}\"?",
|
||||
"links.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
|
||||
"footer.created_by": "Создано",
|
||||
"footer.source_code": "Исходный код"
|
||||
"footer.source_code": "Исходный код",
|
||||
"update.available": "Доступно обновление: v{version}",
|
||||
"update.view_release": "Перейти к релизу"
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "media-server"
|
||||
version = "1.0.0"
|
||||
version = "0.1.0"
|
||||
description = "REST API server for controlling system-wide media playback"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
@@ -32,6 +32,7 @@ dependencies = [
|
||||
"pyyaml>=6.0",
|
||||
"mutagen>=1.47.0",
|
||||
"pillow>=10.0.0",
|
||||
"packaging>=23.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
Reference in New Issue
Block a user