Add CI/CD pipelines, NSIS installer, ES module bundling, and ruff linting
Lint & Test / test (push) Failing after 9s
Release / create-release (push) Successful in 1s
Release / build-windows (push) Successful in 59s

- 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
This commit is contained in:
2026-03-23 02:01:28 +03:00
parent be48318212
commit 5439af1955
41 changed files with 1702 additions and 310 deletions
+103
View File
@@ -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
+35
View File
@@ -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
+4
View File
@@ -49,3 +49,7 @@ Thumbs.db
# Thumbnail cache
.cache/
# Node.js / esbuild
node_modules/
media_server/static/dist/
+42
View File
@@ -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.**
+151
View File
@@ -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}"
+26
View File
@@ -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);
}
+135
View File
@@ -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
+4 -1
View File
@@ -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
+13 -4
View File
@@ -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
+2 -2
View File
@@ -1,11 +1,11 @@
"""Pydantic models for the media server API."""
from .media import (
MediaInfo,
MediaState,
MediaStatus,
VolumeRequest,
SeekRequest,
MediaInfo,
VolumeRequest,
)
__all__ = [
+10 -1
View File
@@ -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",
]
+3 -4
View File
@@ -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}")
+8 -3
View File
@@ -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
-1
View File
@@ -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,
+3 -4
View File
@@ -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__)
+6 -9
View File
@@ -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_,
+1 -1
View File
@@ -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":
+1 -2
View File
@@ -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
-2
View File
@@ -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
+1 -1
View File
@@ -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__)
+1 -1
View File
@@ -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
-6
View File
@@ -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":
+6 -5
View File
@@ -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))
+9 -6
View File
@@ -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",
+25 -10
View File
@@ -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
+1 -9
View File
@@ -651,14 +651,6 @@
</div>
</footer>
<script src="/static/js/core.js"></script>
<script src="/static/js/player.js"></script>
<script src="/static/js/websocket.js"></script>
<script src="/static/js/scripts.js"></script>
<script src="/static/js/callbacks.js"></script>
<script src="/static/js/browser.js"></script>
<script src="/static/js/links.js"></script>
<script src="/static/js/background.js"></script>
<script src="/static/js/main.js"></script>
<script src="/static/dist/app.bundle.js"></script>
</body>
</html>
@@ -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
+7 -5
View File
@@ -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();
+33 -31
View File
@@ -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();
}
+10 -7
View File
@@ -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;
}
+86 -57
View File
@@ -3,22 +3,22 @@
// ============================================================
// SVG path constants (avoid rebuilding innerHTML on every state update)
const SVG_PLAY = '<path d="M8 5v14l11-7z"/>';
const SVG_PAUSE = '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>';
const SVG_STOP = '<path d="M6 6h12v12H6z"/>';
const SVG_IDLE = '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>';
const SVG_MUTED = '<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>';
const SVG_UNMUTED = '<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>';
export const SVG_PLAY = '<path d="M8 5v14l11-7z"/>';
export const SVG_PAUSE = '<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>';
export const SVG_STOP = '<path d="M6 6h12v12H6z"/>';
export const SVG_IDLE = '<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>';
export const SVG_MUTED = '<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>';
export const SVG_UNMUTED = '<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>';
// Empty state illustration SVGs
const EMPTY_SVG_FOLDER = '<svg viewBox="0 0 64 64"><path d="M8 16h20l4-6h20a4 4 0 014 4v36a4 4 0 01-4 4H8a4 4 0 01-4-4V20a4 4 0 014-4z"/><path d="M4 24h56" stroke-dasharray="4 3" opacity="0.4"/></svg>';
const EMPTY_SVG_FILE = '<svg viewBox="0 0 64 64"><path d="M16 4h22l14 14v38a4 4 0 01-4 4H16a4 4 0 01-4-4V8a4 4 0 014-4z"/><path d="M38 4v14h14"/><path d="M22 32h20M22 40h14" opacity="0.5"/></svg>';
function emptyStateHtml(svgStr, text) {
export const EMPTY_SVG_FOLDER = '<svg viewBox="0 0 64 64"><path d="M8 16h20l4-6h20a4 4 0 014 4v36a4 4 0 01-4 4H8a4 4 0 01-4-4V20a4 4 0 014-4z"/><path d="M4 24h56" stroke-dasharray="4 3" opacity="0.4"/></svg>';
export const EMPTY_SVG_FILE = '<svg viewBox="0 0 64 64"><path d="M16 4h22l14 14v38a4 4 0 01-4 4H16a4 4 0 01-4-4V8a4 4 0 014-4z"/><path d="M38 4v14h14"/><path d="M22 32h20M22 40h14" opacity="0.5"/></svg>';
export function emptyStateHtml(svgStr, text) {
return `<div class="empty-state-illustration">${svgStr}<p>${text}</p></div>`;
}
// Media source registry: substring key → { name, icon }
const MEDIA_SOURCES = {
export const MEDIA_SOURCES = {
'spotify': {
name: 'Spotify',
icon: '<svg viewBox="0 0 24 24"><path fill="#1DB954" d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg>'
@@ -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 '<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>';
}
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;
+16 -13
View File
@@ -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;
}
+50 -38
View File
@@ -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;
+20 -13
View File
@@ -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');
+32 -18
View File
@@ -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;
+690
View File
@@ -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"
}
}
}
}
+13
View File
@@ -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"
}
}
+16
View File
@@ -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"
-7
View File
@@ -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
-15
View File
@@ -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
-19
View File
@@ -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