Compare commits
13 Commits
1410a8d2cb
...
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 0256be816e | |||
| 5219263388 | |||
| 98163ea5a9 | |||
| 5e5e5036c0 | |||
| 4f9e99e10b | |||
| 81d5b0a402 | |||
| d67e61ae39 | |||
| e795d224a8 | |||
| d0830cbbe5 | |||
| 4ef11c8f00 | |||
| fb56e6cdc0 | |||
| ff6712620e | |||
| 795a15cb8b |
@@ -0,0 +1,72 @@
|
|||||||
|
name: Build Artifacts
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Version label (e.g. dev, 0.3.0-test)'
|
||||||
|
required: false
|
||||||
|
default: 'dev'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-windows:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
run: npm ci && npm run build
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Install build tools
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends nsis zip
|
||||||
|
|
||||||
|
- name: Build Windows distribution
|
||||||
|
run: |
|
||||||
|
chmod +x build-dist-windows.sh
|
||||||
|
./build-dist-windows.sh "v${{ inputs.version }}"
|
||||||
|
|
||||||
|
- name: Build NSIS installer
|
||||||
|
run: makensis -DVERSION="${{ inputs.version }}" installer.nsi
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: MediaServer-${{ inputs.version }}-win-x64
|
||||||
|
path: |
|
||||||
|
build/MediaServer-*.zip
|
||||||
|
build/MediaServer-*-setup.exe
|
||||||
|
retention-days: 90
|
||||||
|
|
||||||
|
build-linux:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
run: npm ci && npm run build
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Build Linux distribution
|
||||||
|
run: |
|
||||||
|
chmod +x build-dist-linux.sh
|
||||||
|
./build-dist-linux.sh "v${{ inputs.version }}"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: MediaServer-${{ inputs.version }}-linux-x64
|
||||||
|
path: build/MediaServer-*-linux-x64.tar.gz
|
||||||
|
retention-days: 90
|
||||||
@@ -13,10 +13,16 @@ jobs:
|
|||||||
release_id: ${{ steps.create.outputs.release_id }}
|
release_id: ${{ steps.create.outputs.release_id }}
|
||||||
version: ${{ steps.create.outputs.version }}
|
version: ${{ steps.create.outputs.version }}
|
||||||
steps:
|
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
|
- name: Create Gitea release
|
||||||
id: create
|
id: create
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
TAG="${{ gitea.ref_name }}"
|
TAG="${{ gitea.ref_name }}"
|
||||||
VERSION="${TAG#v}"
|
VERSION="${TAG#v}"
|
||||||
@@ -27,21 +33,40 @@ jobs:
|
|||||||
IS_PRE="true"
|
IS_PRE="true"
|
||||||
fi
|
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 "
|
BODY_JSON=$(python3 -c "
|
||||||
import json, textwrap
|
import json, os, textwrap
|
||||||
|
|
||||||
tag = '$TAG'
|
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 |
|
| Platform | File |
|
||||||
|----------|------|
|
|----------|------|
|
||||||
| Windows (installer) | \`MediaServer-{tag}-setup.exe\` |
|
| Windows (installer) | \`MediaServer-{tag}-setup.exe\` |
|
||||||
| Windows (portable) | \`MediaServer-{tag}-win-x64.zip\` |
|
| Windows (portable) | \`MediaServer-{tag}-win-x64.zip\` |
|
||||||
| Linux | \`MediaServer-{tag}-linux-x64.tar.gz\` |
|
| Linux | \`MediaServer-{tag}-linux-x64.tar.gz\` |
|
||||||
'''
|
''').strip())
|
||||||
print(json.dumps(textwrap.dedent(body).strip()))
|
|
||||||
|
print(json.dumps('\n\n'.join(sections)))
|
||||||
")
|
")
|
||||||
|
|
||||||
RELEASE=$(curl -s -X POST "$BASE_URL/releases" \
|
RELEASE=$(curl -s -X POST "$BASE_URL/releases" \
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
-H "Authorization: token $DEPLOY_TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{
|
-d "{
|
||||||
\"tag_name\": \"$TAG\",
|
\"tag_name\": \"$TAG\",
|
||||||
@@ -62,9 +87,9 @@ jobs:
|
|||||||
print(json.dumps(data, indent=2), file=sys.stderr)
|
print(json.dumps(data, indent=2), file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
" 2>&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" \
|
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'])")
|
RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||||
}
|
}
|
||||||
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
|
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
|
||||||
@@ -103,19 +128,45 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload assets to release
|
- name: Upload assets to release
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
|
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
|
||||||
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
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
|
for FILE in build/MediaServer-*.zip build/MediaServer-*-setup.exe; do
|
||||||
[ -f "$FILE" ] || continue
|
[ -f "$FILE" ] || continue
|
||||||
echo "Uploading $(basename "$FILE")..."
|
upload_asset "$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"
|
|
||||||
done
|
done
|
||||||
|
|
||||||
# --- Build Linux tarball ---
|
# --- Build Linux tarball ---
|
||||||
@@ -143,15 +194,35 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload assets to release
|
- name: Upload assets to release
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
|
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
|
||||||
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
||||||
|
|
||||||
FILE=$(ls build/MediaServer-*-linux-x64.tar.gz | head -1)
|
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 \
|
curl -s -X POST \
|
||||||
"$BASE_URL/releases/$RELEASE_ID/assets?name=$(basename "$FILE")" \
|
"$BASE_URL/releases/$RELEASE_ID/assets?name=$NAME" \
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
-H "Authorization: token $DEPLOY_TOKEN" \
|
||||||
-H "Content-Type: application/octet-stream" \
|
-H "Content-Type: application/octet-stream" \
|
||||||
--data-binary "@$FILE"
|
--data-binary "@$FILE"
|
||||||
|
|||||||
@@ -41,10 +41,20 @@ Unregister-ScheduledTask -TaskName "MediaServer" -Confirm:$false
|
|||||||
|
|
||||||
**When restart is NOT needed:**
|
**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
|
- README or documentation updates
|
||||||
- Changes to install/service scripts (only affects new installations)
|
- 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:**
|
**How to restart during development:**
|
||||||
|
|
||||||
1. Find the running server process:
|
1. Find the running server process:
|
||||||
@@ -124,12 +134,17 @@ To add support for a new language:
|
|||||||
|
|
||||||
## Versioning
|
## 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`
|
At runtime, `media_server/__init__.py` reads the version via `importlib.metadata.version()` — no manual syncing needed.
|
||||||
- `media_server/__init__.py` - `__version__`
|
|
||||||
|
|
||||||
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.
|
**Important:** After making any changes, always ask the user if the version needs to be incremented.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
## v0.1.1 (2026-03-28)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- Use custom app icon for Windows shortcuts instead of the default Python executable icon ([5e5e503](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/5e5e503))
|
||||||
|
- Check if port is already in use before starting the server ([5219263](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/5219263))
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- Replace `packaging` library with lightweight built-in version comparison — one fewer dependency ([5219263](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/5219263))
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Development / Internal
|
||||||
|
|
||||||
|
#### CI/Build
|
||||||
|
- Add manual build workflow for testing artifacts without tagging a release ([4f9e99e](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4f9e99e))
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>All Commits</summary>
|
||||||
|
|
||||||
|
| Hash | Message | Author |
|
||||||
|
|------|---------|--------|
|
||||||
|
| [5219263](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/5219263) | fix: port-in-use check and remove packaging dependency | alexei.dolgolyov |
|
||||||
|
| [5e5e503](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/5e5e503) | fix: use custom icon for Windows shortcuts instead of python.exe | alexei.dolgolyov |
|
||||||
|
| [4f9e99e](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/4f9e99e) | ci: add manual build workflow for testing artifacts | 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)
|
# Build Linux distribution (self-contained venv + tarball)
|
||||||
# Usage: ./build-dist-linux.sh [VERSION]
|
# Usage: ./build-dist-linux.sh [VERSION]
|
||||||
|
|
||||||
# --- Version detection ---
|
source "$(dirname "$0")/build-common.sh"
|
||||||
VERSION="${1:-}"
|
|
||||||
|
|
||||||
if [ -z "$VERSION" ]; then
|
detect_version "${1:-}"
|
||||||
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}"
|
|
||||||
echo "Building Media Server v${VERSION_CLEAN} for Linux"
|
echo "Building Media Server v${VERSION_CLEAN} for Linux"
|
||||||
|
|
||||||
# --- Configuration ---
|
# --- Configuration ---
|
||||||
DIST_DIR="dist/media-server"
|
DIST_DIR="dist/media-server"
|
||||||
BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-linux-x64"
|
BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-linux-x64"
|
||||||
|
|
||||||
rm -rf dist build
|
clean_dist "${DIST_DIR}" build
|
||||||
mkdir -p "${DIST_DIR}" build
|
verify_frontend
|
||||||
|
|
||||||
# --- 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
|
|
||||||
|
|
||||||
# --- Create self-contained virtualenv ---
|
# --- Create self-contained virtualenv ---
|
||||||
echo "Creating virtualenv..."
|
echo "Creating virtualenv..."
|
||||||
@@ -49,21 +29,11 @@ rm -rf "${DIST_DIR}"/venv/lib/python*/site-packages/media_server*.dist-info
|
|||||||
|
|
||||||
deactivate
|
deactivate
|
||||||
|
|
||||||
# --- Copy application ---
|
# Trim venv site-packages
|
||||||
echo "Copying application files..."
|
LINUX_SP=$(echo "${DIST_DIR}"/venv/lib/python*/site-packages)
|
||||||
mkdir -p "${DIST_DIR}/app"
|
cleanup_site_packages "$LINUX_SP" "so" "so"
|
||||||
cp -r media_server "${DIST_DIR}/app/"
|
|
||||||
|
|
||||||
# Remove source JS (bundle is in dist/)
|
copy_app_files "$DIST_DIR"
|
||||||
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"
|
|
||||||
|
|
||||||
# --- Create launcher ---
|
# --- Create launcher ---
|
||||||
cat > "${DIST_DIR}/media-server.sh" << 'LAUNCHER'
|
cat > "${DIST_DIR}/media-server.sh" << 'LAUNCHER'
|
||||||
|
|||||||
+6
-50
@@ -4,23 +4,9 @@ set -euo pipefail
|
|||||||
# Cross-build Windows distribution on Linux
|
# Cross-build Windows distribution on Linux
|
||||||
# Usage: ./build-dist-windows.sh [VERSION]
|
# Usage: ./build-dist-windows.sh [VERSION]
|
||||||
|
|
||||||
# --- Version detection ---
|
source "$(dirname "$0")/build-common.sh"
|
||||||
VERSION="${1:-}"
|
|
||||||
|
|
||||||
if [ -z "$VERSION" ]; then
|
detect_version "${1:-}"
|
||||||
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}"
|
|
||||||
echo "Building Media Server v${VERSION_CLEAN} for Windows"
|
echo "Building Media Server v${VERSION_CLEAN} for Windows"
|
||||||
|
|
||||||
# --- Configuration ---
|
# --- Configuration ---
|
||||||
@@ -31,8 +17,7 @@ WHEEL_DIR="build/win-wheels"
|
|||||||
SITE_PACKAGES="${DIST_DIR}/python/Lib/site-packages"
|
SITE_PACKAGES="${DIST_DIR}/python/Lib/site-packages"
|
||||||
BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-win-x64"
|
BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-win-x64"
|
||||||
|
|
||||||
rm -rf dist build
|
clean_dist "${DIST_DIR}" "${WHEEL_DIR}" "${SITE_PACKAGES}"
|
||||||
mkdir -p "${DIST_DIR}" "${WHEEL_DIR}" "${SITE_PACKAGES}"
|
|
||||||
|
|
||||||
# --- Download embedded Python ---
|
# --- Download embedded Python ---
|
||||||
echo "Downloading embedded Python ${PYTHON_VERSION}..."
|
echo "Downloading embedded Python ${PYTHON_VERSION}..."
|
||||||
@@ -100,43 +85,14 @@ for whl in "$WHEEL_DIR"/*.whl; do
|
|||||||
unzip -qo "$whl" -d "$SITE_PACKAGES"
|
unzip -qo "$whl" -d "$SITE_PACKAGES"
|
||||||
done
|
done
|
||||||
|
|
||||||
# --- Size optimization ---
|
cleanup_site_packages "$SITE_PACKAGES" "pyd" "dll"
|
||||||
echo "Optimizing size..."
|
verify_frontend
|
||||||
find "$SITE_PACKAGES" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
copy_app_files "$DIST_DIR"
|
||||||
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}/"
|
|
||||||
|
|
||||||
# Copy scripts needed for auto-start
|
# Copy scripts needed for auto-start
|
||||||
mkdir -p "${DIST_DIR}/scripts"
|
mkdir -p "${DIST_DIR}/scripts"
|
||||||
cp scripts/start-hidden.vbs "${DIST_DIR}/scripts/"
|
cp scripts/start-hidden.vbs "${DIST_DIR}/scripts/"
|
||||||
|
|
||||||
# --- Write version ---
|
|
||||||
echo "$VERSION_CLEAN" > "${DIST_DIR}/VERSION"
|
|
||||||
|
|
||||||
# --- Create launcher ---
|
# --- Create launcher ---
|
||||||
cat > "${DIST_DIR}/media-server.bat" << 'LAUNCHER'
|
cat > "${DIST_DIR}/media-server.bat" << 'LAUNCHER'
|
||||||
@echo off
|
@echo off
|
||||||
|
|||||||
+4
-4
@@ -84,10 +84,10 @@ Section "!Core (required)" SecCore
|
|||||||
CreateDirectory "$SMPROGRAMS\${APPNAME}"
|
CreateDirectory "$SMPROGRAMS\${APPNAME}"
|
||||||
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" \
|
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" \
|
||||||
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
|
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
|
||||||
"$INSTDIR\python\python.exe" 0
|
"$INSTDIR\app\media_server\static\icons\icon.ico" 0
|
||||||
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME} (Console).lnk" \
|
CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME} (Console).lnk" \
|
||||||
"$INSTDIR\${EXENAME}" "" \
|
"$INSTDIR\${EXENAME}" "" \
|
||||||
"$INSTDIR\python\python.exe" 0
|
"$INSTDIR\app\media_server\static\icons\icon.ico" 0
|
||||||
CreateShortcut "$SMPROGRAMS\${APPNAME}\Uninstall.lnk" \
|
CreateShortcut "$SMPROGRAMS\${APPNAME}\Uninstall.lnk" \
|
||||||
"$INSTDIR\uninstall.exe"
|
"$INSTDIR\uninstall.exe"
|
||||||
|
|
||||||
@@ -117,14 +117,14 @@ SectionEnd
|
|||||||
Section "Desktop shortcut" SecDesktop
|
Section "Desktop shortcut" SecDesktop
|
||||||
CreateShortcut "$DESKTOP\${APPNAME}.lnk" \
|
CreateShortcut "$DESKTOP\${APPNAME}.lnk" \
|
||||||
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
|
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
|
||||||
"$INSTDIR\python\python.exe" 0
|
"$INSTDIR\app\media_server\static\icons\icon.ico" 0
|
||||||
SectionEnd
|
SectionEnd
|
||||||
|
|
||||||
Section "Start with Windows" SecAutostart
|
Section "Start with Windows" SecAutostart
|
||||||
; Create Startup folder shortcut (runs hidden via VBS)
|
; Create Startup folder shortcut (runs hidden via VBS)
|
||||||
CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" \
|
CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" \
|
||||||
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
|
"wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
|
||||||
"$INSTDIR\python\python.exe" 0
|
"$INSTDIR\app\media_server\static\icons\icon.ico" 0
|
||||||
SectionEnd
|
SectionEnd
|
||||||
|
|
||||||
; --- Section descriptions ---
|
; --- Section descriptions ---
|
||||||
|
|||||||
@@ -1,3 +1,23 @@
|
|||||||
"""Media Server - REST API for controlling system media playback."""
|
"""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)",
|
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
|
@classmethod
|
||||||
def load_from_yaml(cls, path: Optional[Path] = None) -> "Settings":
|
def load_from_yaml(cls, path: Optional[Path] = None) -> "Settings":
|
||||||
"""Load settings from a YAML configuration file."""
|
"""Load settings from a YAML configuration file."""
|
||||||
|
|||||||
@@ -451,6 +451,34 @@ class ConfigManager:
|
|||||||
del settings.links[name]
|
del settings.links[name]
|
||||||
logger.info(f"Link '{name}' deleted from config")
|
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
|
# Global config manager instance
|
||||||
config_manager = ConfigManager()
|
config_manager = ConfigManager()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
|
import socket
|
||||||
import sys
|
import sys
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -74,6 +75,18 @@ async def lifespan(app: FastAPI):
|
|||||||
await ws_manager.start_status_monitor(controller.get_status)
|
await ws_manager.start_status_monitor(controller.get_status)
|
||||||
logger.info("WebSocket status monitor started")
|
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)
|
# Register audio visualizer (capture starts on-demand when clients subscribe)
|
||||||
analyzer = None
|
analyzer = None
|
||||||
if settings.visualizer_enabled:
|
if settings.visualizer_enabled:
|
||||||
@@ -92,6 +105,10 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
# Stop update checker
|
||||||
|
if update_checker is not None:
|
||||||
|
await update_checker.stop()
|
||||||
|
|
||||||
# Stop audio visualizer
|
# Stop audio visualizer
|
||||||
await ws_manager.stop_audio_monitor()
|
await ws_manager.stop_audio_monitor()
|
||||||
if analyzer and analyzer.running:
|
if analyzer and analyzer.running:
|
||||||
@@ -243,6 +260,19 @@ def main():
|
|||||||
print("\nAuthentication is DISABLED (no tokens configured)")
|
print("\nAuthentication is DISABLED (no tokens configured)")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Check if port is available before starting
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
|
try:
|
||||||
|
sock.bind((args.host if args.host != "0.0.0.0" else "127.0.0.1", args.port))
|
||||||
|
except OSError:
|
||||||
|
print(
|
||||||
|
f"ERROR: Port {args.port} is already in use. "
|
||||||
|
f"Another instance of Media Server may be running.\n"
|
||||||
|
f"Stop the other process or use --port to pick a different port.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
from .tray import PYSTRAY_AVAILABLE, TrayManager
|
from .tray import PYSTRAY_AVAILABLE, TrayManager
|
||||||
|
|
||||||
use_tray = PYSTRAY_AVAILABLE and not args.no_tray
|
use_tray = PYSTRAY_AVAILABLE and not args.no_tray
|
||||||
|
|||||||
@@ -3,23 +3,31 @@
|
|||||||
import platform
|
import platform
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, Request
|
||||||
|
|
||||||
|
from .. import __version__
|
||||||
from ..auth import auth_enabled
|
from ..auth import auth_enabled
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["health"])
|
router = APIRouter(prefix="/api", tags=["health"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/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.
|
"""Health check endpoint - no authentication required.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Health status and server information
|
Health status and server information
|
||||||
"""
|
"""
|
||||||
return {
|
result: dict[str, Any] = {
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
"platform": platform.system(),
|
"platform": platform.system(),
|
||||||
"version": "1.0.0",
|
"version": __version__,
|
||||||
"auth_required": auth_enabled(),
|
"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
|
# set_device() handles stop/start internally if capture was running
|
||||||
success = analyzer.set_device(device_name)
|
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 {
|
return {
|
||||||
"success": success,
|
"success": success,
|
||||||
"current_device": analyzer.current_device,
|
"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,169 @@
|
|||||||
|
"""Provider-agnostic update checker service."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from functools import total_ordering
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from .release_provider import ReleaseProvider
|
||||||
|
from .websocket_manager import ws_manager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_PRE_PATTERN = re.compile(
|
||||||
|
r"^(\d+\.\d+\.\d+)[-.]?(alpha|beta|rc)[.-]?(\d+)$", re.IGNORECASE
|
||||||
|
)
|
||||||
|
_PRE_ORDER = {"alpha": 0, "beta": 1, "rc": 2}
|
||||||
|
|
||||||
|
|
||||||
|
@total_ordering
|
||||||
|
class _Version:
|
||||||
|
"""Lightweight PEP 440-ish version for comparison without packaging dep.
|
||||||
|
|
||||||
|
Supports: X.Y.Z and X.Y.Z-{alpha,beta,rc}.N
|
||||||
|
Pre-releases sort before the corresponding stable release.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("_release", "_pre")
|
||||||
|
|
||||||
|
def __init__(self, release: tuple[int, ...], pre: Optional[tuple[int, int]]) -> None:
|
||||||
|
self._release = release
|
||||||
|
self._pre = pre
|
||||||
|
|
||||||
|
def __eq__(self, other: object) -> bool:
|
||||||
|
if not isinstance(other, _Version):
|
||||||
|
return NotImplemented
|
||||||
|
return self._release == other._release and self._pre == other._pre
|
||||||
|
|
||||||
|
def __lt__(self, other: object) -> bool:
|
||||||
|
if not isinstance(other, _Version):
|
||||||
|
return NotImplemented
|
||||||
|
if self._release != other._release:
|
||||||
|
return self._release < other._release
|
||||||
|
# No pre-release (stable) is greater than any pre-release
|
||||||
|
if self._pre is None and other._pre is None:
|
||||||
|
return False
|
||||||
|
if self._pre is not None and other._pre is None:
|
||||||
|
return True
|
||||||
|
if self._pre is None and other._pre is not None:
|
||||||
|
return False
|
||||||
|
return self._pre < other._pre # type: ignore[operator]
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
v = ".".join(str(p) for p in self._release)
|
||||||
|
if self._pre is not None:
|
||||||
|
labels = {0: "alpha", 1: "beta", 2: "rc"}
|
||||||
|
v += f"-{labels[self._pre[0]]}.{self._pre[1]}"
|
||||||
|
return f"_Version('{v}')"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_version(raw: str) -> _Version:
|
||||||
|
"""Parse a version tag for comparison.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
v0.3.0-alpha.1 → (0,3,0) pre=(0,1) (sorts below 0.3.0)
|
||||||
|
v0.3.0-rc.3 → (0,3,0) pre=(2,3)
|
||||||
|
v1.0.0 → (1,0,0) pre=None
|
||||||
|
"""
|
||||||
|
cleaned = raw.lstrip("v").strip()
|
||||||
|
m = _PRE_PATTERN.match(cleaned)
|
||||||
|
if m:
|
||||||
|
base = tuple(int(x) for x in m.group(1).split("."))
|
||||||
|
pre_label = m.group(2).lower()
|
||||||
|
pre_num = int(m.group(3))
|
||||||
|
return _Version(base, (_PRE_ORDER[pre_label], pre_num))
|
||||||
|
release = tuple(int(x) for x in cleaned.split("."))
|
||||||
|
return _Version(release, None)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateChecker:
|
||||||
|
"""Periodically checks for new releases using a ReleaseProvider."""
|
||||||
|
|
||||||
|
def __init__(self, provider: ReleaseProvider, current_version: str) -> None:
|
||||||
|
self._provider = provider
|
||||||
|
self._current_version = current_version
|
||||||
|
self._current_parsed = _parse_version(current_version)
|
||||||
|
self._task: Optional[asyncio.Task] = None
|
||||||
|
self._cached_update: Optional[dict[str, Any]] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cached_update(self) -> Optional[dict[str, Any]]:
|
||||||
|
"""Return the cached update info, or None if up-to-date."""
|
||||||
|
return self._cached_update
|
||||||
|
|
||||||
|
async def check_for_update(self) -> Optional[dict[str, Any]]:
|
||||||
|
"""Check for a newer release.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with current/latest/url if an update exists, None otherwise.
|
||||||
|
"""
|
||||||
|
release = await self._provider.get_latest_release()
|
||||||
|
if release is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
latest_parsed = _parse_version(release.version)
|
||||||
|
if latest_parsed <= self._current_parsed:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"current": self._current_version,
|
||||||
|
"latest": release.version,
|
||||||
|
"url": release.url,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def start(self, interval: int) -> None:
|
||||||
|
"""Start periodic update checking.
|
||||||
|
|
||||||
|
Checks immediately on start, then every `interval` seconds.
|
||||||
|
"""
|
||||||
|
if self._task is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._task = asyncio.create_task(self._check_loop(interval))
|
||||||
|
logger.info("Update checker started (interval: %ds)", interval)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""Stop periodic update checking."""
|
||||||
|
if self._task is not None:
|
||||||
|
self._task.cancel()
|
||||||
|
try:
|
||||||
|
await self._task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._task = None
|
||||||
|
logger.info("Update checker stopped")
|
||||||
|
|
||||||
|
async def _check_loop(self, interval: int) -> None:
|
||||||
|
"""Background loop that checks for updates periodically."""
|
||||||
|
# Initial check with a small delay to let the server finish starting
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
update = await self.check_for_update()
|
||||||
|
|
||||||
|
if update and update != self._cached_update:
|
||||||
|
self._cached_update = update
|
||||||
|
logger.info(
|
||||||
|
"New version available: %s → %s (%s)",
|
||||||
|
update["current"],
|
||||||
|
update["latest"],
|
||||||
|
update["url"],
|
||||||
|
)
|
||||||
|
await ws_manager.broadcast(
|
||||||
|
{"type": "update_available", "data": update}
|
||||||
|
)
|
||||||
|
elif update is None and self._cached_update is not None:
|
||||||
|
# Version was updated (or release removed) — clear cache
|
||||||
|
self._cached_update = None
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Update check failed: %s", e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
@@ -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 */
|
||||||
.connection-banner {
|
.connection-banner {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -114,6 +114,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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 -->
|
<!-- Connection Banner -->
|
||||||
<div class="connection-banner hidden" id="connectionBanner">
|
<div class="connection-banner hidden" id="connectionBanner">
|
||||||
<span id="connectionBannerText"></span>
|
<span id="connectionBannerText"></span>
|
||||||
|
|||||||
@@ -318,12 +318,35 @@ export async function fetchVersion() {
|
|||||||
if (data.version) {
|
if (data.version) {
|
||||||
label.textContent = `v${data.version}`;
|
label.textContent = `v${data.version}`;
|
||||||
}
|
}
|
||||||
|
if (data.update_available) {
|
||||||
|
showUpdateBanner(data.update_available);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching version:', 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
|
// Shared Utilities
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
dom, t, showToast, setWs,
|
dom, t, showToast, setWs,
|
||||||
WS_BACKOFF_BASE_MS, WS_BACKOFF_MAX_MS,
|
WS_BACKOFF_BASE_MS, WS_BACKOFF_MAX_MS,
|
||||||
WS_MAX_RECONNECT_ATTEMPTS, WS_PING_INTERVAL_MS,
|
WS_MAX_RECONNECT_ATTEMPTS, WS_PING_INTERVAL_MS,
|
||||||
authRequired,
|
authRequired, showUpdateBanner,
|
||||||
} from './core.js';
|
} from './core.js';
|
||||||
import { updateUI, visualizerEnabled, visualizerAvailable, setFrequencyData, stopPositionInterpolation, loadAudioDevices } from './player.js';
|
import { updateUI, visualizerEnabled, visualizerAvailable, setFrequencyData, stopPositionInterpolation, loadAudioDevices } from './player.js';
|
||||||
import { loadScripts, loadScriptsTable, displayQuickAccess } from './scripts.js';
|
import { loadScripts, loadScriptsTable, displayQuickAccess } from './scripts.js';
|
||||||
@@ -100,6 +100,8 @@ export function connectWebSocket(token) {
|
|||||||
loadHeaderLinks();
|
loadHeaderLinks();
|
||||||
loadLinksTable();
|
loadLinksTable();
|
||||||
displayQuickAccess();
|
displayQuickAccess();
|
||||||
|
} else if (msg.type === 'update_available') {
|
||||||
|
showUpdateBanner(msg.data);
|
||||||
} else if (msg.type === 'audio_data') {
|
} else if (msg.type === 'audio_data') {
|
||||||
setFrequencyData(msg.data);
|
setFrequencyData(msg.data);
|
||||||
} else if (msg.type === 'error') {
|
} else if (msg.type === 'error') {
|
||||||
|
|||||||
@@ -224,5 +224,7 @@
|
|||||||
"links.confirm.delete": "Are you sure you want to delete the link \"{name}\"?",
|
"links.confirm.delete": "Are you sure you want to delete the link \"{name}\"?",
|
||||||
"links.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?",
|
"links.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?",
|
||||||
"footer.created_by": "Created by",
|
"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.delete": "Вы уверены, что хотите удалить ссылку \"{name}\"?",
|
||||||
"links.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
|
"links.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
|
||||||
"footer.created_by": "Создано",
|
"footer.created_by": "Создано",
|
||||||
"footer.source_code": "Исходный код"
|
"footer.source_code": "Исходный код",
|
||||||
|
"update.available": "Доступно обновление: v{version}",
|
||||||
|
"update.view_release": "Перейти к релизу"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "media-server"
|
name = "media-server"
|
||||||
version = "1.0.0"
|
version = "0.1.1"
|
||||||
description = "REST API server for controlling system-wide media playback"
|
description = "REST API server for controlling system-wide media playback"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
|
|||||||
Reference in New Issue
Block a user