diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index a19e12f..0b3511d 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -16,7 +16,7 @@ jobs: - 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}" @@ -41,7 +41,7 @@ jobs: ") 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\", @@ -64,7 +64,7 @@ jobs: " 2>&1) || { echo "Create failed, fetching existing release for tag $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'])") } echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT" @@ -103,19 +103,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 +169,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" diff --git a/CLAUDE.md b/CLAUDE.md index d40e20a..6372e79 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/build-common.sh b/build-common.sh new file mode 100644 index 0000000..30e7765 --- /dev/null +++ b/build-common.sh @@ -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 +} diff --git a/build-dist-linux.sh b/build-dist-linux.sh index d64d4f2..d65ac9b 100644 --- a/build-dist-linux.sh +++ b/build-dist-linux.sh @@ -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' diff --git a/build-dist-windows.sh b/build-dist-windows.sh index b96d0ee..a05d4de 100644 --- a/build-dist-windows.sh +++ b/build-dist-windows.sh @@ -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 diff --git a/media_server/__init__.py b/media_server/__init__.py index fc29ea0..8069df0 100644 --- a/media_server/__init__.py +++ b/media_server/__init__.py @@ -1,3 +1,23 @@ """Media Server - REST API for controlling system media playback.""" -__version__ = "1.0.1" +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() diff --git a/media_server/services/update_checker.py b/media_server/services/update_checker.py index 5fa7111..a242cf6 100644 --- a/media_server/services/update_checker.py +++ b/media_server/services/update_checker.py @@ -2,30 +2,36 @@ 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(version: str) -> tuple[int, ...]: - """Parse a version string into a comparable tuple. - Handles versions like "1.0.0", "1.2.3", ignoring non-numeric suffixes. +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 """ - parts: list[int] = [] - for part in version.split("."): - digits = "" - for ch in part: - if ch.isdigit(): - digits += ch - else: - break - if digits: - parts.append(int(digits)) - return tuple(parts) + 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: diff --git a/media_server/static/css/styles.css b/media_server/static/css/styles.css index f21a2f3..2829f30 100644 --- a/media_server/static/css/styles.css +++ b/media_server/static/css/styles.css @@ -3537,6 +3537,8 @@ footer .separator { opacity: 0.7; cursor: pointer; line-height: 1; + display: flex; + align-items: center; } .update-banner-close:hover { diff --git a/pyproject.toml b/pyproject.toml index 23aa485..76d6c42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ "pyyaml>=6.0", "mutagen>=1.47.0", "pillow>=10.0.0", + "packaging>=23.0", ] [project.optional-dependencies]