#!/usr/bin/env bash set -euo pipefail # Cross-build Windows distribution on Linux # Usage: ./build-dist-windows.sh [VERSION] source "$(dirname "$0")/build-common.sh" detect_version "${1:-}" echo "Building Media Server v${VERSION_CLEAN} for Windows" # --- Configuration --- PYTHON_VERSION="3.11.9" PYTHON_SHORT="311" DIST_DIR="dist/media-server" WHEEL_DIR="build/win-wheels" SITE_PACKAGES="${DIST_DIR}/python/Lib/site-packages" BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-win-x64" clean_dist "${DIST_DIR}" "${WHEEL_DIR}" "${SITE_PACKAGES}" # --- Download embedded Python (cache-friendly) --- mkdir -p build if [ ! -f build/python-embed.zip ]; then echo "Downloading embedded Python ${PYTHON_VERSION}..." curl -sL "https://www.python.org/ftp/python/${PYTHON_VERSION}/python-${PYTHON_VERSION}-embed-amd64.zip" \ -o build/python-embed.zip else echo "Using cached embedded Python ${PYTHON_VERSION}" fi unzip -qo build/python-embed.zip -d "${DIST_DIR}/python" # Patch ._pth to enable site-packages and app source PTH_FILE=$(ls "${DIST_DIR}"/python/python*._pth | head -1) sed -i 's/^#\s*import site/import site/' "$PTH_FILE" echo 'Lib\site-packages' >> "$PTH_FILE" echo '..\app' >> "$PTH_FILE" # --- Download Windows wheels --- echo "Downloading Windows wheels..." # Core dependencies # NOTE: uvicorn[standard] pulls in uvloop via `sys_platform != 'win32'` marker. # pip evaluates env markers against the HOST (Linux in CI), so uvloop is # requested, but `--platform win_amd64 --only-binary :all:` cannot find a # Windows wheel for uvloop (none exist). Result: pip backtracks across every # uvicorn[standard] version and ResolutionImpossible. Fix: use plain uvicorn # and list only the Windows-compatible standard extras we actually need. CORE_DEPS=( "fastapi>=0.109.0" "uvicorn>=0.27.0" "httptools>=0.5.0" "websockets>=10.4" "python-dotenv>=0.13" "pydantic>=2.0" "pydantic-settings>=2.0" "pyyaml>=6.0" "mutagen>=1.47.0" "pillow>=10.0.0" ) # Windows-specific dependencies # NOTE: wmi is a transitive dep of screen-brightness-control gated on # `platform_system == "Windows"`. pip evaluates env markers against the HOST # (Linux in CI), so it gets skipped during cross-build. Listed explicitly here # so the wheel actually lands in the Windows bundle. Same gotcha as the # uvicorn[standard]/uvloop case documented above. WIN_DEPS=( "winsdk>=1.0.0b10" "pywin32>=306" "comtypes>=1.2.0" "pycaw>=20230407" "screen-brightness-control>=0.20.0" "wmi>=1.5.1" "monitorcontrol>=3.0.0" ) # Visualizer dependencies VIS_DEPS=( "soundcard>=0.4.0" "numpy>=1.24.0,<2.0" # pystray lives here (not WIN_DEPS) so its transitive Pillow resolves in the # same pass as CORE_DEPS. Keeping it in the per-dep WIN_DEPS loop downloaded # a second Pillow version that clobbered the core one on unzip, producing # "_imaging extension was built for another version of Pillow" at runtime. "pystray>=0.19.0" ) # Resolve core + visualizer deps in a SINGLE call so pip picks compatible # transitive versions (notably pydantic/pydantic-core must match). # Per-dep loops resolve each dep independently and can leave mismatched # transitive versions that overwrite each other in the site-packages unzip. CROSS_DEPS=("${CORE_DEPS[@]}" "${VIS_DEPS[@]}") pip download --quiet --no-cache-dir --dest "$WHEEL_DIR" \ --platform win_amd64 --python-version "${PYTHON_SHORT}" \ --implementation cp --only-binary :all: \ "${CROSS_DEPS[@]}" # Windows-only deps in a loop with --pre: winsdk only ships beta wheels # (1.0.0bNN) and each dep needs its own platform/non-platform fallback. for dep in "${WIN_DEPS[@]}"; do pip download --pre --quiet --no-cache-dir --dest "$WHEEL_DIR" \ --platform win_amd64 --python-version "${PYTHON_SHORT}" \ --implementation cp --only-binary :all: \ "$dep" 2>/dev/null || \ pip download --pre --quiet --no-cache-dir --dest "$WHEEL_DIR" \ --only-binary :all: \ "$dep" done # Remove numpy 2.x wheels pulled as transitive deps (soundcard requires <2.0) for f in "$WHEEL_DIR"/numpy-2*; do [ -f "$f" ] && echo "Removing incompatible: $(basename "$f")" && rm "$f" done # Install wheels into site-packages echo "Installing wheels..." for whl in "$WHEEL_DIR"/*.whl; do unzip -qo "$whl" -d "$SITE_PACKAGES" done # numpy wheels from PyPI don't include _distributor_init_local.py unless # patched by delvewheel. In embedded Python, os.add_dll_directory() is never # called, so libopenblas can't be found and numpy fails to import. # Generate the missing loader here instead. if [ -d "${SITE_PACKAGES}/numpy" ]; then cat > "${SITE_PACKAGES}/numpy/_distributor_init_local.py" << 'EOF' import os import sys if sys.platform == 'win32': _libs = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'numpy.libs')) if os.path.isdir(_libs): os.add_dll_directory(_libs) EOF echo "Generated numpy/_distributor_init_local.py" fi 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/" # --- Create launcher --- cat > "${DIST_DIR}/media-server.bat" << 'LAUNCHER' @echo off setlocal set "ROOT=%~dp0" "%ROOT%python\python.exe" -m media_server.main %* LAUNCHER # --- Package --- echo "Creating archives..." mkdir -p build # Portable ZIP cp -r "${DIST_DIR}" "${BUILD_OUTPUT}" cd build zip -qr "MediaServer-v${VERSION_CLEAN}-win-x64.zip" "MediaServer-v${VERSION_CLEAN}-win-x64" cd .. echo "Build complete: build/MediaServer-v${VERSION_CLEAN}-win-x64.zip" echo "Dist directory ready for NSIS: ${DIST_DIR}"