From 5439af1955badb9a569341025bc7e85938315159 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 23 Mar 2026 02:01:28 +0300 Subject: [PATCH] Add CI/CD pipelines, NSIS installer, ES module bundling, and ruff linting - Add Gitea Actions workflows: test.yml (lint + test on push/PR) and release.yml (build + NSIS installer + upload on v* tags) - Add NSIS installer with optional desktop shortcut and auto-start - Add esbuild bundler: ES module migration with IIFE bundle output - Add build-dist-windows.sh for cross-building Windows distribution - Fix all ruff lint errors (import sorting, unused imports, line length) - Remove redundant scripts (start-server.bat, stop-server.bat, start-server-background.vbs) - Update CLAUDE.md with CI/CD and release documentation --- .gitea/workflows/release.yml | 103 +++ .gitea/workflows/test.yml | 35 ++ .gitignore | 4 + CLAUDE.md | 42 ++ build-dist-windows.sh | 151 +++++ esbuild.mjs | 26 + installer.nsi | 135 ++++ media_server/config.py | 5 +- media_server/main.py | 17 +- media_server/models/__init__.py | 4 +- media_server/routes/__init__.py | 11 +- media_server/routes/browser.py | 7 +- media_server/routes/callbacks.py | 11 +- media_server/routes/display.py | 1 - media_server/routes/media.py | 7 +- media_server/service/install_windows.py | 15 +- media_server/services/__init__.py | 2 +- media_server/services/android_media.py | 3 +- media_server/services/browser_service.py | 2 - media_server/services/display_service.py | 2 +- media_server/services/linux_media.py | 2 +- media_server/services/macos_media.py | 6 - media_server/services/metadata_service.py | 11 +- media_server/services/thumbnail_service.py | 15 +- media_server/services/windows_media.py | 35 +- media_server/static/index.html | 10 +- media_server/static/js/{main.js => app.js} | 154 ++++- media_server/static/js/background.js | 12 +- media_server/static/js/browser.js | 64 +- media_server/static/js/callbacks.js | 17 +- media_server/static/js/core.js | 143 +++-- media_server/static/js/links.js | 29 +- media_server/static/js/player.js | 88 +-- media_server/static/js/scripts.js | 33 +- media_server/static/js/websocket.js | 50 +- package-lock.json | 690 +++++++++++++++++++++ package.json | 13 + pyproject.toml | 16 + scripts/start-server-background.vbs | 7 - scripts/start-server.bat | 15 - scripts/stop-server.bat | 19 - 41 files changed, 1702 insertions(+), 310 deletions(-) create mode 100644 .gitea/workflows/release.yml create mode 100644 .gitea/workflows/test.yml create mode 100644 build-dist-windows.sh create mode 100644 esbuild.mjs create mode 100644 installer.nsi rename media_server/static/js/{main.js => app.js} (65%) create mode 100644 package-lock.json create mode 100644 package.json delete mode 100644 scripts/start-server-background.vbs delete mode 100644 scripts/start-server.bat delete mode 100644 scripts/stop-server.bat diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..374418e --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,103 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + # --- Create Gitea release --- + create-release: + runs-on: ubuntu-latest + outputs: + release_id: ${{ steps.create.outputs.release_id }} + version: ${{ steps.create.outputs.version }} + steps: + - name: Create Gitea release + id: create + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + TAG="${{ gitea.ref_name }}" + VERSION="${TAG#v}" + BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}" + + IS_PRE="false" + if echo "$TAG" | grep -qE '(alpha|beta|rc)'; then + IS_PRE="true" + fi + + BODY_JSON=$(python3 -c " + import json, textwrap + tag = '$TAG' + body = '''## Downloads + | Platform | File | + |----------|------| + | Windows (installer) | \`MediaServer-{tag}-setup.exe\` | + | Windows (portable) | \`MediaServer-{tag}-win-x64.zip\` | + ''' + print(json.dumps(textwrap.dedent(body).strip())) + ") + + RELEASE=$(curl -s -X POST "$BASE_URL/releases" \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"tag_name\": \"$TAG\", + \"name\": \"Media Server $TAG\", + \"body\": $BODY_JSON, + \"draft\": false, + \"prerelease\": $IS_PRE + }") + + RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") + echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + # --- Build Windows installer + portable ZIP --- + build-windows: + needs: create-release + 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 "${{ gitea.ref_name }}" + + - name: Build NSIS installer + run: | + VERSION="${{ needs.create-release.outputs.version }}" + makensis -DVERSION="${VERSION}" installer.nsi + + - name: Upload assets to release + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + RELEASE_ID="${{ needs.create-release.outputs.release_id }}" + BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}" + + 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" + done diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..c625967 --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,35 @@ +name: Lint & Test + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test: + 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 dependencies + run: | + pip install --upgrade pip + pip install -e ".[dev]" + + - name: Lint + run: ruff check media_server/ + + - name: Test + run: pytest --tb=short -q diff --git a/.gitignore b/.gitignore index 0b508b5..6c4be85 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,7 @@ Thumbs.db # Thumbnail cache .cache/ + +# Node.js / esbuild +node_modules/ +media_server/static/dist/ diff --git a/CLAUDE.md b/CLAUDE.md index ef9a8d9..9e63502 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,6 +133,48 @@ When releasing a new version, update both files with the same version string. **Important:** After making any changes, always ask the user if the version needs to be incremented. +## CI/CD + +Gitea Actions workflow at `.gitea/workflows/test.yml` runs on every push/PR to `master`: + +1. **Lint** — `ruff check media_server/` (rules: E, F, I, W) +2. **Test** — `pytest --tb=short -q` + +Release workflow at `.gitea/workflows/release.yml` triggers on `v*` tags: + +1. **Create release** — Gitea release via REST API (detects pre-release from tag) +2. **Build Windows** — cross-builds on Linux using embedded Python + NSIS installer +3. **Upload assets** — portable ZIP + installer `.exe` attached to the release + +### Releasing + +```bash +# Stable release +git tag v1.0.0 && git push origin v1.0.0 + +# Pre-release +git tag v1.1.0-alpha.1 && git push origin v1.1.0-alpha.1 +``` + +### Installer + +The NSIS installer (`installer.nsi`) installs to `%LOCALAPPDATA%\Media Server` (no admin required) with optional: +- **Desktop shortcut** +- **Start with Windows** (Startup folder shortcut, runs hidden via VBS) + +Uninstall preserves `config.yaml` (user data). + +Reference: [gitea-python-ci-cd.md](https://git.dolgolyov-family.by/alexei.dolgolyov/claude-code-facts/src/branch/main/gitea-python-ci-cd.md) + +### Before Pushing + +Ensure CI will pass locally: + +```bash +ruff check media_server/ +pytest --tb=short -q +``` + ## Git Rules - **ALWAYS ask for user approval before committing and pushing changes.** diff --git a/build-dist-windows.sh b/build-dist-windows.sh new file mode 100644 index 0000000..b2b4acb --- /dev/null +++ b/build-dist-windows.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Cross-build Windows distribution on Linux +# Usage: ./build-dist-windows.sh [VERSION] + +# --- Version detection --- +VERSION="${1:-}" + +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}" +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" + +rm -rf dist build +mkdir -p "${DIST_DIR}" "${WHEEL_DIR}" "${SITE_PACKAGES}" + +# --- Download embedded Python --- +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 +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 +CORE_DEPS=( + "fastapi>=0.109.0" + "uvicorn[standard]>=0.27.0" + "pydantic>=2.0" + "pydantic-settings>=2.0" + "pyyaml>=6.0" + "mutagen>=1.47.0" + "pillow>=10.0.0" +) + +# Windows-specific dependencies +WIN_DEPS=( + "winsdk>=1.0.0b10" + "pywin32>=306" + "comtypes>=1.2.0" + "pycaw>=20230407" + "screen-brightness-control>=0.20.0" + "monitorcontrol>=3.0.0" +) + +# Visualizer dependencies +VIS_DEPS=( + "soundcard>=0.4.0" + "numpy>=1.24.0" +) + +ALL_DEPS=("${CORE_DEPS[@]}" "${WIN_DEPS[@]}" "${VIS_DEPS[@]}") + +for dep in "${ALL_DEPS[@]}"; do + pip download --quiet --dest "$WHEEL_DIR" \ + --platform win_amd64 --python-version "${PYTHON_SHORT}" \ + --implementation cp --only-binary :all: \ + "$dep" 2>/dev/null || \ + pip download --quiet --dest "$WHEEL_DIR" "$dep" +done + +# Install wheels into site-packages +echo "Installing wheels..." +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}/" + +# 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 +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}" diff --git a/esbuild.mjs b/esbuild.mjs new file mode 100644 index 0000000..9f0c8b6 --- /dev/null +++ b/esbuild.mjs @@ -0,0 +1,26 @@ +import * as esbuild from 'esbuild'; + +const srcDir = 'media_server/static'; +const outDir = `${srcDir}/dist`; + +const watch = process.argv.includes('--watch'); + +/** @type {esbuild.BuildOptions} */ +const jsOpts = { + entryPoints: [`${srcDir}/js/app.js`], + bundle: true, + format: 'iife', + outfile: `${outDir}/app.bundle.js`, + minify: true, + sourcemap: true, + target: ['es2020'], + logLevel: 'info', +}; + +if (watch) { + const jsCtx = await esbuild.context(jsOpts); + await jsCtx.watch(); + console.log('Watching for changes...'); +} else { + await esbuild.build(jsOpts); +} diff --git a/installer.nsi b/installer.nsi new file mode 100644 index 0000000..a930e00 --- /dev/null +++ b/installer.nsi @@ -0,0 +1,135 @@ +; Media Server NSIS Installer +; Cross-compilable: apt install nsis && makensis -DVERSION="1.0.0" installer.nsi + +!include "MUI2.nsh" +!include "FileFunc.nsh" + +; --- Configuration --- +!define APPNAME "Media Server" +!define EXENAME "media-server.bat" +!define VBSNAME "start-hidden.vbs" +!ifndef VERSION + !define VERSION "0.0.0" +!endif + +Name "${APPNAME} ${VERSION}" +OutFile "build\MediaServer-v${VERSION}-setup.exe" +InstallDir "$LOCALAPPDATA\${APPNAME}" +RequestExecutionLevel user + +; --- UI --- +; To use a custom icon, convert icon.svg to icon.ico and uncomment: +; !define MUI_ICON "media_server\static\icons\icon.ico" +; !define MUI_UNICON "media_server\static\icons\icon.ico" +!define MUI_ABORTWARNING + +!insertmacro MUI_PAGE_WELCOME +!insertmacro MUI_PAGE_DIRECTORY +!insertmacro MUI_PAGE_COMPONENTS +!insertmacro MUI_PAGE_INSTFILES +!insertmacro MUI_PAGE_FINISH + +!insertmacro MUI_UNPAGE_CONFIRM +!insertmacro MUI_UNPAGE_INSTFILES + +!insertmacro MUI_LANGUAGE "English" + +; --- Sections --- +Section "!Core (required)" SecCore + SectionIn RO + + ; Stop running instance if any + nsExec::ExecToLog 'taskkill /F /IM python.exe /FI "WINDOWTITLE eq media_server*"' + + SetOutPath "$INSTDIR" + + ; Copy entire distribution + File /r "dist\media-server\*.*" + + ; Create uninstaller + WriteUninstaller "$INSTDIR\uninstall.exe" + + ; Start Menu shortcuts + CreateDirectory "$SMPROGRAMS\${APPNAME}" + CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" \ + "wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \ + "$INSTDIR\python\python.exe" 0 + CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME} (Console).lnk" \ + "$INSTDIR\${EXENAME}" "" \ + "$INSTDIR\python\python.exe" 0 + CreateShortcut "$SMPROGRAMS\${APPNAME}\Uninstall.lnk" \ + "$INSTDIR\uninstall.exe" + + ; Registry for Add/Remove Programs + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \ + "DisplayName" "${APPNAME}" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \ + "DisplayVersion" "${VERSION}" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \ + "UninstallString" "$\"$INSTDIR\uninstall.exe$\"" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \ + "InstallLocation" "$INSTDIR" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \ + "Publisher" "Alexei Dolgolyov" + WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \ + "NoModify" 1 + WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \ + "NoRepair" 1 + + ; Calculate installed size + ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2 + IntFmt $0 "0x%08X" $0 + WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \ + "EstimatedSize" "$0" +SectionEnd + +Section "Desktop shortcut" SecDesktop + CreateShortcut "$DESKTOP\${APPNAME}.lnk" \ + "wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \ + "$INSTDIR\python\python.exe" 0 +SectionEnd + +Section "Start with Windows" SecAutostart + ; Create Startup folder shortcut (runs hidden via VBS) + CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" \ + "wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \ + "$INSTDIR\python\python.exe" 0 +SectionEnd + +; --- Section descriptions --- +!insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN + !insertmacro MUI_DESCRIPTION_TEXT ${SecCore} \ + "Core application files, embedded Python, and Start Menu shortcuts." + !insertmacro MUI_DESCRIPTION_TEXT ${SecDesktop} \ + "Create a desktop shortcut to launch ${APPNAME}." + !insertmacro MUI_DESCRIPTION_TEXT ${SecAutostart} \ + "Automatically start ${APPNAME} when you log in to Windows." +!insertmacro MUI_FUNCTION_DESCRIPTION_END + +; --- Uninstaller --- +Section "Uninstall" + ; Stop running instance + nsExec::ExecToLog 'taskkill /F /IM python.exe /FI "WINDOWTITLE eq media_server*"' + + ; Remove application files + RMDir /r "$INSTDIR\python" + RMDir /r "$INSTDIR\app" + RMDir /r "$INSTDIR\scripts" + Delete "$INSTDIR\${EXENAME}" + Delete "$INSTDIR\VERSION" + Delete "$INSTDIR\uninstall.exe" + + ; Preserve config.yaml (user data) — only remove the example + Delete "$INSTDIR\config.example.yaml" + + ; Remove shortcuts + Delete "$DESKTOP\${APPNAME}.lnk" + Delete "$SMSTARTUP\${APPNAME}.lnk" + RMDir /r "$SMPROGRAMS\${APPNAME}" + + ; Remove registry + DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" + + ; Remove install dir only if empty (config.yaml may remain) + RMDir "$INSTDIR" +SectionEnd diff --git a/media_server/config.py b/media_server/config.py index e2ba9eb..00f1faf 100644 --- a/media_server/config.py +++ b/media_server/config.py @@ -76,7 +76,10 @@ class Settings(BaseSettings): # Audio device settings audio_device: Optional[str] = Field( default=None, - description="Audio device name to control (None = default device). Use /api/audio/devices to list available devices.", + description=( + "Audio device name to control (None = default device)." + " Use /api/audio/devices to list available devices." + ), ) # Logging diff --git a/media_server/main.py b/media_server/main.py index 8c396f0..2a45643 100644 --- a/media_server/main.py +++ b/media_server/main.py @@ -15,8 +15,17 @@ from fastapi.staticfiles import StaticFiles from . import __version__ from .auth import get_token_label, token_label_var -from .config import settings, generate_default_config, get_config_dir -from .routes import audio_router, browser_router, callbacks_router, display_router, health_router, links_router, media_router, scripts_router +from .config import generate_default_config, get_config_dir, settings +from .routes import ( + audio_router, + browser_router, + callbacks_router, + display_router, + health_router, + links_router, + media_router, + scripts_router, +) from .services import get_media_controller from .services.websocket_manager import ws_manager @@ -206,12 +215,12 @@ def main(): if args.generate_config: config_path = generate_default_config() print(f"Configuration file generated at: {config_path}") - print(f"API Token has been saved to the config file.") + print("API Token has been saved to the config file.") return if args.show_token: print(f"Config directory: {get_config_dir()}") - print(f"\nAPI Tokens:") + print("\nAPI Tokens:") for label, token in settings.api_tokens.items(): print(f" {label:20} {token}") return diff --git a/media_server/models/__init__.py b/media_server/models/__init__.py index abf7966..637c173 100644 --- a/media_server/models/__init__.py +++ b/media_server/models/__init__.py @@ -1,11 +1,11 @@ """Pydantic models for the media server API.""" from .media import ( + MediaInfo, MediaState, MediaStatus, - VolumeRequest, SeekRequest, - MediaInfo, + VolumeRequest, ) __all__ = [ diff --git a/media_server/routes/__init__.py b/media_server/routes/__init__.py index 5305084..d42ef41 100644 --- a/media_server/routes/__init__.py +++ b/media_server/routes/__init__.py @@ -9,4 +9,13 @@ from .links import router as links_router from .media import router as media_router from .scripts import router as scripts_router -__all__ = ["audio_router", "browser_router", "callbacks_router", "display_router", "health_router", "links_router", "media_router", "scripts_router"] +__all__ = [ + "audio_router", + "browser_router", + "callbacks_router", + "display_router", + "health_router", + "links_router", + "media_router", + "scripts_router", +] diff --git a/media_server/routes/browser.py b/media_server/routes/browser.py index 78882c4..259fc93 100644 --- a/media_server/routes/browser.py +++ b/media_server/routes/browser.py @@ -4,20 +4,19 @@ import asyncio import logging import tempfile from pathlib import Path -from typing import Optional from urllib.parse import unquote from fastapi import APIRouter, Depends, HTTPException, Query, Response -from fastapi.responses import FileResponse, StreamingResponse +from fastapi.responses import FileResponse from pydantic import BaseModel, Field from ..auth import verify_token, verify_token_or_query from ..config import MediaFolderConfig, settings from ..config_manager import config_manager +from ..services import get_media_controller from ..services.browser_service import BrowserService from ..services.metadata_service import MetadataService from ..services.thumbnail_service import ThumbnailService -from ..services import get_media_controller from ..services.websocket_manager import ws_manager logger = logging.getLogger(__name__) @@ -281,7 +280,7 @@ async def browse( logger.warning(f"Folder temporarily unavailable: {e}") raise HTTPException( status_code=503, - detail=f"Folder is temporarily unavailable. It may be a network share that is not accessible at the moment." + detail="Folder is temporarily unavailable. It may be a network share that is not accessible at the moment." ) except Exception as e: logger.error(f"Error browsing directory (type: {type(e).__name__}): {e}") diff --git a/media_server/routes/callbacks.py b/media_server/routes/callbacks.py index 8fce8e6..677321d 100644 --- a/media_server/routes/callbacks.py +++ b/media_server/routes/callbacks.py @@ -2,7 +2,6 @@ import asyncio import logging -import re import subprocess import time from concurrent.futures import ThreadPoolExecutor @@ -238,7 +237,10 @@ async def create_callback( if callback_name in settings.callbacks: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Callback '{callback_name}' already exists. Use PUT /api/callbacks/update/{callback_name} to update it.", + detail=( + f"Callback '{callback_name}' already exists." + f" Use PUT /api/callbacks/update/{callback_name} to update it." + ), ) # Create callback config @@ -283,7 +285,10 @@ async def update_callback( if callback_name not in settings.callbacks: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Callback '{callback_name}' not found. Use POST /api/callbacks/create/{callback_name} to create it.", + detail=( + f"Callback '{callback_name}' not found." + f" Use POST /api/callbacks/create/{callback_name} to create it." + ), ) # Create updated callback config diff --git a/media_server/routes/display.py b/media_server/routes/display.py index cc9b434..a27b1dd 100644 --- a/media_server/routes/display.py +++ b/media_server/routes/display.py @@ -7,7 +7,6 @@ from pydantic import BaseModel, Field from ..auth import verify_token from ..services.display_service import ( - get_brightness, list_monitors, set_brightness, set_power, diff --git a/media_server/routes/media.py b/media_server/routes/media.py index 971199c..eee667f 100644 --- a/media_server/routes/media.py +++ b/media_server/routes/media.py @@ -3,14 +3,13 @@ import asyncio import logging -from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect -from fastapi import status +from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect, status from fastapi.responses import Response from ..auth import verify_token, verify_token_or_query from ..config import settings -from ..models import MediaStatus, VolumeRequest, SeekRequest -from ..services import get_media_controller, get_current_album_art +from ..models import MediaStatus, SeekRequest, VolumeRequest +from ..services import get_current_album_art, get_media_controller from ..services.websocket_manager import ws_manager logger = logging.getLogger(__name__) diff --git a/media_server/service/install_windows.py b/media_server/service/install_windows.py index f0edd87..3e52b53 100644 --- a/media_server/service/install_windows.py +++ b/media_server/service/install_windows.py @@ -13,15 +13,12 @@ Usage: import os import sys -import socket -import logging try: - import win32serviceutil - import win32service - import win32event import servicemanager - import win32api + import win32event + import win32service + import win32serviceutil WIN32_AVAILABLE = True except ImportError: @@ -64,8 +61,9 @@ class MediaServerService: def main(self): """Main service loop.""" import uvicorn - from media_server.main import app + from media_server.config import settings + from media_server.main import app config = uvicorn.Config( app, @@ -95,10 +93,9 @@ def install_service(): try: # Get the path to the Python executable - python_exe = sys.executable # Get the path to this module - module_path = os.path.abspath(__file__) + os.path.abspath(__file__) win32serviceutil.InstallService( MediaServerService._svc_name_, diff --git a/media_server/services/__init__.py b/media_server/services/__init__.py index d8575d8..39de409 100644 --- a/media_server/services/__init__.py +++ b/media_server/services/__init__.py @@ -40,8 +40,8 @@ def get_media_controller() -> "MediaController": system = platform.system() if system == "Windows": - from .windows_media import WindowsMediaController from ..config import settings + from .windows_media import WindowsMediaController _controller_instance = WindowsMediaController(audio_device=settings.audio_device) elif system == "Linux": diff --git a/media_server/services/android_media.py b/media_server/services/android_media.py index df4c991..109a8df 100644 --- a/media_server/services/android_media.py +++ b/media_server/services/android_media.py @@ -10,11 +10,10 @@ Installation: 4. Grant necessary permissions to Termux:API """ -import asyncio import json import logging import subprocess -from typing import Optional, Any +from typing import Any, Optional from ..models import MediaState, MediaStatus from .media_controller import MediaController diff --git a/media_server/services/browser_service.py b/media_server/services/browser_service.py index 72d751a..0be8f43 100644 --- a/media_server/services/browser_service.py +++ b/media_server/services/browser_service.py @@ -1,12 +1,10 @@ """Browser service for media file browsing and path validation.""" import logging -import os import stat as stat_module import time from datetime import datetime from pathlib import Path -from typing import Optional from ..config import settings diff --git a/media_server/services/display_service.py b/media_server/services/display_service.py index dbc9bbf..3500fe0 100644 --- a/media_server/services/display_service.py +++ b/media_server/services/display_service.py @@ -6,7 +6,7 @@ import logging import platform import struct import time -from dataclasses import dataclass, field +from dataclasses import dataclass logger = logging.getLogger(__name__) diff --git a/media_server/services/linux_media.py b/media_server/services/linux_media.py index a25eeec..c6628e8 100644 --- a/media_server/services/linux_media.py +++ b/media_server/services/linux_media.py @@ -3,7 +3,7 @@ import asyncio import logging import subprocess -from typing import Optional, Any +from typing import Any, Optional from ..models import MediaState, MediaStatus from .media_controller import MediaController diff --git a/media_server/services/macos_media.py b/media_server/services/macos_media.py index 3107851..2afdf26 100644 --- a/media_server/services/macos_media.py +++ b/media_server/services/macos_media.py @@ -3,7 +3,6 @@ import asyncio import logging import subprocess -import json from typing import Optional from ..models import MediaState, MediaStatus @@ -203,11 +202,6 @@ class MacOSMediaController(MediaController): async def play(self) -> bool: """Resume playback using media key simulation.""" # Use system media key - script = ''' - tell application "System Events" - key code 16 using {command down, option down} - end tell - ''' # Fallback: try specific app active_app = self._get_active_app() if active_app == "Spotify": diff --git a/media_server/services/metadata_service.py b/media_server/services/metadata_service.py index c14a13a..ef17ff4 100644 --- a/media_server/services/metadata_service.py +++ b/media_server/services/metadata_service.py @@ -2,7 +2,6 @@ import logging from pathlib import Path -from typing import Optional logger = logging.getLogger(__name__) @@ -21,7 +20,6 @@ class MetadataService: Dictionary with audio metadata. """ try: - import mutagen from mutagen import File as MutagenFile audio = MutagenFile(str(file_path), easy=True) @@ -68,7 +66,9 @@ class MetadataService: metadata["album"] = tags["album"][0] if isinstance(tags["album"], list) else tags["album"] if "albumartist" in tags: - metadata["album_artist"] = tags["albumartist"][0] if isinstance(tags["albumartist"], list) else tags["albumartist"] + metadata["album_artist"] = ( + tags["albumartist"][0] if isinstance(tags["albumartist"], list) else tags["albumartist"] + ) if "date" in tags: metadata["date"] = tags["date"][0] if isinstance(tags["date"], list) else tags["date"] @@ -77,7 +77,9 @@ class MetadataService: metadata["genre"] = tags["genre"][0] if isinstance(tags["genre"], list) else tags["genre"] if "tracknumber" in tags: - metadata["track_number"] = tags["tracknumber"][0] if isinstance(tags["tracknumber"], list) else tags["tracknumber"] + metadata["track_number"] = ( + tags["tracknumber"][0] if isinstance(tags["tracknumber"], list) else tags["tracknumber"] + ) # If no title tag, use filename if "title" not in metadata: @@ -110,7 +112,6 @@ class MetadataService: Dictionary with video metadata. """ try: - import mutagen from mutagen import File as MutagenFile video = MutagenFile(str(file_path)) diff --git a/media_server/services/thumbnail_service.py b/media_server/services/thumbnail_service.py index cb6c3f2..36742f2 100644 --- a/media_server/services/thumbnail_service.py +++ b/media_server/services/thumbnail_service.py @@ -3,9 +3,7 @@ import asyncio import hashlib import logging -import os import shutil -import subprocess from pathlib import Path from typing import Optional @@ -151,10 +149,10 @@ class ThumbnailService: Thumbnail bytes (JPEG) or None if no album art. """ try: - import mutagen + from io import BytesIO + from mutagen import File as MutagenFile from PIL import Image - from io import BytesIO audio = MutagenFile(str(file_path)) if audio is None: @@ -232,9 +230,10 @@ class ThumbnailService: Thumbnail bytes (JPEG) or None if ffmpeg not available. """ try: - from PIL import Image from io import BytesIO + from PIL import Image + # Check if ffmpeg is available if not shutil.which("ffmpeg"): logger.debug("ffmpeg not available, cannot generate video thumbnail") @@ -247,7 +246,11 @@ class ThumbnailService: cmd = [ "ffmpeg", "-i", str(file_path), - "-vf", f"thumbnail,scale={target_size[0]}:{target_size[1]}:force_original_aspect_ratio=increase,crop={target_size[0]}:{target_size[1]}", + "-vf", ( + f"thumbnail,scale={target_size[0]}:{target_size[1]}" + f":force_original_aspect_ratio=increase" + f",crop={target_size[0]}:{target_size[1]}" + ), "-frames:v", "1", "-f", "image2pipe", "-vcodec", "mjpeg", diff --git a/media_server/services/windows_media.py b/media_server/services/windows_media.py index b1cd063..2441ffe 100644 --- a/media_server/services/windows_media.py +++ b/media_server/services/windows_media.py @@ -5,7 +5,7 @@ import logging import threading import time as _time from concurrent.futures import ThreadPoolExecutor -from typing import Optional, Any +from typing import Any from ..models import MediaState, MediaStatus from .media_controller import MediaController @@ -47,6 +47,8 @@ def get_current_album_art() -> bytes | None: try: from winsdk.windows.media.control import ( GlobalSystemMediaTransportControlsSessionManager as MediaManager, + ) + from winsdk.windows.media.control import ( GlobalSystemMediaTransportControlsSessionPlaybackStatus as PlaybackStatus, ) @@ -61,11 +63,11 @@ _volume_control = None _configured_device_name: str | None = None try: - from ctypes import cast, POINTER - from comtypes import CLSCTX_ALL, CoInitialize, CoUninitialize - from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume - import warnings + from ctypes import POINTER, cast + + from comtypes import CLSCTX_ALL + from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume # Suppress pycaw warnings about missing device properties warnings.filterwarnings("ignore", category=UserWarning, module="pycaw") @@ -240,13 +242,18 @@ def _sync_get_media_status() -> dict[str, Any]: _track_skip_pending["stale_pos"] = -999 # Reset stale position tracking skip_just_completed = True # Reset position cache for new track - new_track_id = f"{current_title}:{result.get('artist', '')}:{result.get('duration', 0)}" + new_track_id = ( + f"{current_title}:{result.get('artist', '')}:{result.get('duration', 0)}" + ) _position_cache["track_id"] = new_track_id _position_cache["base_position"] = 0.0 _position_cache["base_time"] = current_time _position_cache["last_smtc_pos"] = -999 # Force fresh start _position_cache["is_playing"] = is_playing - logger.debug(f"Track skip complete, new title: {current_title}, grace until: {_track_skip_pending['grace_until']}") + logger.debug( + f"Track skip complete, new title: {current_title}," + f" grace until: {_track_skip_pending['grace_until']}" + ) elif current_time - _track_skip_pending["skip_time"] > 5.0: # Timeout after 5 seconds _track_skip_pending["active"] = False @@ -298,7 +305,10 @@ def _sync_get_media_status() -> dict[str, Any]: pos = smtc_pos _track_skip_pending["grace_until"] = 0 _track_skip_pending["stale_pos"] = -999 - logger.debug(f"Grace period: accepting SMTC pos {smtc_pos} (low={smtc_pos < 10}, changed={smtc_changed})") + logger.debug( + f"Grace period: accepting SMTC pos {smtc_pos}" + f" (low={smtc_pos < 10}, changed={smtc_changed})" + ) else: # SMTC is stale - keep interpolating pos = interpolated_pos @@ -307,7 +317,10 @@ def _sync_get_media_status() -> dict[str, Any]: _track_skip_pending["stale_pos"] = smtc_pos # Keep grace period active indefinitely while SMTC is stale _track_skip_pending["grace_until"] = current_time + 300.0 - logger.debug(f"Grace period: SMTC stale ({smtc_pos}), using interpolated {interpolated_pos}") + logger.debug( + f"Grace period: SMTC stale ({smtc_pos})," + f" using interpolated {interpolated_pos}" + ) else: # Normal position tracking # Create track ID from title + artist + duration @@ -335,7 +348,9 @@ def _sync_get_media_status() -> dict[str, Any]: # Update playing state if _position_cache.get("is_playing") != is_playing: - _position_cache["base_position"] = pos if is_playing else _position_cache.get("base_position", smtc_pos) + _position_cache["base_position"] = ( + pos if is_playing else _position_cache.get("base_position", smtc_pos) + ) _position_cache["base_time"] = current_time _position_cache["is_playing"] = is_playing diff --git a/media_server/static/index.html b/media_server/static/index.html index d99734a..3d7d13f 100644 --- a/media_server/static/index.html +++ b/media_server/static/index.html @@ -651,14 +651,6 @@ - - - - - - - - - + diff --git a/media_server/static/js/main.js b/media_server/static/js/app.js similarity index 65% rename from media_server/static/js/main.js rename to media_server/static/js/app.js index f9ef3e0..7011281 100644 --- a/media_server/static/js/main.js +++ b/media_server/static/js/app.js @@ -1,5 +1,131 @@ // ============================================================ -// Main: Initialization orchestrator (loaded last) +// App: Entry point — imports all modules, registers window globals, +// and orchestrates initialization (replaces main.js) +// ============================================================ + +// Layer 0: Core state & utilities +import { + cacheDom, dom, registerUpdateCallbacks, + initLocale, fetchVersion, formatTime, setupIconPreview, + isUserAdjustingVolume, setIsUserAdjustingVolume, + volumeUpdateTimer, setVolumeUpdateTimer, + currentDuration, currentPosition, setVolume, seek, + togglePlayPause, nextTrack, previousTrack, toggleMute, + VOLUME_THROTTLE_MS, VOLUME_RELEASE_DELAY_MS, + changeLocale, t, +} from './core.js'; + +// Layer 1: Player (tabs, theme, accent, vinyl, visualizer, UI) +import { + activeTab, switchTab, updateTabIndicator, setMiniPlayerVisible, + initTheme, toggleTheme, initAccentColor, applyAccentColor, + renderAccentSwatches, selectAccentColor, toggleAccentPicker, lightenColor, + toggleVinylMode, applyVinylMode, + visualizerEnabled, visualizerAvailable, + checkVisualizerAvailability, toggleVisualizer, applyVisualizerMode, + loadAudioDevices, onAudioDeviceChanged, + setupProgressDrag, updateUI, updatePlaybackState, stopPositionInterpolation, +} from './player.js'; + +// Layer 2: WebSocket +import { + connectWebSocket, showAuthForm, authenticate, clearToken, + manualReconnect, updateConnectionStatus, +} from './websocket.js'; + +// Layer 3: Features +import { + loadScripts, loadScriptsTable, displayQuickAccess, + showAddScriptDialog, showEditScriptDialog, closeScriptDialog, saveScript, + deleteScriptConfirm, executeScriptDebug, executeCallbackDebug, + closeExecutionDialog, scriptFormDirty, setScriptFormDirty, +} from './scripts.js'; + +import { + loadCallbacksTable, + showAddCallbackDialog, showEditCallbackDialog, closeCallbackDialog, + saveCallback, deleteCallbackConfirm, + callbackFormDirty, setCallbackFormDirty, +} from './callbacks.js'; + +import { + loadMediaFolders, initBrowserToolbar, thumbnailCache, + setViewMode, refreshBrowser, playAllFolder, + previousPage, nextPage, goToPage, + onBrowserSearch, clearBrowserSearch, onItemsPerPageChanged, + downloadFile, closeFolderDialog, saveFolder, + showManageFoldersDialog, +} from './browser.js'; + +import { + loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange, + toggleDisplayPower, loadHeaderLinks, loadLinksTable, + showAddLinkDialog, showEditLinkDialog, closeLinkDialog, saveLink, deleteLinkConfirm, + linkFormDirty, setLinkFormDirty, +} from './links.js'; + +import { + toggleDynamicBackground, applyDynamicBackground, updateBackgroundColors, +} from './background.js'; + +// ============================================================ +// Register late-bound callbacks for core's updateAllText() +// ============================================================ + +registerUpdateCallbacks({ + updatePlaybackState, + updateConnectionStatus, + loadScriptsTable, + loadCallbacksTable, + loadLinksTable, + displayQuickAccess, + renderAccentSwatches, +}); + +// ============================================================ +// Register all functions on window for HTML onclick handlers +// ============================================================ + +Object.assign(window, { + // Player controls + togglePlayPause, nextTrack, previousTrack, toggleMute, seek, + // Tabs + switchTab, + // Theme & accent + toggleTheme, toggleAccentPicker, selectAccentColor, lightenColor, + // Vinyl & visualizer + toggleVinylMode, toggleVisualizer, + // Background + toggleDynamicBackground, + // Auth + authenticate, clearToken, manualReconnect, + // Locale + changeLocale, + // Scripts + showAddScriptDialog, showEditScriptDialog, closeScriptDialog, saveScript, + deleteScriptConfirm, executeScriptDebug, executeCallbackDebug, + closeExecutionDialog, + // Callbacks + showAddCallbackDialog, showEditCallbackDialog, closeCallbackDialog, + saveCallback, deleteCallbackConfirm, + // Browser + setViewMode, refreshBrowser, playAllFolder, + previousPage, nextPage, goToPage, + onBrowserSearch, clearBrowserSearch, onItemsPerPageChanged, + downloadFile, closeFolderDialog, saveFolder, + showManageFoldersDialog, + // Links + showAddLinkDialog, showEditLinkDialog, closeLinkDialog, + saveLink, deleteLinkConfirm, + // Display + loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange, + toggleDisplayPower, + // Audio device + onAudioDeviceChanged, +}); + +// ============================================================ +// Initialization (DOMContentLoaded) // ============================================================ window.addEventListener('DOMContentLoaded', async () => { @@ -50,7 +176,7 @@ window.addEventListener('DOMContentLoaded', async () => { function setupVolumeSlider(sliderId) { const slider = document.getElementById(sliderId); slider.addEventListener('input', (e) => { - isUserAdjustingVolume = true; + setIsUserAdjustingVolume(true); const volume = parseInt(e.target.value); // Sync both sliders and displays dom.volumeDisplay.textContent = `${volume}%`; @@ -59,20 +185,20 @@ window.addEventListener('DOMContentLoaded', async () => { dom.miniVolumeSlider.value = volume; if (volumeUpdateTimer) clearTimeout(volumeUpdateTimer); - volumeUpdateTimer = setTimeout(() => { + setVolumeUpdateTimer(setTimeout(() => { setVolume(volume); - volumeUpdateTimer = null; - }, VOLUME_THROTTLE_MS); + setVolumeUpdateTimer(null); + }, VOLUME_THROTTLE_MS)); }); slider.addEventListener('change', (e) => { if (volumeUpdateTimer) { clearTimeout(volumeUpdateTimer); - volumeUpdateTimer = null; + setVolumeUpdateTimer(null); } const volume = parseInt(e.target.value); setVolume(volume); - setTimeout(() => { isUserAdjustingVolume = false; }, VOLUME_RELEASE_DELAY_MS); + setTimeout(() => { setIsUserAdjustingVolume(false); }, VOLUME_RELEASE_DELAY_MS); }); } @@ -124,25 +250,24 @@ window.addEventListener('DOMContentLoaded', async () => { // Script form dirty state tracking const scriptForm = document.getElementById('scriptForm'); scriptForm.addEventListener('input', () => { - scriptFormDirty = true; + setScriptFormDirty(true); }); scriptForm.addEventListener('change', () => { - scriptFormDirty = true; + setScriptFormDirty(true); }); // Callback form dirty state tracking const callbackForm = document.getElementById('callbackForm'); callbackForm.addEventListener('input', () => { - callbackFormDirty = true; + setCallbackFormDirty(true); }); callbackForm.addEventListener('change', () => { - callbackFormDirty = true; + setCallbackFormDirty(true); }); // Script dialog backdrop click to close const scriptDialog = document.getElementById('scriptDialog'); scriptDialog.addEventListener('click', (e) => { - // Check if click is on the backdrop (not the dialog content) if (e.target === scriptDialog) { closeScriptDialog(); } @@ -151,7 +276,6 @@ window.addEventListener('DOMContentLoaded', async () => { // Callback dialog backdrop click to close const callbackDialog = document.getElementById('callbackDialog'); callbackDialog.addEventListener('click', (e) => { - // Check if click is on the backdrop (not the dialog content) if (e.target === callbackDialog) { closeCallbackDialog(); } @@ -200,10 +324,10 @@ window.addEventListener('DOMContentLoaded', async () => { // Track link form dirty state const linkForm = document.getElementById('linkForm'); linkForm.addEventListener('input', () => { - linkFormDirty = true; + setLinkFormDirty(true); }); linkForm.addEventListener('change', () => { - linkFormDirty = true; + setLinkFormDirty(true); }); // Initialize browser toolbar and load folders diff --git a/media_server/static/js/background.js b/media_server/static/js/background.js index 63e868f..ca7c6e6 100644 --- a/media_server/static/js/background.js +++ b/media_server/static/js/background.js @@ -2,6 +2,8 @@ // Background: WebGL shader-based dynamic background // ============================================================ +import { frequencyData } from './player.js'; + let bgCanvas = null; let bgGL = null; let bgProgram = null; @@ -216,7 +218,7 @@ function resizeBackgroundCanvas() { // ---- Cached color/theme updates (called on accent or theme change, not per-frame) ---- -function updateBackgroundColors() { +export function updateBackgroundColors() { const style = getComputedStyle(document.documentElement); const accentHex = style.getPropertyValue('--accent').trim(); if (accentHex && accentHex.length >= 7) { @@ -245,8 +247,8 @@ function renderBackgroundFrame() { const time = performance.now() / 1000 - bgStartTime; - // Smooth audio data from the global frequencyData (shared with visualizer) - if (typeof frequencyData !== 'undefined' && frequencyData && frequencyData.frequencies) { + // Smooth audio data from the imported frequencyData (shared with visualizer) + if (frequencyData && frequencyData.frequencies) { const bins = frequencyData.frequencies; const step = Math.max(1, Math.floor(bins.length / BG_BAND_COUNT)); for (let i = 0; i < BG_BAND_COUNT; i++) { @@ -296,13 +298,13 @@ function stopBackground() { // ---- Public API ---- -function toggleDynamicBackground() { +export function toggleDynamicBackground() { bgEnabled = !bgEnabled; localStorage.setItem('dynamicBackground', bgEnabled); applyDynamicBackground(); } -function applyDynamicBackground() { +export function applyDynamicBackground() { const btn = document.getElementById('bgToggle'); if (bgEnabled) { startBackground(); diff --git a/media_server/static/js/browser.js b/media_server/static/js/browser.js index 8234c0f..f1a7bfd 100644 --- a/media_server/static/js/browser.js +++ b/media_server/static/js/browser.js @@ -2,6 +2,11 @@ // Media Browser: Navigation, rendering, search, pagination // ============================================================ +import { + t, showToast, escapeHtml, closeDialog, + SEARCH_DEBOUNCE_MS, EMPTY_SVG_FILE, EMPTY_SVG_FOLDER, emptyStateHtml, +} from './core.js'; + // Browser state let currentFolderId = null; let currentPath = ''; @@ -13,11 +18,11 @@ let viewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid'; let cachedItems = null; let browserSearchTerm = ''; let browserSearchTimer = null; -const thumbnailCache = new Map(); +export const thumbnailCache = new Map(); const THUMBNAIL_CACHE_MAX = 200; // Load media folders on page load -async function loadMediaFolders() { +export async function loadMediaFolders() { try { const token = localStorage.getItem('media_server_token'); if (!token) { @@ -169,11 +174,11 @@ async function browsePath(folderId, path, offset = 0, nocache = false) { } } -function renderBreadcrumbs(currentPath, parentPath) { +function renderBreadcrumbs(currentPathStr, parentPath) { const breadcrumb = document.getElementById('breadcrumb'); breadcrumb.innerHTML = ''; - const parts = (currentPath || '').split('/').filter(p => p); + const parts = (currentPathStr || '').split('/').filter(p => p); let path = '/'; // Home link (back to folder list) @@ -373,10 +378,10 @@ function renderBrowserGrid(items, container) { // Lazy load thumbnail loadThumbnail(thumbnail, item.name); } else { - const icon = document.createElement('div'); - icon.className = 'browser-icon'; - icon.textContent = getFileIcon(item.type); - thumbWrapper.appendChild(icon); + const iconEl = document.createElement('div'); + iconEl.className = 'browser-icon'; + iconEl.textContent = getFileIcon(item.type); + thumbWrapper.appendChild(iconEl); } // Play overlay for media files @@ -527,11 +532,10 @@ async function loadThumbnail(imgElement, fileName) { }; // Revoke previous blob URL if not managed by cache - // (Cache is keyed by path, so check values) if (imgElement.src && imgElement.src.startsWith('blob:')) { let isCached = false; - for (const url of thumbnailCache.values()) { - if (url === imgElement.src) { isCached = true; break; } + for (const cachedUrl of thumbnailCache.values()) { + if (cachedUrl === imgElement.src) { isCached = true; break; } } if (!isCached) URL.revokeObjectURL(imgElement.src); } @@ -544,10 +548,10 @@ async function loadThumbnail(imgElement, fileName) { if (isList) { parent.textContent = '\u{1F3B5}'; } else { - const icon = document.createElement('div'); - icon.className = 'browser-icon'; - icon.textContent = '\u{1F3B5}'; - parent.insertBefore(icon, parent.firstChild); + const iconEl = document.createElement('div'); + iconEl.className = 'browser-icon'; + iconEl.textContent = '\u{1F3B5}'; + parent.insertBefore(iconEl, parent.firstChild); } } } catch (error) { @@ -600,7 +604,7 @@ async function playMediaFile(fileName) { } } -async function playAllFolder() { +export async function playAllFolder() { if (playInProgress) return; playInProgress = true; const btn = document.getElementById('playAllBtn'); @@ -634,7 +638,7 @@ async function playAllFolder() { } } -async function downloadFile(fileName, event) { +export async function downloadFile(fileName, event) { if (event) event.stopPropagation(); const token = localStorage.getItem('media_server_token'); if (!token) return; @@ -699,19 +703,19 @@ function renderPagination() { nextBtn.disabled = currentPage === totalPages; } -function previousPage() { +export function previousPage() { if (currentOffset >= itemsPerPage) { browsePath(currentFolderId, currentPath, currentOffset - itemsPerPage); } } -function nextPage() { +export function nextPage() { if (currentOffset + itemsPerPage < totalItems) { browsePath(currentFolderId, currentPath, currentOffset + itemsPerPage); } } -function refreshBrowser() { +export function refreshBrowser() { if (currentFolderId) { browsePath(currentFolderId, currentPath, currentOffset, true); } else { @@ -720,7 +724,7 @@ function refreshBrowser() { } // Browser search -function onBrowserSearch() { +export function onBrowserSearch() { const input = document.getElementById('browserSearchInput'); const clearBtn = document.getElementById('browserSearchClear'); const term = input.value.trim(); @@ -735,7 +739,7 @@ function onBrowserSearch() { }, SEARCH_DEBOUNCE_MS); } -function clearBrowserSearch() { +export function clearBrowserSearch() { const input = document.getElementById('browserSearchInput'); input.value = ''; document.getElementById('browserSearchClear').style.display = 'none'; @@ -768,7 +772,7 @@ function showBrowserSearch(visible) { } } -function setViewMode(mode) { +export function setViewMode(mode) { if (mode === viewMode) return; viewMode = mode; localStorage.setItem('mediaBrowser.viewMode', mode); @@ -786,7 +790,7 @@ function setViewMode(mode) { } } -function onItemsPerPageChanged() { +export function onItemsPerPageChanged() { const select = document.getElementById('itemsPerPageSelect'); itemsPerPage = parseInt(select.value); localStorage.setItem('mediaBrowser.itemsPerPage', itemsPerPage); @@ -798,7 +802,7 @@ function onItemsPerPageChanged() { } } -function goToPage() { +export function goToPage() { const pageInput = document.getElementById('pageInput'); const totalPages = Math.ceil(totalItems / itemsPerPage); let page = parseInt(pageInput.value); @@ -813,7 +817,7 @@ function goToPage() { } } -function initBrowserToolbar() { +export function initBrowserToolbar() { // Restore view mode const savedViewMode = localStorage.getItem('mediaBrowser.viewMode') || 'grid'; viewMode = savedViewMode; @@ -865,18 +869,16 @@ function loadLastBrowserPath() { } // Folder Management -function showManageFoldersDialog() { +export function showManageFoldersDialog() { // TODO: Implement folder management UI - // For now, show a simple alert showToast(t('browser.manage_folders_hint'), 'info'); } -function closeFolderDialog() { +export function closeFolderDialog() { closeDialog(document.getElementById('folderDialog')); } -async function saveFolder(event) { +export async function saveFolder(event) { event.preventDefault(); - // TODO: Implement folder save functionality closeFolderDialog(); } diff --git a/media_server/static/js/callbacks.js b/media_server/static/js/callbacks.js index d0ef940..f481c9f 100644 --- a/media_server/static/js/callbacks.js +++ b/media_server/static/js/callbacks.js @@ -2,10 +2,13 @@ // Callbacks: CRUD management // ============================================================ -let callbackFormDirty = false; +import { t, showToast, escapeHtml, closeDialog, showConfirm } from './core.js'; + +export let callbackFormDirty = false; +export function setCallbackFormDirty(value) { callbackFormDirty = value; } let _loadCallbacksPromise = null; -async function loadCallbacksTable() { +export async function loadCallbacksTable() { if (_loadCallbacksPromise) return _loadCallbacksPromise; _loadCallbacksPromise = _loadCallbacksTableImpl(); _loadCallbacksPromise.finally(() => { _loadCallbacksPromise = null; }); @@ -59,7 +62,7 @@ async function _loadCallbacksTableImpl() { } } -function showAddCallbackDialog() { +export function showAddCallbackDialog() { const dialog = document.getElementById('callbackDialog'); const form = document.getElementById('callbackForm'); const title = document.getElementById('callbackDialogTitle'); @@ -75,7 +78,7 @@ function showAddCallbackDialog() { dialog.showModal(); } -async function showEditCallbackDialog(callbackName) { +export async function showEditCallbackDialog(callbackName) { const token = localStorage.getItem('media_server_token'); const dialog = document.getElementById('callbackDialog'); const title = document.getElementById('callbackDialogTitle'); @@ -115,7 +118,7 @@ async function showEditCallbackDialog(callbackName) { } } -async function closeCallbackDialog() { +export async function closeCallbackDialog() { if (callbackFormDirty) { if (!await showConfirm(t('callbacks.confirm.unsaved'))) { return; @@ -128,7 +131,7 @@ async function closeCallbackDialog() { document.body.classList.remove('dialog-open'); } -async function saveCallback(event) { +export async function saveCallback(event) { event.preventDefault(); const submitBtn = event.target.querySelector('button[type="submit"]'); @@ -179,7 +182,7 @@ async function saveCallback(event) { } } -async function deleteCallbackConfirm(callbackName) { +export async function deleteCallbackConfirm(callbackName) { if (!await showConfirm(t('callbacks.confirm.delete').replace('{name}', callbackName))) { return; } diff --git a/media_server/static/js/core.js b/media_server/static/js/core.js index 6f5a846..8bdf964 100644 --- a/media_server/static/js/core.js +++ b/media_server/static/js/core.js @@ -3,22 +3,22 @@ // ============================================================ // SVG path constants (avoid rebuilding innerHTML on every state update) -const SVG_PLAY = ''; -const SVG_PAUSE = ''; -const SVG_STOP = ''; -const SVG_IDLE = ''; -const SVG_MUTED = ''; -const SVG_UNMUTED = ''; +export const SVG_PLAY = ''; +export const SVG_PAUSE = ''; +export const SVG_STOP = ''; +export const SVG_IDLE = ''; +export const SVG_MUTED = ''; +export const SVG_UNMUTED = ''; // Empty state illustration SVGs -const EMPTY_SVG_FOLDER = ''; -const EMPTY_SVG_FILE = ''; -function emptyStateHtml(svgStr, text) { +export const EMPTY_SVG_FOLDER = ''; +export const EMPTY_SVG_FILE = ''; +export function emptyStateHtml(svgStr, text) { return `
${svgStr}

