Compare commits

...

8 Commits

Author SHA1 Message Date
alexei.dolgolyov 81d5b0a402 ci: sparse-checkout RELEASE_NOTES.md in create-release job
Release / create-release (push) Successful in 3s
Lint & Test / test (push) Successful in 10s
Release / build-linux (push) Successful in 30s
Release / build-windows (push) Successful in 1m8s
2026-03-25 23:20:00 +03:00
alexei.dolgolyov d67e61ae39 ci: embed RELEASE_NOTES.md in Gitea release body
Lint & Test / test (push) Successful in 11s
Release / create-release (push) Successful in 2s
Release / build-linux (push) Successful in 36s
Release / build-windows (push) Successful in 1m21s
2026-03-25 23:17:01 +03:00
alexei.dolgolyov e795d224a8 chore: update release notes and version for v0.1.0
Lint & Test / test (push) Successful in 19s
Release / create-release (push) Successful in 8s
Release / build-linux (push) Successful in 36s
Release / build-windows (push) Has been cancelled
2026-03-25 23:10:42 +03:00
alexei.dolgolyov d0830cbbe5 ci: use warning annotation for existing release fallback
Lint & Test / test (push) Successful in 10s
2026-03-25 23:02:46 +03:00
alexei.dolgolyov 4ef11c8f00 chore: CI/build improvements and version detection
Lint & Test / test (push) Successful in 10s
- Rename GITEA_TOKEN to DEPLOY_TOKEN in release workflow
- Extract shared version detection into build-common.sh
- Use importlib.metadata for runtime version instead of hardcoded string
- Use PEP 440 parsing (packaging lib) for update version comparison
- Add packaging>=23.0 to dependencies
- Fix update banner close button alignment (CSS)
- Update CLAUDE.md with versioning docs and frontend rebuild notes
2026-03-25 15:43:27 +03:00
alexei.dolgolyov fb56e6cdc0 feat: persist audio capture device selection to config.yaml
Release / create-release (push) Successful in 1s
Lint & Test / test (push) Successful in 9s
Release / build-linux (push) Successful in 26s
Release / build-windows (push) Successful in 1m3s
Device choice now survives server restarts. Falls back to default
if the saved device is no longer available.
2026-03-25 11:50:01 +03:00
alexei.dolgolyov ff6712620e chore: bump version to 1.0.1
Release / create-release (push) Successful in 2s
Lint & Test / test (push) Successful in 38s
Release / build-linux (push) Successful in 2m10s
Release / build-windows (push) Successful in 2m48s
2026-03-25 11:37:50 +03:00
alexei.dolgolyov 795a15cb8b feat: add update-available notification system
Lint & Test / test (push) Successful in 10s
- Abstract ReleaseProvider protocol for platform-agnostic version checking
- GiteaReleaseProvider implementation using stdlib urllib
- UpdateChecker service with periodic background checks and WS broadcast
- Persistent dismissible banner in Web UI when a new version is detected
- Health endpoint now returns cached update info
- Configurable via update_check_enabled and update_check_interval settings
- i18n support (EN/RU)
2026-03-25 11:37:09 +03:00
22 changed files with 798 additions and 121 deletions
+90 -19
View File
@@ -13,10 +13,16 @@ jobs:
release_id: ${{ steps.create.outputs.release_id }}
version: ${{ steps.create.outputs.version }}
steps:
- name: Fetch RELEASE_NOTES.md only
uses: actions/checkout@v4
with:
sparse-checkout: RELEASE_NOTES.md
sparse-checkout-cone-mode: false
- name: Create Gitea release
id: create
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: |
TAG="${{ gitea.ref_name }}"
VERSION="${TAG#v}"
@@ -27,21 +33,40 @@ jobs:
IS_PRE="true"
fi
if [ -f RELEASE_NOTES.md ]; then
export RELEASE_NOTES=$(cat RELEASE_NOTES.md)
echo "Found RELEASE_NOTES.md"
else
export RELEASE_NOTES=""
echo "No RELEASE_NOTES.md found"
fi
BODY_JSON=$(python3 -c "
import json, textwrap
import json, os, textwrap
tag = '$TAG'
body = 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"
+20 -5
View File
@@ -41,10 +41,20 @@ Unregister-ScheduledTask -TaskName "MediaServer" -Confirm:$false
**When restart is NOT needed:**
- Static file changes (`*.html`, `*.css`, `*.js`, `*.json`) - browser refresh is enough
- Static file changes (`*.html`, `*.css`, `*.json`) - browser refresh is enough
- README or documentation updates
- Changes to install/service scripts (only affects new installations)
### Frontend Rebuild After JS Changes
**CRITICAL:** The frontend is bundled via esbuild into `static/dist/app.bundle.js`. After modifying ANY JavaScript file in `media_server/static/js/`, you **MUST** run:
```bash
npm run build
```
Raw JS file edits have **NO effect** until the bundle is rebuilt. After rebuilding, a browser hard-refresh (Ctrl+Shift+R) is sufficient — no server restart needed.
**How to restart during development:**
1. Find the running server process:
@@ -124,12 +134,17 @@ To add support for a new language:
## Versioning
Version is tracked in two files that must be kept in sync:
**`pyproject.toml`** is the single source of truth for the version string.
- `pyproject.toml` - `[project].version`
- `media_server/__init__.py` - `__version__`
At runtime, `media_server/__init__.py` reads the version via `importlib.metadata.version()` — no manual syncing needed.
When releasing a new version, update both files with the same version string.
Version flow:
1. `git tag v0.3.0` → CI reads the tag
2. Build scripts stamp `pyproject.toml` with the clean version via `sed`
3. `pip install` bakes the version into package metadata
4. `importlib.metadata.version("media-server")` reads it at runtime
When bumping the version for a new release, only `pyproject.toml` needs to be updated.
**Important:** After making any changes, always ask the user if the version needs to be incremented.
+148
View File
@@ -0,0 +1,148 @@
## v0.1.0 (2026-03-25)
Initial public release of Media Server — a standalone REST API server (FastAPI) for controlling system-wide media playback on Windows, Linux, macOS, and Android.
### Features
- Remote media control: play, pause, stop, next, previous, volume, seek ([83acf5f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/83acf5f))
- Built-in Web UI for media control and monitoring ([a0d138b](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/a0d138b))
- Media browser with grid/compact/list views and single-click playback ([e16674c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/e16674c))
- Low-latency volume control via WebSocket ([32b058c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/32b058c))
- Audio visualizer with spectrogram, beat-reactive art, and device selection ([0691e3d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/0691e3d))
- Dynamic WebGL background with audio reactivity ([be48318](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/be48318))
- Runtime script management with Home Assistant integration ([d7c5994](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d7c5994))
- Typed script parameters with validation and icon-grid selector ([1410a8d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/1410a8d))
- Callback management API/UI and theme support ([a0af855](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/a0af855))
- Multi-token authentication with client labels ([71a0a6e](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/71a0a6e))
- Optional authentication — no tokens = no auth ([4d1bb78](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4d1bb78))
- Internationalization (i18n) with English and Russian locales ([9bbb8e1](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/9bbb8e1))
- PWA support: installable standalone app with safe area handling ([a20812e](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/a20812e))
- System tray icon with Show UI, Restart, and Shutdown actions ([6500d6f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/6500d6f), [3f14512](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/3f14512))
- Display brightness and power control ([a568608](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/a568608))
- Custom accent color picker and primary display indicator ([397d38a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/397d38a))
- 3D album art rotation and vinyl desaturation effect ([4112367](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4112367))
- Friendly media source names with brand icons ([73a6f38](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/73a6f38))
- Browser search/filter for media items ([5f474d6](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/5f474d6))
- Header quick links with CRUD management ([99dbbb1](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/99dbbb1))
- Swagger API docs button in header toolbar ([2b1e09d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/2b1e09d))
- Update-available notification system ([795a15c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/795a15c))
- Persist audio capture device selection to config ([fb56e6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/fb56e6c))
- UI animations: dialogs, tabs, settings, browser stagger, banner pulse ([3cfc437](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/3cfc437))
- Tabbed UI with browse caching and bottom mini player ([98a33bc](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/98a33bc))
- Slider tracks tinted with accent color ([1c0a011](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/1c0a011))
### Bug Fixes
- Tray restart uses `python -m` for reliable process respawn ([415231f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/415231f))
- Tray main-thread message loop, numpy <2.0 pin, installer config copy ([4021837](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4021837))
- Loopback device status showing 'Unavailable' after change ([0eca829](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/0eca829))
- Error handling for unavailable network shares ([d1ec27c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d1ec27c))
- HTTPException handling in folder endpoints ([c5f8c7a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/c5f8c7a))
- FOUC (Flash of Untranslated Content) issues ([4f8f59d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4f8f59d))
- Windows Task Scheduler auto-start ([8077181](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/8077181))
- Vinyl angle persistence on toggle ([00d313d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/00d313d))
---
### Development / Internal
#### CI/Build
- CI/CD pipelines with Gitea Actions, NSIS installer, ES module bundling, ruff linting ([5439af1](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/5439af1))
- Linux build in release workflow ([ddd8788](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddd8788))
- NSIS installer with custom icon, launch-after-install, running-instance detection ([26b5f74](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/26b5f74))
- CI/build improvements and version detection ([4ef11c8](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4ef11c8))
- Warning annotation for existing release fallback ([d0830cb](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d0830cb))
#### Refactoring
- Modular frontend: refactor monolithic app.js into 8 modules ([92d6709](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/92d6709))
- Codebase audit: stability, performance, accessibility fixes ([9404b37](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/9404b37))
---
<details>
<summary>All Commits (82)</summary>
| Hash | Message | Author |
|------|---------|--------|
| [d0830cb](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d0830cb) | ci: use warning annotation for existing release fallback | alexei.dolgolyov |
| [4ef11c8](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4ef11c8) | chore: CI/build improvements and version detection | alexei.dolgolyov |
| [fb56e6c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/fb56e6c) | feat: persist audio capture device selection to config.yaml | alexei.dolgolyov |
| [ff67126](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ff67126) | chore: bump version to 1.0.1 | alexei.dolgolyov |
| [795a15c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/795a15c) | feat: add update-available notification system | alexei.dolgolyov |
| [1410a8d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/1410a8d) | feat: typed script parameters with validation and icon-grid selector | alexei.dolgolyov |
| [1c0a011](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/1c0a011) | feat: tint slider tracks with 15% accent color | alexei.dolgolyov |
| [2b1e09d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/2b1e09d) | feat: add Swagger API docs button to header toolbar | alexei.dolgolyov |
| [415231f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/415231f) | fix: tray restart uses python -m for reliable process respawn | alexei.dolgolyov |
| [32e2ff5](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/32e2ff5) | fix: add --only-binary to pip download fallback (CI compatibility) | alexei.dolgolyov |
| [309f547](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/309f547) | feat: add default MDI icons to example config scripts | alexei.dolgolyov |
| [4021837](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4021837) | fix: tray main-thread message loop, numpy <2.0 pin, installer config copy | alexei.dolgolyov |
| [d7e10b1](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d7e10b1) | fix: interpolate tag in release body template (f-string) | alexei.dolgolyov |
| [3f14512](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/3f14512) | feat: add Restart and Shutdown tray actions with confirmation dialogs | alexei.dolgolyov |
| [26b5f74](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/26b5f74) | feat: improve installer with custom icon, launch-after-install, and running-instance detection | alexei.dolgolyov |
| [1f6e4f6](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/1f6e4f6) | feat: add Launch option to installer finish page | alexei.dolgolyov |
| [6500d6f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/6500d6f) | feat: add system tray icon with Show UI and Exit actions | alexei.dolgolyov |
| [4d1bb78](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4d1bb78) | feat: make authentication optional — no tokens = no auth | alexei.dolgolyov |
| [f80f6e9](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/f80f6e9) | fix: correct ._pth path in Windows build script | alexei.dolgolyov |
| [0216851](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/0216851) | docs: comprehensive README update with all API endpoints and features | alexei.dolgolyov |
| [c76ffb9](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/c76ffb9) | fix: handle existing release in create-release job | alexei.dolgolyov |
| [ddd8788](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ddd8788) | Add Linux build to release workflow, fix pytest exit code 5 | alexei.dolgolyov |
| [5439af1](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/5439af1) | Add CI/CD pipelines, NSIS installer, ES module bundling, and ruff linting | alexei.dolgolyov |
| [be48318](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/be48318) | Add dynamic WebGL background with audio reactivity | alexei.dolgolyov |
| [0eca829](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/0eca829) | Fix loopback device status showing 'Unavailable' after change | alexei.dolgolyov |
| [3cfc437](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/3cfc437) | Add UI animations: dialogs, tabs, settings, browser stagger, banner pulse | alexei.dolgolyov |
| [a20812e](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/a20812e) | Add PWA support: installable standalone app with safe area handling | alexei.dolgolyov |
| [652f10f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/652f10f) | Reduce visualizer latency, tighten UI paddings, fix mobile browser toolbar | alexei.dolgolyov |
| [3846610](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/3846610) | On-demand audio visualizer capture + UI fixes | alexei.dolgolyov |
| [92d6709](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/92d6709) | Refactor monolithic app.js into 8 modular files | alexei.dolgolyov |
| [9404b37](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/9404b37) | Codebase audit fixes: stability, performance, accessibility | alexei.dolgolyov |
| [73a6f38](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/73a6f38) | Add friendly media source names with brand icons | alexei.dolgolyov |
| [b11edc2](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/b11edc2) | Redesign header as pill-shaped toolbar group | alexei.dolgolyov |
| [3d01d98](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/3d01d98) | Style audio device select, hide mini player volume on tablet | alexei.dolgolyov |
| [4112367](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4112367) | Add 3D album art rotation and vinyl desaturation effect | alexei.dolgolyov |
| [00d313d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/00d313d) | Fix vinyl angle persistence on toggle, group player toggle buttons | alexei.dolgolyov |
| [0691e3d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/0691e3d) | Add audio visualizer with spectrogram, beat-reactive art, and device selection | alexei.dolgolyov |
| [8a8f00f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/8a8f00f) | Persist vinyl rotation angle across page reloads | alexei.dolgolyov |
| [397d38a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/397d38a) | Add primary display indicator, custom accent color picker, restart script | alexei.dolgolyov |
| [adf2d93](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/adf2d93) | Consolidate tabs, Quick Access links, mini player nav, link descriptions | alexei.dolgolyov |
| [99dbbb1](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/99dbbb1) | Add header quick links with CRUD management and icon enhancements | alexei.dolgolyov |
| [6f6a4e4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/6f6a4e4) | Improve slider track visibility in both themes | alexei.dolgolyov |
| [a568608](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/a568608) | Add display brightness and power control | alexei.dolgolyov |
| [03a1b30](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/03a1b30) | Comprehensive WebUI improvements: security, UX, accessibility, performance | alexei.dolgolyov |
| [ef1935c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/ef1935c) | Update README with current features | alexei.dolgolyov |
| [7ee0a60](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/7ee0a60) | Update media browser screenshot | alexei.dolgolyov |
| [7f28145](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/7f28145) | Update documentation screenshots | alexei.dolgolyov |
| [80d4dbc](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/80d4dbc) | Fix browser grid card sizing | alexei.dolgolyov |
| [caf24db](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/caf24db) | Compact browser grid cards | alexei.dolgolyov |
| [babdb61](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/babdb61) | Update media-server: Vinyl record mode and accent color picker | alexei.dolgolyov |
| [65b513c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/65b513c) | Update media-server: UI polish and bug fixes | alexei.dolgolyov |
| [84b985e](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/84b985e) | Backend optimizations, frontend optimizations, and UI design improvements | alexei.dolgolyov |
| [d1ec27c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d1ec27c) | Improve error handling for unavailable network shares | alexei.dolgolyov |
| [13df69a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/13df69a) | Show media title (Artist Title) instead of filename when available | alexei.dolgolyov |
| [4c13322](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4c13322) | Show bitrate in browser, remove type labels and Play All text | alexei.dolgolyov |
| [5f474d6](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/5f474d6) | Add browser search/filter for media items | alexei.dolgolyov |
| [98a33bc](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/98a33bc) | Tabbed UI, browse caching, and bottom mini player | alexei.dolgolyov |
| [8db40d3](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/8db40d3) | UI polish: refresh button, negative thumbnail cache, and style fixes | alexei.dolgolyov |
| [f275240](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/f275240) | Add Play All, home navigation, and UI improvements | alexei.dolgolyov |
| [e16674c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/e16674c) | Add media browser with grid/compact/list views and single-click playback | alexei.dolgolyov |
| [32b058c](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/32b058c) | Add low-latency volume control via WebSocket | alexei.dolgolyov |
| [c5f8c7a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/c5f8c7a) | Fix HTTPException handling in folder endpoints and install script path | alexei.dolgolyov |
| [8d15a2a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/8d15a2a) | Update Web UI: Header redesign, thumbnail fix, and title fallback | alexei.dolgolyov |
| [1cb83ea](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/1cb83ea) | Add screenshots to README | alexei.dolgolyov |
| [62c42f7](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/62c42f7) | Move install_task_windows.ps1 to scripts folder | alexei.dolgolyov |
| [eb2aed4](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/eb2aed4) | Update media browser UI with fade-in animations and improvements | alexei.dolgolyov |
| [7c631d0](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/7c631d0) | Add media browser feature with UI improvements | alexei.dolgolyov |
| [d5ec5c6](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d5ec5c6) | Update Web UI: Improve volume slider responsiveness | alexei.dolgolyov |
| [29e0618](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/29e0618) | Update Web UI: Add server management scripts and improve UX | alexei.dolgolyov |
| [4f8f59d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4f8f59d) | Update media-server: Fix FOUC (Flash of Untranslated Content) issues | alexei.dolgolyov |
| [40c2c11](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/40c2c11) | Update media-server: Improve script/callback table layout and command editor UX | alexei.dolgolyov |
| [4635cac](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4635cac) | Update media-server: Add execution timing and improve script/callback execution UI | alexei.dolgolyov |
| [957a177](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/957a177) | Update media-server: Add backdrop click-to-close for dialogs | alexei.dolgolyov |
| [8077181](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/8077181) | Fix Windows Task Scheduler auto-start | alexei.dolgolyov |
| [9bbb8e1](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/9bbb8e1) | Add internationalization (i18n) support with English and Russian locales | alexei.dolgolyov |
| [a0af855](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/a0af855) | Add callback management API/UI and theme support | alexei.dolgolyov |
| [d7c5994](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d7c5994) | Add runtime script management with Home Assistant integration | alexei.dolgolyov |
| [71a0a6e](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/71a0a6e) | Add multi-token authentication with client labels | alexei.dolgolyov |
| [5342cff](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/5342cff) | Add script execution to Web UI | alexei.dolgolyov |
| [a0d138b](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/a0d138b) | Add built-in Web UI for media control and monitoring | alexei.dolgolyov |
| [1a1cfba](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/1a1cfba) | Add callbacks support for all media actions | alexei.dolgolyov |
| [83acf5f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/83acf5f) | Initial commit: Media Server for remote media control | alexei.dolgolyov |
</details>
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# build-common.sh — shared functions for platform build scripts
# Source this file, do not execute directly.
# --- Version detection ---
# Fallback chain: CLI arg → git tag → CI env var → pyproject.toml
detect_version() {
local arg="${1:-}"
VERSION="${arg}"
if [ -z "$VERSION" ]; then
VERSION=$(git describe --tags --exact-match 2>/dev/null || true)
fi
if [ -z "$VERSION" ]; then
VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
fi
if [ -z "$VERSION" ]; then
VERSION=$(grep -oP '^version\s*=\s*"\K[^"]+' \
pyproject.toml 2>/dev/null || echo "0.0.0")
fi
VERSION_CLEAN="${VERSION#v}"
# Stamp version into pyproject.toml (single source of truth)
sed -i "s/^version = .*/version = \"${VERSION_CLEAN}\"/" pyproject.toml
}
# --- Clean dist/build directories ---
clean_dist() {
rm -rf dist build
mkdir -p "$@"
}
# --- Verify frontend bundle exists ---
verify_frontend() {
if [ ! -f "media_server/static/dist/app.bundle.js" ]; then
echo "ERROR: Frontend bundle not found. Run 'npm ci && npm run build' first."
exit 1
fi
}
# --- Copy application files into dist ---
# Args: $1 = DIST_DIR
copy_app_files() {
local dist_dir="$1"
echo "Copying application files..."
mkdir -p "${dist_dir}/app"
cp -r media_server "${dist_dir}/app/"
# Remove source JS (bundle is in dist/)
rm -rf "${dist_dir}/app/media_server/static/js"
# Remove source maps from release
rm -f "${dist_dir}/app/media_server/static/dist/"*.map
# Copy config example
cp config.example.yaml "${dist_dir}/"
# Write version file
echo "$VERSION_CLEAN" > "${dist_dir}/VERSION"
}
# --- Clean up site-packages for smaller distribution ---
# Args: $1 = site-packages path, $2 = ext suffix (pyd|so), $3 = lib suffix (dll|so)
# Windows: cleanup_site_packages "$SP" "pyd" "dll"
# Linux: cleanup_site_packages "$SP" "so" "so"
cleanup_site_packages() {
local sp_dir="$1"
local ext_suffix="${2:-so}"
local lib_suffix="${3:-so}"
echo "Optimizing size..."
# Generic cleanup
find "$sp_dir" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find "$sp_dir" -type d -name tests -exec rm -rf {} + 2>/dev/null || true
find "$sp_dir" -type d -name "*.dist-info" -exec rm -rf {} + 2>/dev/null || true
find "$sp_dir" -name "*.pyi" -delete 2>/dev/null || true
rm -rf "$sp_dir"/{pip,setuptools,pkg_resources,_distutils_hack}* 2>/dev/null || true
# Trim numpy if present
for mod in polynomial linalg ma lib distutils f2py typing _pyinstaller; do
rm -rf "$sp_dir/numpy/$mod" 2>/dev/null || true
done
# Trim OpenCV if present
rm -f "$sp_dir"/cv2/opencv_videoio_ffmpeg*."$lib_suffix" 2>/dev/null || true
rm -rf "$sp_dir"/cv2/{data,gapi,misc,utils,typing_stubs,typing} 2>/dev/null || true
# Trim Pillow unused plugins if present
rm -rf "$sp_dir"/PIL/{FpxImagePlugin,MicImagePlugin,McIdasImagePlugin}* 2>/dev/null || true
# Trim zeroconf service DB if present
rm -rf "$sp_dir"/zeroconf/_services 2>/dev/null || true
# Strip debug symbols from native extensions
find "$sp_dir" -name "*.$ext_suffix" -exec strip --strip-debug {} \; 2>/dev/null || true
# Remove .py source files (keep .pyc only) — saves ~30-40% on pure-Python packages
find "$sp_dir" -name "*.py" ! -name "__init__.py" -delete 2>/dev/null || true
}
+8 -38
View File
@@ -4,37 +4,17 @@ set -euo pipefail
# Build Linux distribution (self-contained venv + tarball)
# Usage: ./build-dist-linux.sh [VERSION]
# --- Version detection ---
VERSION="${1:-}"
source "$(dirname "$0")/build-common.sh"
if [ -z "$VERSION" ]; then
VERSION=$(git describe --tags --exact-match 2>/dev/null || true)
fi
if [ -z "$VERSION" ]; then
VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
fi
if [ -z "$VERSION" ]; then
VERSION=$(grep -oP '__version__\s*=\s*"\K[^"]+' \
media_server/__init__.py 2>/dev/null || echo "0.0.0")
fi
VERSION_CLEAN="${VERSION#v}"
detect_version "${1:-}"
echo "Building Media Server v${VERSION_CLEAN} for Linux"
# --- Configuration ---
DIST_DIR="dist/media-server"
BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-linux-x64"
rm -rf dist build
mkdir -p "${DIST_DIR}" build
# --- Verify frontend bundle ---
if [ ! -f "media_server/static/dist/app.bundle.js" ]; then
echo "ERROR: Frontend bundle not found. Run 'npm ci && npm run build' first."
exit 1
fi
clean_dist "${DIST_DIR}" build
verify_frontend
# --- Create self-contained virtualenv ---
echo "Creating virtualenv..."
@@ -49,21 +29,11 @@ rm -rf "${DIST_DIR}"/venv/lib/python*/site-packages/media_server*.dist-info
deactivate
# --- Copy application ---
echo "Copying application files..."
mkdir -p "${DIST_DIR}/app"
cp -r media_server "${DIST_DIR}/app/"
# Trim venv site-packages
LINUX_SP=$(echo "${DIST_DIR}"/venv/lib/python*/site-packages)
cleanup_site_packages "$LINUX_SP" "so" "so"
# Remove source JS (bundle is in dist/)
rm -rf "${DIST_DIR}/app/media_server/static/js"
# Remove source maps from release
rm -f "${DIST_DIR}/app/media_server/static/dist/"*.map
# Copy config example
cp config.example.yaml "${DIST_DIR}/"
# --- Write version ---
echo "$VERSION_CLEAN" > "${DIST_DIR}/VERSION"
copy_app_files "$DIST_DIR"
# --- Create launcher ---
cat > "${DIST_DIR}/media-server.sh" << 'LAUNCHER'
+7 -50
View File
@@ -4,23 +4,9 @@ set -euo pipefail
# Cross-build Windows distribution on Linux
# Usage: ./build-dist-windows.sh [VERSION]
# --- Version detection ---
VERSION="${1:-}"
source "$(dirname "$0")/build-common.sh"
if [ -z "$VERSION" ]; then
VERSION=$(git describe --tags --exact-match 2>/dev/null || true)
fi
if [ -z "$VERSION" ]; then
VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
fi
if [ -z "$VERSION" ]; then
VERSION=$(grep -oP '__version__\s*=\s*"\K[^"]+' \
media_server/__init__.py 2>/dev/null || echo "0.0.0")
fi
VERSION_CLEAN="${VERSION#v}"
detect_version "${1:-}"
echo "Building Media Server v${VERSION_CLEAN} for Windows"
# --- Configuration ---
@@ -31,8 +17,7 @@ WHEEL_DIR="build/win-wheels"
SITE_PACKAGES="${DIST_DIR}/python/Lib/site-packages"
BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-win-x64"
rm -rf dist build
mkdir -p "${DIST_DIR}" "${WHEEL_DIR}" "${SITE_PACKAGES}"
clean_dist "${DIST_DIR}" "${WHEEL_DIR}" "${SITE_PACKAGES}"
# --- Download embedded Python ---
echo "Downloading embedded Python ${PYTHON_VERSION}..."
@@ -58,6 +43,7 @@ CORE_DEPS=(
"pyyaml>=6.0"
"mutagen>=1.47.0"
"pillow>=10.0.0"
"packaging>=23.0"
)
# Windows-specific dependencies
@@ -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
+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()
+11
View File
@@ -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."""
+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()
+16
View File
@@ -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:
+12 -4
View File
@@ -3,23 +3,31 @@
import platform
from typing import Any
from fastapi import APIRouter
from fastapi import APIRouter, Request
from .. import __version__
from ..auth import auth_enabled
router = APIRouter(prefix="/api", tags=["health"])
@router.get("/health")
async def health_check() -> dict[str, Any]:
async def health_check(request: Request) -> dict[str, Any]:
"""Health check endpoint - no authentication required.
Returns:
Health status and server information
"""
return {
result: dict[str, Any] = {
"status": "healthy",
"platform": platform.system(),
"version": "1.0.0",
"version": __version__,
"auth_required": auth_enabled(),
}
# Include cached update info if available
checker = getattr(request.app.state, "update_checker", None)
if checker is not None and checker.cached_update is not None:
result["update_available"] = checker.cached_update
return result
+6
View File
@@ -307,6 +307,12 @@ async def set_visualizer_device(
# set_device() handles stop/start internally if capture was running
success = analyzer.set_device(device_name)
# Persist selection to config.yaml so it survives server restarts
if success:
from ..config_manager import config_manager
config_manager.set_setting("visualizer_device", device_name)
return {
"success": success,
"current_device": analyzer.current_device,
@@ -0,0 +1,77 @@
"""Gitea release provider implementation."""
import json
import logging
import urllib.error
import urllib.request
from typing import Optional
from .release_provider import ReleaseInfo, ReleaseProvider
logger = logging.getLogger(__name__)
# Default repository coordinates
_DEFAULT_BASE_URL = "https://git.dolgolyov-family.by"
_DEFAULT_OWNER = "alexei.dolgolyov"
_DEFAULT_REPO = "media-player-server"
class GiteaReleaseProvider(ReleaseProvider):
"""Fetches the latest release from a Gitea repository."""
def __init__(
self,
base_url: str = _DEFAULT_BASE_URL,
owner: str = _DEFAULT_OWNER,
repo: str = _DEFAULT_REPO,
timeout: float = 10.0,
) -> None:
self._api_url = f"{base_url}/api/v1/repos/{owner}/{repo}/releases"
self._release_page_url = f"{base_url}/{owner}/{repo}/releases/tag"
self._timeout = timeout
async def get_latest_release(self) -> Optional[ReleaseInfo]:
"""Fetch the latest stable release from Gitea API.
Returns:
ReleaseInfo for the latest non-prerelease, or None on failure.
"""
import asyncio
try:
data = await asyncio.to_thread(self._fetch_releases)
except Exception as e:
logger.warning("Failed to check for updates: %s", e)
return None
if not data:
return None
# Find the first non-prerelease, non-draft release
for release in data:
if release.get("draft") or release.get("prerelease"):
continue
tag = release.get("tag_name", "")
version = tag.lstrip("v")
if not version:
continue
return ReleaseInfo(
version=version,
url=f"{self._release_page_url}/{tag}",
prerelease=False,
)
logger.debug("No stable releases found")
return None
def _fetch_releases(self) -> list[dict]:
"""Synchronous HTTP fetch of releases (run in thread)."""
url = f"{self._api_url}?limit=5"
req = urllib.request.Request(url, headers={"Accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
return json.loads(resp.read().decode())
except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError, OSError) as e:
raise RuntimeError(f"Gitea API request failed: {e}") from e
+29
View File
@@ -0,0 +1,29 @@
"""Abstract release provider interface for version checking."""
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True)
class ReleaseInfo:
"""Version-provider-agnostic release metadata."""
version: str # e.g. "1.1.0" (no "v" prefix)
url: str # release page URL
prerelease: bool
class ReleaseProvider(Protocol):
"""Abstract interface for fetching the latest release.
Implement this protocol to support different hosting platforms
(Gitea, GitHub, GitLab, etc.).
"""
async def get_latest_release(self) -> ReleaseInfo | None:
"""Fetch the latest stable release.
Returns:
ReleaseInfo if a release was found, None on failure.
"""
...
+126
View File
@@ -0,0 +1,126 @@
"""Provider-agnostic update checker service."""
import asyncio
import logging
import re
from typing import Any, Optional
from packaging.version import Version
from .release_provider import ReleaseProvider
from .websocket_manager import ws_manager
logger = logging.getLogger(__name__)
_PRE_PATTERN = re.compile(
r"^(\d+\.\d+\.\d+)[-.]?(alpha|beta|rc)[.-]?(\d+)$", re.IGNORECASE
)
_PRE_MAP = {"alpha": "a", "beta": "b", "rc": "rc"}
def _parse_version(raw: str) -> Version:
"""Normalize a version tag to PEP 440 for correct comparison.
Examples:
v0.3.0-alpha.1 → 0.3.0a1 (pre-release, sorts below 0.3.0)
v0.3.0-rc.3 → 0.3.0rc3
v1.0.0 → 1.0.0
"""
cleaned = raw.lstrip("v").strip()
m = _PRE_PATTERN.match(cleaned)
if m:
base, pre_label, pre_num = m.group(1), m.group(2).lower(), m.group(3)
cleaned = f"{base}{_PRE_MAP[pre_label]}{pre_num}"
return Version(cleaned)
class UpdateChecker:
"""Periodically checks for new releases using a ReleaseProvider."""
def __init__(self, provider: ReleaseProvider, current_version: str) -> None:
self._provider = provider
self._current_version = current_version
self._current_parsed = _parse_version(current_version)
self._task: Optional[asyncio.Task] = None
self._cached_update: Optional[dict[str, Any]] = None
@property
def cached_update(self) -> Optional[dict[str, Any]]:
"""Return the cached update info, or None if up-to-date."""
return self._cached_update
async def check_for_update(self) -> Optional[dict[str, Any]]:
"""Check for a newer release.
Returns:
Dict with current/latest/url if an update exists, None otherwise.
"""
release = await self._provider.get_latest_release()
if release is None:
return None
latest_parsed = _parse_version(release.version)
if latest_parsed <= self._current_parsed:
return None
return {
"current": self._current_version,
"latest": release.version,
"url": release.url,
}
async def start(self, interval: int) -> None:
"""Start periodic update checking.
Checks immediately on start, then every `interval` seconds.
"""
if self._task is not None:
return
self._task = asyncio.create_task(self._check_loop(interval))
logger.info("Update checker started (interval: %ds)", interval)
async def stop(self) -> None:
"""Stop periodic update checking."""
if self._task is not None:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
logger.info("Update checker stopped")
async def _check_loop(self, interval: int) -> None:
"""Background loop that checks for updates periodically."""
# Initial check with a small delay to let the server finish starting
await asyncio.sleep(5)
while True:
try:
update = await self.check_for_update()
if update and update != self._cached_update:
self._cached_update = update
logger.info(
"New version available: %s%s (%s)",
update["current"],
update["latest"],
update["url"],
)
await ws_manager.broadcast(
{"type": "update_available", "data": update}
)
elif update is None and self._cached_update is not None:
# Version was updated (or release removed) — clear cache
self._cached_update = None
except asyncio.CancelledError:
break
except Exception as e:
logger.warning("Update check failed: %s", e)
try:
await asyncio.sleep(interval)
except asyncio.CancelledError:
break
+55
View File
@@ -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;
+7
View File
@@ -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">&times;</button>
</div>
<!-- Connection Banner -->
<div class="connection-banner hidden" id="connectionBanner">
<span id="connectionBannerText"></span>
+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
// ============================================================
+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') {
+3 -1
View File
@@ -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"
}
+3 -1
View File
@@ -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
View File
@@ -1,6 +1,6 @@
[project]
name = "media-server"
version = "1.0.0"
version = "0.1.0"
description = "REST API server for controlling system-wide media playback"
readme = "README.md"
license = { text = "MIT" }
@@ -32,6 +32,7 @@ dependencies = [
"pyyaml>=6.0",
"mutagen>=1.47.0",
"pillow>=10.0.0",
"packaging>=23.0",
]
[project.optional-dependencies]