${text}

`; } // Media source registry: substring key → { name, icon } -const MEDIA_SOURCES = { +export const MEDIA_SOURCES = { 'spotify': { name: 'Spotify', icon: '' @@ -89,7 +89,7 @@ const MEDIA_SOURCES = { }, }; -function resolveMediaSource(raw) { +export function resolveMediaSource(raw) { if (!raw) return null; const lower = raw.toLowerCase(); for (const [key, info] of Object.entries(MEDIA_SOURCES)) { @@ -99,8 +99,8 @@ function resolveMediaSource(raw) { } // Cached DOM references (populated once after DOMContentLoaded) -const dom = {}; -function cacheDom() { +export const dom = {}; +export function cacheDom() { dom.trackTitle = document.getElementById('track-title'); dom.artist = document.getElementById('artist'); dom.album = document.getElementById('album'); @@ -137,26 +137,35 @@ function cacheDom() { } // Timing constants -const VOLUME_THROTTLE_MS = 16; -const POSITION_INTERPOLATION_MS = 100; -const SEARCH_DEBOUNCE_MS = 200; -const TOAST_DURATION_MS = 3000; -const WS_BACKOFF_BASE_MS = 3000; -const WS_BACKOFF_MAX_MS = 30000; -const WS_MAX_RECONNECT_ATTEMPTS = 20; -const WS_PING_INTERVAL_MS = 30000; -const VOLUME_RELEASE_DELAY_MS = 500; +export const VOLUME_THROTTLE_MS = 16; +export const POSITION_INTERPOLATION_MS = 100; +export const SEARCH_DEBOUNCE_MS = 200; +export const TOAST_DURATION_MS = 3000; +export const WS_BACKOFF_BASE_MS = 3000; +export const WS_BACKOFF_MAX_MS = 30000; +export const WS_MAX_RECONNECT_ATTEMPTS = 20; +export const WS_PING_INTERVAL_MS = 30000; +export const VOLUME_RELEASE_DELAY_MS = 500; // Shared state (accessed across multiple modules) -let ws = null; -let currentState = 'idle'; -let currentDuration = 0; -let currentPosition = 0; -let isUserAdjustingVolume = false; -let volumeUpdateTimer = null; -let scripts = []; -let lastStatus = null; -let currentPlayState = 'idle'; +export let ws = null; +export function setWs(value) { ws = value; } +export let currentState = 'idle'; +export function setCurrentState(value) { currentState = value; } +export let currentDuration = 0; +export function setCurrentDuration(value) { currentDuration = value; } +export let currentPosition = 0; +export function setCurrentPosition(value) { currentPosition = value; } +export let isUserAdjustingVolume = false; +export function setIsUserAdjustingVolume(value) { isUserAdjustingVolume = value; } +export let volumeUpdateTimer = null; +export function setVolumeUpdateTimer(value) { volumeUpdateTimer = value; } +export let scripts = []; +export function setScripts(value) { scripts = value; } +export let lastStatus = null; +export function setLastStatus(value) { lastStatus = value; } +export let currentPlayState = 'idle'; +export function setCurrentPlayState(value) { currentPlayState = value; } // ============================================================ // Internationalization (i18n) @@ -178,7 +187,7 @@ const fallbackTranslations = { 'player.status.disconnected': 'Disconnected' }; -function t(key, params = {}) { +export function t(key, params = {}) { let text = translations[key] || fallbackTranslations[key] || key; Object.keys(params).forEach(param => { text = text.replace(new RegExp(`\\{${param}\\}`, 'g'), params[param]); @@ -208,7 +217,7 @@ function detectBrowserLocale() { return supportedLocales[langCode] ? langCode : 'en'; } -async function initLocale() { +export async function initLocale() { const savedLocale = localStorage.getItem('locale') || detectBrowserLocale(); await setLocale(savedLocale); } @@ -228,7 +237,7 @@ async function setLocale(locale) { document.body.classList.add('translations-loaded'); } -function changeLocale() { +export function changeLocale() { const select = document.getElementById('locale-select'); const newLocale = select.value; if (newLocale && newLocale !== currentLocale) { @@ -244,6 +253,26 @@ function updateLocaleSelect() { } } +// Note: updateAllText calls functions from other modules via late-bound references. +// These are set from app.js after all modules are loaded. +let _updatePlaybackState = null; +let _updateConnectionStatus = null; +let _loadScriptsTable = null; +let _loadCallbacksTable = null; +let _loadLinksTable = null; +let _displayQuickAccess = null; +let _renderAccentSwatches = null; + +export function registerUpdateCallbacks(callbacks) { + _updatePlaybackState = callbacks.updatePlaybackState; + _updateConnectionStatus = callbacks.updateConnectionStatus; + _loadScriptsTable = callbacks.loadScriptsTable; + _loadCallbacksTable = callbacks.loadCallbacksTable; + _loadLinksTable = callbacks.loadLinksTable; + _displayQuickAccess = callbacks.displayQuickAccess; + _renderAccentSwatches = callbacks.renderAccentSwatches; +} + function updateAllText() { document.querySelectorAll('[data-i18n]').forEach(el => { const key = el.getAttribute('data-i18n'); @@ -259,9 +288,9 @@ function updateAllText() { }); // Re-apply dynamic content with new translations - updatePlaybackState(currentState); + if (_updatePlaybackState) _updatePlaybackState(currentState); const connected = ws && ws.readyState === WebSocket.OPEN; - updateConnectionStatus(connected); + if (_updateConnectionStatus) _updateConnectionStatus(connected); if (lastStatus) { const fallbackTitle = lastStatus.state === 'idle' ? t('player.no_media') : t('player.title_unavailable'); @@ -273,15 +302,15 @@ function updateAllText() { const token = localStorage.getItem('media_server_token'); if (token) { - loadScriptsTable(); - loadCallbacksTable(); - loadLinksTable(); - displayQuickAccess(); + if (_loadScriptsTable) _loadScriptsTable(); + if (_loadCallbacksTable) _loadCallbacksTable(); + if (_loadLinksTable) _loadLinksTable(); + if (_displayQuickAccess) _displayQuickAccess(); } - renderAccentSwatches(); + if (_renderAccentSwatches) _renderAccentSwatches(); } -async function fetchVersion() { +export async function fetchVersion() { try { const response = await fetch('/api/health'); if (response.ok) { @@ -300,20 +329,20 @@ async function fetchVersion() { // Shared Utilities // ============================================================ -function formatTime(seconds) { +export function formatTime(seconds) { if (!seconds || seconds < 0) return '0:00'; const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; } -function escapeHtml(text) { +export function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } -function showToast(message, type = 'success') { +export function showToast(message, type = 'success') { const container = document.getElementById('toast-container'); const toast = document.createElement('div'); toast.className = `toast ${type}`; @@ -331,7 +360,7 @@ function showToast(message, type = 'success') { }, TOAST_DURATION_MS); } -function closeDialog(dialog) { +export function closeDialog(dialog) { dialog.classList.add('dialog-closing'); dialog.addEventListener('animationend', () => { dialog.classList.remove('dialog-closing'); @@ -339,7 +368,7 @@ function closeDialog(dialog) { }, { once: true }); } -function showConfirm(message) { +export function showConfirm(message) { return new Promise((resolve) => { const dialog = document.getElementById('confirmDialog'); const msg = document.getElementById('confirmDialogMessage'); @@ -371,7 +400,7 @@ function showConfirm(message) { // API Commands // ============================================================ -async function sendCommand(endpoint, body = null) { +export async function sendCommand(endpoint, body = null) { const token = localStorage.getItem('media_server_token'); const options = { @@ -399,7 +428,7 @@ async function sendCommand(endpoint, body = null) { } } -function togglePlayPause() { +export function togglePlayPause() { if (currentState === 'playing') { sendCommand('pause'); } else { @@ -407,16 +436,16 @@ function togglePlayPause() { } } -function nextTrack() { +export function nextTrack() { sendCommand('next'); } -function previousTrack() { +export function previousTrack() { sendCommand('previous'); } let lastSentVolume = -1; -function setVolume(volume) { +export function setVolume(volume) { if (volume === lastSentVolume) return; lastSentVolume = volume; if (ws && ws.readyState === WebSocket.OPEN) { @@ -426,11 +455,11 @@ function setVolume(volume) { } } -function toggleMute() { +export function toggleMute() { sendCommand('mute'); } -function seek(position) { +export function seek(position) { sendCommand('seek', { position: position }); } @@ -448,7 +477,7 @@ function _persistMdiCache() { try { localStorage.setItem('mdiIconCache', JSON.stringify(mdiIconCache)); } catch {} } -async function fetchMdiIcon(iconName) { +export async function fetchMdiIcon(iconName) { const name = iconName.replace(/^mdi:/, ''); if (mdiIconCache[name]) return mdiIconCache[name]; @@ -467,7 +496,7 @@ async function fetchMdiIcon(iconName) { return ''; } -async function resolveMdiIcons(container) { +export async function resolveMdiIcons(container) { const els = container.querySelectorAll('[data-mdi-icon]'); await Promise.all(Array.from(els).map(async (el) => { const icon = el.dataset.mdiIcon; @@ -477,7 +506,7 @@ async function resolveMdiIcons(container) { })); } -function setupIconPreview(inputId, previewId) { +export function setupIconPreview(inputId, previewId) { const input = document.getElementById(inputId); const preview = document.getElementById(previewId); if (!input || !preview) return; diff --git a/media_server/static/js/links.js b/media_server/static/js/links.js index bccfe3e..a4abd8a 100644 --- a/media_server/static/js/links.js +++ b/media_server/static/js/links.js @@ -1,11 +1,13 @@ // ============================================================ -// Display Brightness & Power Control +// Display Brightness & Power Control + Links Management // ============================================================ +import { t, showToast, escapeHtml, closeDialog, showConfirm, resolveMdiIcons, fetchMdiIcon } from './core.js'; + let displayBrightnessTimers = {}; const DISPLAY_THROTTLE_MS = 50; -async function loadDisplayMonitors() { +export async function loadDisplayMonitors() { const token = localStorage.getItem('media_server_token'); if (!token) return; @@ -86,7 +88,7 @@ async function loadDisplayMonitors() { } } -function onDisplayBrightnessInput(monitorId, value) { +export function onDisplayBrightnessInput(monitorId, value) { const label = document.getElementById(`brightness-val-${monitorId}`); if (label) label.textContent = `${value}%`; @@ -97,7 +99,7 @@ function onDisplayBrightnessInput(monitorId, value) { }, DISPLAY_THROTTLE_MS); } -function onDisplayBrightnessChange(monitorId, value) { +export function onDisplayBrightnessChange(monitorId, value) { if (displayBrightnessTimers[monitorId]) { clearTimeout(displayBrightnessTimers[monitorId]); displayBrightnessTimers[monitorId] = null; @@ -121,7 +123,7 @@ async function sendDisplayBrightness(monitorId, brightness) { } } -async function toggleDisplayPower(monitorId, monitorName) { +export async function toggleDisplayPower(monitorId, monitorName) { const btn = document.getElementById(`power-btn-${monitorId}`); const isOn = btn && btn.classList.contains('on'); const newState = !isOn; @@ -157,7 +159,7 @@ async function toggleDisplayPower(monitorId, monitorName) { // Header Quick Links // ============================================================ -async function loadHeaderLinks() { +export async function loadHeaderLinks() { const token = localStorage.getItem('media_server_token'); if (!token) return; @@ -197,9 +199,10 @@ async function loadHeaderLinks() { // ============================================================ let _loadLinksPromise = null; -let linkFormDirty = false; +export let linkFormDirty = false; +export function setLinkFormDirty(value) { linkFormDirty = value; } -async function loadLinksTable() { +export async function loadLinksTable() { if (_loadLinksPromise) return _loadLinksPromise; _loadLinksPromise = _loadLinksTableImpl(); _loadLinksPromise.finally(() => { _loadLinksPromise = null; }); @@ -251,7 +254,7 @@ async function _loadLinksTableImpl() { } } -function showAddLinkDialog() { +export function showAddLinkDialog() { const dialog = document.getElementById('linkDialog'); const form = document.getElementById('linkForm'); const title = document.getElementById('linkDialogTitle'); @@ -269,7 +272,7 @@ function showAddLinkDialog() { dialog.showModal(); } -async function showEditLinkDialog(linkName) { +export async function showEditLinkDialog(linkName) { const token = localStorage.getItem('media_server_token'); const dialog = document.getElementById('linkDialog'); const title = document.getElementById('linkDialogTitle'); @@ -320,7 +323,7 @@ async function showEditLinkDialog(linkName) { } } -async function closeLinkDialog() { +export async function closeLinkDialog() { if (linkFormDirty) { if (!await showConfirm(t('links.confirm.unsaved'))) { return; @@ -333,7 +336,7 @@ async function closeLinkDialog() { document.body.classList.remove('dialog-open'); } -async function saveLink(event) { +export async function saveLink(event) { event.preventDefault(); const submitBtn = event.target.querySelector('button[type="submit"]'); @@ -385,7 +388,7 @@ async function saveLink(event) { } } -async function deleteLinkConfirm(linkName) { +export async function deleteLinkConfirm(linkName) { if (!await showConfirm(t('links.confirm.delete').replace('{name}', linkName))) { return; } diff --git a/media_server/static/js/player.js b/media_server/static/js/player.js index f7857c2..46e2bf1 100644 --- a/media_server/static/js/player.js +++ b/media_server/static/js/player.js @@ -2,10 +2,21 @@ // Player: Tabs, theme, accent, vinyl, visualizer, UI updates // ============================================================ -// Tab management -let activeTab = 'player'; +import { + dom, t, formatTime, showToast, resolveMediaSource, + SVG_PLAY, SVG_PAUSE, SVG_STOP, SVG_IDLE, SVG_MUTED, SVG_UNMUTED, + ws, currentState, setCurrentState, currentDuration, setCurrentDuration, + currentPosition, setCurrentPosition, isUserAdjustingVolume, + lastStatus, setLastStatus, currentPlayState, setCurrentPlayState, + POSITION_INTERPOLATION_MS, seek, +} from './core.js'; +import { updateBackgroundColors } from './background.js'; +import { loadDisplayMonitors } from './links.js'; -function setMiniPlayerVisible(visible) { +// Tab management +export let activeTab = 'player'; + +export function setMiniPlayerVisible(visible) { const miniPlayer = document.getElementById('mini-player'); if (visible) { miniPlayer.classList.remove('hidden'); @@ -16,7 +27,7 @@ function setMiniPlayerVisible(visible) { } } -function updateTabIndicator(btn, animate = true) { +export function updateTabIndicator(btn, animate = true) { const indicator = document.getElementById('tabIndicator'); if (!indicator || !btn) return; const tabBar = document.getElementById('tabBar'); @@ -32,7 +43,7 @@ function updateTabIndicator(btn, animate = true) { } } -function switchTab(tabName) { +export function switchTab(tabName) { activeTab = tabName; document.querySelectorAll('[data-tab-content]').forEach(el => { @@ -75,12 +86,12 @@ function switchTab(tabName) { } // Theme management -function initTheme() { +export function initTheme() { const savedTheme = localStorage.getItem('theme') || 'dark'; setTheme(savedTheme); } -function setTheme(theme) { +export function setTheme(theme) { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('theme', theme); @@ -100,17 +111,17 @@ function setTheme(theme) { metaThemeColor.setAttribute('content', theme === 'light' ? '#ffffff' : '#121212'); } - if (typeof updateBackgroundColors === 'function') updateBackgroundColors(); + updateBackgroundColors(); } -function toggleTheme() { +export function toggleTheme() { const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark'; const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; setTheme(newTheme); } // Accent color management -const accentPresets = [ +export const accentPresets = [ { name: 'Green', color: '#1db954', hover: '#1ed760' }, { name: 'Blue', color: '#3b82f6', hover: '#60a5fa' }, { name: 'Purple', color: '#8b5cf6', hover: '#a78bfa' }, @@ -122,7 +133,7 @@ const accentPresets = [ { name: 'Yellow', color: '#eab308', hover: '#facc15' }, ]; -function lightenColor(hex, percent) { +export function lightenColor(hex, percent) { const num = parseInt(hex.replace('#', ''), 16); const r = Math.min(255, (num >> 16) + Math.round(255 * percent / 100)); const g = Math.min(255, ((num >> 8) & 0xff) + Math.round(255 * percent / 100)); @@ -130,7 +141,7 @@ function lightenColor(hex, percent) { return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; } -function initAccentColor() { +export function initAccentColor() { const saved = localStorage.getItem('accentColor'); if (saved) { const preset = accentPresets.find(p => p.color === saved); @@ -143,16 +154,16 @@ function initAccentColor() { renderAccentSwatches(); } -function applyAccentColor(color, hover) { +export function applyAccentColor(color, hover) { document.documentElement.style.setProperty('--accent', color); document.documentElement.style.setProperty('--accent-hover', hover); localStorage.setItem('accentColor', color); const dot = document.getElementById('accentDot'); if (dot) dot.style.background = color; - if (typeof updateBackgroundColors === 'function') updateBackgroundColors(); + updateBackgroundColors(); } -function renderAccentSwatches() { +export function renderAccentSwatches() { const dropdown = document.getElementById('accentDropdown'); if (!dropdown) return; const current = localStorage.getItem('accentColor') || '#1db954'; @@ -177,13 +188,13 @@ function renderAccentSwatches() { dropdown.innerHTML = swatches + customRow; } -function selectAccentColor(color, hover) { +export function selectAccentColor(color, hover) { applyAccentColor(color, hover); renderAccentSwatches(); document.getElementById('accentDropdown').classList.remove('open'); } -function toggleAccentPicker() { +export function toggleAccentPicker() { document.getElementById('accentDropdown').classList.toggle('open'); } @@ -225,14 +236,14 @@ function restoreVinylAngle() { setInterval(saveVinylAngle, 2000); window.addEventListener('beforeunload', saveVinylAngle); -function toggleVinylMode() { +export function toggleVinylMode() { if (vinylMode) saveVinylAngle(); vinylMode = !vinylMode; localStorage.setItem('vinylMode', vinylMode); applyVinylMode(); } -function applyVinylMode() { +export function applyVinylMode() { const container = document.querySelector('.album-art-container'); const btn = document.getElementById('vinylToggle'); if (!container) return; @@ -260,15 +271,16 @@ function updateVinylSpin() { } // Audio Visualizer -let visualizerEnabled = localStorage.getItem('visualizerEnabled') === 'true'; -let visualizerAvailable = false; +export let visualizerEnabled = localStorage.getItem('visualizerEnabled') === 'true'; +export let visualizerAvailable = false; let visualizerCtx = null; let visualizerAnimFrame = null; -let frequencyData = null; +export let frequencyData = null; +export function setFrequencyData(value) { frequencyData = value; } let smoothedFrequencies = null; const VISUALIZER_SMOOTHING = 0.15; -async function checkVisualizerAvailability() { +export async function checkVisualizerAvailability() { try { const token = localStorage.getItem('media_server_token'); const resp = await fetch('/api/media/visualizer/status', { @@ -285,13 +297,13 @@ async function checkVisualizerAvailability() { if (btn) btn.style.display = visualizerAvailable ? '' : 'none'; } -function toggleVisualizer() { +export function toggleVisualizer() { visualizerEnabled = !visualizerEnabled; localStorage.setItem('visualizerEnabled', visualizerEnabled); applyVisualizerMode(); } -function applyVisualizerMode() { +export function applyVisualizerMode() { const container = document.querySelector('.album-art-container'); const btn = document.getElementById('visualizerToggle'); if (!container) return; @@ -333,7 +345,7 @@ function startVisualizerRender() { renderVisualizerFrame(); } -function stopVisualizerRender() { +export function stopVisualizerRender() { if (visualizerAnimFrame) { cancelAnimationFrame(visualizerAnimFrame); visualizerAnimFrame = null; @@ -410,7 +422,7 @@ function renderVisualizerFrame() { } // Audio device selection -async function loadAudioDevices() { +export async function loadAudioDevices() { const section = document.getElementById('audioDeviceSection'); const select = document.getElementById('audioDeviceSelect'); if (!section || !select) return; @@ -478,7 +490,7 @@ function updateAudioDeviceStatus(status) { } } -async function onAudioDeviceChanged() { +export async function onAudioDeviceChanged() { const select = document.getElementById('audioDeviceSelect'); if (!select) return; @@ -519,7 +531,7 @@ let lastPositionUpdate = 0; let lastPositionValue = 0; let interpolationInterval = null; -function setupProgressDrag(bar, fill) { +export function setupProgressDrag(bar, fill) { let dragging = false; function getPercent(clientX) { @@ -571,8 +583,8 @@ function setupProgressDrag(bar, fill) { }); } -function updateUI(status) { - lastStatus = status; +export function updateUI(status) { + setLastStatus(status); const fallbackTitle = status.state === 'idle' ? t('player.no_media') : t('player.title_unavailable'); dom.trackTitle.textContent = status.title || fallbackTitle; @@ -583,7 +595,7 @@ function updateUI(status) { dom.miniArtist.textContent = status.artist || ''; const previousState = currentState; - currentState = status.state; + setCurrentState(status.state); updatePlaybackState(status.state); const altText = status.title && status.artist @@ -628,8 +640,8 @@ function updateUI(status) { } if (status.duration && status.position !== null) { - currentDuration = status.duration; - currentPosition = status.position; + setCurrentDuration(status.duration); + setCurrentPosition(status.position); lastPositionUpdate = Date.now(); lastPositionValue = status.position; updateProgress(status.position, status.duration); @@ -661,8 +673,8 @@ function updateUI(status) { } } -function updatePlaybackState(state) { - currentPlayState = state; +export function updatePlaybackState(state) { + setCurrentPlayState(state); switch(state) { case 'playing': dom.playbackState.textContent = t('state.playing'); @@ -715,7 +727,7 @@ function updateProgress(position, duration) { miniBar.setAttribute('aria-valuemax', durRound); } -function startPositionInterpolation() { +export function startPositionInterpolation() { if (interpolationInterval) { clearInterval(interpolationInterval); } @@ -728,7 +740,7 @@ function startPositionInterpolation() { }, POSITION_INTERPOLATION_MS); } -function stopPositionInterpolation() { +export function stopPositionInterpolation() { if (interpolationInterval) { clearInterval(interpolationInterval); interpolationInterval = null; diff --git a/media_server/static/js/scripts.js b/media_server/static/js/scripts.js index 5f028ac..bae5647 100644 --- a/media_server/static/js/scripts.js +++ b/media_server/static/js/scripts.js @@ -2,9 +2,16 @@ // Scripts: CRUD, quick access, execution dialog // ============================================================ -let scriptFormDirty = false; +import { + t, showToast, escapeHtml, closeDialog, showConfirm, + resolveMdiIcons, fetchMdiIcon, + scripts, setScripts, +} from './core.js'; -async function loadScripts() { +export let scriptFormDirty = false; +export function setScriptFormDirty(value) { scriptFormDirty = value; } + +export async function loadScripts() { const token = localStorage.getItem('media_server_token'); try { @@ -15,7 +22,7 @@ async function loadScripts() { }); if (response.ok) { - scripts = await response.json(); + setScripts(await response.json()); displayQuickAccess(); } } catch (error) { @@ -24,7 +31,7 @@ async function loadScripts() { } let _quickAccessGen = 0; -async function displayQuickAccess() { +export async function displayQuickAccess() { const gen = ++_quickAccessGen; const grid = document.getElementById('scripts-grid'); @@ -150,7 +157,7 @@ async function executeScript(scriptName, buttonElement) { // ============================================================ let _loadScriptsPromise = null; -async function loadScriptsTable() { +export async function loadScriptsTable() { if (_loadScriptsPromise) return _loadScriptsPromise; _loadScriptsPromise = _loadScriptsTableImpl(); _loadScriptsPromise.finally(() => { _loadScriptsPromise = null; }); @@ -206,7 +213,7 @@ async function _loadScriptsTableImpl() { } } -function showAddScriptDialog() { +export function showAddScriptDialog() { const dialog = document.getElementById('scriptDialog'); const form = document.getElementById('scriptForm'); const title = document.getElementById('dialogTitle'); @@ -224,7 +231,7 @@ function showAddScriptDialog() { dialog.showModal(); } -async function showEditScriptDialog(scriptName) { +export async function showEditScriptDialog(scriptName) { const token = localStorage.getItem('media_server_token'); const dialog = document.getElementById('scriptDialog'); const title = document.getElementById('dialogTitle'); @@ -274,7 +281,7 @@ async function showEditScriptDialog(scriptName) { } } -async function closeScriptDialog() { +export async function closeScriptDialog() { if (scriptFormDirty) { if (!await showConfirm(t('scripts.confirm.unsaved'))) { return; @@ -287,7 +294,7 @@ async function closeScriptDialog() { document.body.classList.remove('dialog-open'); } -async function saveScript(event) { +export async function saveScript(event) { event.preventDefault(); const submitBtn = event.target.querySelector('button[type="submit"]'); @@ -341,7 +348,7 @@ async function saveScript(event) { } } -async function deleteScriptConfirm(scriptName) { +export async function deleteScriptConfirm(scriptName) { if (!await showConfirm(t('scripts.confirm.delete').replace('{name}', scriptName))) { return; } @@ -373,7 +380,7 @@ async function deleteScriptConfirm(scriptName) { // Execution Result Dialog (shared by scripts and callbacks) // ============================================================ -function closeExecutionDialog() { +export function closeExecutionDialog() { const dialog = document.getElementById('executionDialog'); closeDialog(dialog); document.body.classList.remove('dialog-open'); @@ -435,7 +442,7 @@ function showExecutionResult(name, result, type = 'script') { dialog.showModal(); } -async function executeScriptDebug(scriptName) { +export async function executeScriptDebug(scriptName) { const token = localStorage.getItem('media_server_token'); const dialog = document.getElementById('executionDialog'); const title = document.getElementById('executionDialogTitle'); @@ -486,7 +493,7 @@ async function executeScriptDebug(scriptName) { } } -async function executeCallbackDebug(callbackName) { +export async function executeCallbackDebug(callbackName) { const token = localStorage.getItem('media_server_token'); const dialog = document.getElementById('executionDialog'); const title = document.getElementById('executionDialogTitle'); diff --git a/media_server/static/js/websocket.js b/media_server/static/js/websocket.js index 8f162a8..cafe82e 100644 --- a/media_server/static/js/websocket.js +++ b/media_server/static/js/websocket.js @@ -2,11 +2,21 @@ // WebSocket: Connection, reconnection, authentication // ============================================================ +import { + dom, t, showToast, setWs, + WS_BACKOFF_BASE_MS, WS_BACKOFF_MAX_MS, + WS_MAX_RECONNECT_ATTEMPTS, WS_PING_INTERVAL_MS, +} from './core.js'; +import { updateUI, visualizerEnabled, visualizerAvailable, setFrequencyData, stopPositionInterpolation, loadAudioDevices } from './player.js'; +import { loadScripts, loadScriptsTable, displayQuickAccess } from './scripts.js'; +import { loadCallbacksTable } from './callbacks.js'; +import { loadHeaderLinks, loadLinksTable } from './links.js'; + let reconnectTimeout = null; let pingInterval = null; let wsReconnectAttempts = 0; -function showAuthForm(errorMessage = '') { +export function showAuthForm(errorMessage = '') { const overlay = document.getElementById('auth-overlay'); overlay.classList.remove('hidden'); @@ -23,7 +33,7 @@ function hideAuthForm() { document.getElementById('auth-overlay').classList.add('hidden'); } -function authenticate() { +export function authenticate() { const token = document.getElementById('token-input').value.trim(); if (!token) { showAuthForm(t('auth.required')); @@ -34,15 +44,18 @@ function authenticate() { connectWebSocket(token); } -function clearToken() { +export function clearToken() { localStorage.removeItem('media_server_token'); - if (ws) { - ws.close(); - } + // Access ws via import + import('./core.js').then(core => { + if (core.ws) { + core.ws.close(); + } + }); showAuthForm(t('auth.cleared')); } -function connectWebSocket(token) { +export function connectWebSocket(token) { if (pingInterval) { clearInterval(pingInterval); pingInterval = null; @@ -51,9 +64,10 @@ function connectWebSocket(token) { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/api/media/ws?token=${encodeURIComponent(token)}`; - ws = new WebSocket(wsUrl); + const newWs = new WebSocket(wsUrl); + setWs(newWs); - ws.onopen = () => { + newWs.onopen = () => { console.log('WebSocket connected'); wsReconnectAttempts = 0; updateConnectionStatus(true); @@ -66,11 +80,11 @@ function connectWebSocket(token) { loadHeaderLinks(); loadAudioDevices(); if (visualizerEnabled && visualizerAvailable) { - ws.send(JSON.stringify({ type: 'enable_visualizer' })); + newWs.send(JSON.stringify({ type: 'enable_visualizer' })); } }; - ws.onmessage = (event) => { + newWs.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'status' || msg.type === 'status_update') { @@ -85,18 +99,18 @@ function connectWebSocket(token) { loadLinksTable(); displayQuickAccess(); } else if (msg.type === 'audio_data') { - frequencyData = msg.data; + setFrequencyData(msg.data); } else if (msg.type === 'error') { console.error('WebSocket error:', msg.message); } }; - ws.onerror = (error) => { + newWs.onerror = (error) => { console.error('WebSocket error:', error); updateConnectionStatus(false); }; - ws.onclose = (event) => { + newWs.onclose = (event) => { console.log('WebSocket closed:', event.code); updateConnectionStatus(false); stopPositionInterpolation(); @@ -131,13 +145,13 @@ function connectWebSocket(token) { }; pingInterval = setInterval(() => { - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'ping' })); + if (newWs && newWs.readyState === WebSocket.OPEN) { + newWs.send(JSON.stringify({ type: 'ping' })); } }, WS_PING_INTERVAL_MS); } -function updateConnectionStatus(connected) { +export function updateConnectionStatus(connected) { if (connected) { dom.statusDot.classList.add('connected'); } else { @@ -159,7 +173,7 @@ function hideConnectionBanner() { banner.classList.add('hidden'); } -function manualReconnect() { +export function manualReconnect() { const savedToken = localStorage.getItem('media_server_token'); if (savedToken) { wsReconnectAttempts = 0; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4496bd3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,690 @@ +{ + "name": "media-server-frontend", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "media-server-frontend", + "version": "1.0.0", + "devDependencies": { + "esbuild": "^0.27.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + } + }, + "dependencies": { + "@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "dev": true, + "optional": true + }, + "@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "dev": true, + "optional": true + }, + "@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "dev": true, + "optional": true + }, + "@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "dev": true, + "optional": true + }, + "@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "dev": true, + "optional": true + }, + "@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "dev": true, + "optional": true + }, + "@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "dev": true, + "optional": true + }, + "@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "dev": true, + "optional": true + }, + "esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "requires": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..81f64e4 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "media-server-frontend", + "version": "1.0.0", + "private": true, + "description": "Frontend build tooling for media server WebUI", + "scripts": { + "build": "node esbuild.mjs", + "watch": "node esbuild.mjs --watch" + }, + "devDependencies": { + "esbuild": "^0.27.4" + } +} diff --git a/pyproject.toml b/pyproject.toml index 7fe70cc..89ad0b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ dev = [ "pytest>=7.0", "pytest-asyncio>=0.21", "httpx>=0.24", + "ruff>=0.4.0", ] [project.urls] @@ -67,3 +68,18 @@ build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] include = ["media_server*"] + +[tool.ruff] +target-version = "py310" +line-length = 120 + +[tool.ruff.lint] +select = ["E", "F", "I", "W"] + +[tool.ruff.lint.per-file-ignores] +# AppleScript string literals contain long lines that cannot be broken +"media_server/services/macos_media.py" = ["E501"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" diff --git a/scripts/start-server-background.vbs b/scripts/start-server-background.vbs deleted file mode 100644 index 8e15691..0000000 --- a/scripts/start-server-background.vbs +++ /dev/null @@ -1,7 +0,0 @@ -Set WshShell = CreateObject("WScript.Shell") -Set FSO = CreateObject("Scripting.FileSystemObject") -' Get parent folder of scripts folder (media-server root) -WshShell.CurrentDirectory = FSO.GetParentFolderName(FSO.GetParentFolderName(WScript.ScriptFullName)) -WshShell.Run "python -m media_server.main", 0, False -Set FSO = Nothing -Set WshShell = Nothing diff --git a/scripts/start-server.bat b/scripts/start-server.bat deleted file mode 100644 index ea10b08..0000000 --- a/scripts/start-server.bat +++ /dev/null @@ -1,15 +0,0 @@ -@echo off -REM Media Server Startup Script -REM This script starts the media server - -echo Starting Media Server... -echo. - -REM Change to the media-server directory (parent of scripts folder) -cd /d "%~dp0\.." - -REM Start the media server -python -m media_server.main - -REM If the server exits, pause to show any error messages -pause diff --git a/scripts/stop-server.bat b/scripts/stop-server.bat deleted file mode 100644 index c19848d..0000000 --- a/scripts/stop-server.bat +++ /dev/null @@ -1,19 +0,0 @@ -@echo off -REM Media Server Stop Script -REM This script stops the running media server - -echo Stopping Media Server... -echo. - -REM Find and kill Python processes running media_server.main -for /f "tokens=2" %%i in ('tasklist /FI "IMAGENAME eq python.exe" /FO LIST ^| findstr /B "PID:"') do ( - wmic process where "ProcessId=%%i" get CommandLine 2>nul | findstr /C:"media_server.main" >nul - if not errorlevel 1 ( - taskkill /PID %%i /F - echo Media server process (PID %%i) terminated. - ) -) - -echo. -echo Done! Media server stopped. -pause