diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml
new file mode 100644
index 0000000..374418e
--- /dev/null
+++ b/.gitea/workflows/release.yml
@@ -0,0 +1,103 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+jobs:
+ # --- Create Gitea release ---
+ create-release:
+ runs-on: ubuntu-latest
+ outputs:
+ release_id: ${{ steps.create.outputs.release_id }}
+ version: ${{ steps.create.outputs.version }}
+ steps:
+ - name: Create Gitea release
+ id: create
+ env:
+ GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
+ run: |
+ TAG="${{ gitea.ref_name }}"
+ VERSION="${TAG#v}"
+ BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
+
+ IS_PRE="false"
+ if echo "$TAG" | grep -qE '(alpha|beta|rc)'; then
+ IS_PRE="true"
+ fi
+
+ BODY_JSON=$(python3 -c "
+ import json, textwrap
+ tag = '$TAG'
+ body = '''## Downloads
+ | Platform | File |
+ |----------|------|
+ | Windows (installer) | \`MediaServer-{tag}-setup.exe\` |
+ | Windows (portable) | \`MediaServer-{tag}-win-x64.zip\` |
+ '''
+ print(json.dumps(textwrap.dedent(body).strip()))
+ ")
+
+ RELEASE=$(curl -s -X POST "$BASE_URL/releases" \
+ -H "Authorization: token $GITEA_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d "{
+ \"tag_name\": \"$TAG\",
+ \"name\": \"Media Server $TAG\",
+ \"body\": $BODY_JSON,
+ \"draft\": false,
+ \"prerelease\": $IS_PRE
+ }")
+
+ RELEASE_ID=$(echo "$RELEASE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
+ echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+
+ # --- Build Windows installer + portable ZIP ---
+ build-windows:
+ needs: create-release
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Build frontend
+ run: npm ci && npm run build
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+
+ - name: Install build tools
+ run: sudo apt-get update && sudo apt-get install -y --no-install-recommends nsis zip
+
+ - name: Build Windows distribution
+ run: |
+ chmod +x build-dist-windows.sh
+ ./build-dist-windows.sh "${{ gitea.ref_name }}"
+
+ - name: Build NSIS installer
+ run: |
+ VERSION="${{ needs.create-release.outputs.version }}"
+ makensis -DVERSION="${VERSION}" installer.nsi
+
+ - name: Upload assets to release
+ env:
+ GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
+ run: |
+ RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
+ BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
+
+ for FILE in build/MediaServer-*.zip build/MediaServer-*-setup.exe; do
+ [ -f "$FILE" ] || continue
+ echo "Uploading $(basename "$FILE")..."
+ curl -s -X POST \
+ "$BASE_URL/releases/$RELEASE_ID/assets?name=$(basename "$FILE")" \
+ -H "Authorization: token $GITEA_TOKEN" \
+ -H "Content-Type: application/octet-stream" \
+ --data-binary "@$FILE"
+ done
diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml
new file mode 100644
index 0000000..c625967
--- /dev/null
+++ b/.gitea/workflows/test.yml
@@ -0,0 +1,35 @@
+name: Lint & Test
+
+on:
+ push:
+ branches: [master]
+ pull_request:
+ branches: [master]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Build frontend
+ run: npm ci && npm run build
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+
+ - name: Install dependencies
+ run: |
+ pip install --upgrade pip
+ pip install -e ".[dev]"
+
+ - name: Lint
+ run: ruff check media_server/
+
+ - name: Test
+ run: pytest --tb=short -q
diff --git a/.gitignore b/.gitignore
index 0b508b5..6c4be85 100644
--- a/.gitignore
+++ b/.gitignore
@@ -49,3 +49,7 @@ Thumbs.db
# Thumbnail cache
.cache/
+
+# Node.js / esbuild
+node_modules/
+media_server/static/dist/
diff --git a/CLAUDE.md b/CLAUDE.md
index ef9a8d9..9e63502 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -133,6 +133,48 @@ When releasing a new version, update both files with the same version string.
**Important:** After making any changes, always ask the user if the version needs to be incremented.
+## CI/CD
+
+Gitea Actions workflow at `.gitea/workflows/test.yml` runs on every push/PR to `master`:
+
+1. **Lint** — `ruff check media_server/` (rules: E, F, I, W)
+2. **Test** — `pytest --tb=short -q`
+
+Release workflow at `.gitea/workflows/release.yml` triggers on `v*` tags:
+
+1. **Create release** — Gitea release via REST API (detects pre-release from tag)
+2. **Build Windows** — cross-builds on Linux using embedded Python + NSIS installer
+3. **Upload assets** — portable ZIP + installer `.exe` attached to the release
+
+### Releasing
+
+```bash
+# Stable release
+git tag v1.0.0 && git push origin v1.0.0
+
+# Pre-release
+git tag v1.1.0-alpha.1 && git push origin v1.1.0-alpha.1
+```
+
+### Installer
+
+The NSIS installer (`installer.nsi`) installs to `%LOCALAPPDATA%\Media Server` (no admin required) with optional:
+- **Desktop shortcut**
+- **Start with Windows** (Startup folder shortcut, runs hidden via VBS)
+
+Uninstall preserves `config.yaml` (user data).
+
+Reference: [gitea-python-ci-cd.md](https://git.dolgolyov-family.by/alexei.dolgolyov/claude-code-facts/src/branch/main/gitea-python-ci-cd.md)
+
+### Before Pushing
+
+Ensure CI will pass locally:
+
+```bash
+ruff check media_server/
+pytest --tb=short -q
+```
+
## Git Rules
- **ALWAYS ask for user approval before committing and pushing changes.**
diff --git a/build-dist-windows.sh b/build-dist-windows.sh
new file mode 100644
index 0000000..b2b4acb
--- /dev/null
+++ b/build-dist-windows.sh
@@ -0,0 +1,151 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Cross-build Windows distribution on Linux
+# Usage: ./build-dist-windows.sh [VERSION]
+
+# --- Version detection ---
+VERSION="${1:-}"
+
+if [ -z "$VERSION" ]; then
+ VERSION=$(git describe --tags --exact-match 2>/dev/null || true)
+fi
+
+if [ -z "$VERSION" ]; then
+ VERSION="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
+fi
+
+if [ -z "$VERSION" ]; then
+ VERSION=$(grep -oP '__version__\s*=\s*"\K[^"]+' \
+ media_server/__init__.py 2>/dev/null || echo "0.0.0")
+fi
+
+VERSION_CLEAN="${VERSION#v}"
+echo "Building Media Server v${VERSION_CLEAN} for Windows"
+
+# --- Configuration ---
+PYTHON_VERSION="3.11.9"
+PYTHON_SHORT="311"
+DIST_DIR="dist/media-server"
+WHEEL_DIR="build/win-wheels"
+SITE_PACKAGES="${DIST_DIR}/python/Lib/site-packages"
+BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-win-x64"
+
+rm -rf dist build
+mkdir -p "${DIST_DIR}" "${WHEEL_DIR}" "${SITE_PACKAGES}"
+
+# --- Download embedded Python ---
+echo "Downloading embedded Python ${PYTHON_VERSION}..."
+curl -sL "https://www.python.org/ftp/python/${PYTHON_VERSION}/python-${PYTHON_VERSION}-embed-amd64.zip" \
+ -o build/python-embed.zip
+unzip -qo build/python-embed.zip -d "${DIST_DIR}/python"
+
+# Patch ._pth to enable site-packages and app source
+PTH_FILE=$(ls "${DIST_DIR}"/python/python*._pth | head -1)
+sed -i 's/^#\s*import site/import site/' "$PTH_FILE"
+echo 'Lib\site-packages' >> "$PTH_FILE"
+echo '..\..\app' >> "$PTH_FILE"
+
+# --- Download Windows wheels ---
+echo "Downloading Windows wheels..."
+
+# Core dependencies
+CORE_DEPS=(
+ "fastapi>=0.109.0"
+ "uvicorn[standard]>=0.27.0"
+ "pydantic>=2.0"
+ "pydantic-settings>=2.0"
+ "pyyaml>=6.0"
+ "mutagen>=1.47.0"
+ "pillow>=10.0.0"
+)
+
+# Windows-specific dependencies
+WIN_DEPS=(
+ "winsdk>=1.0.0b10"
+ "pywin32>=306"
+ "comtypes>=1.2.0"
+ "pycaw>=20230407"
+ "screen-brightness-control>=0.20.0"
+ "monitorcontrol>=3.0.0"
+)
+
+# Visualizer dependencies
+VIS_DEPS=(
+ "soundcard>=0.4.0"
+ "numpy>=1.24.0"
+)
+
+ALL_DEPS=("${CORE_DEPS[@]}" "${WIN_DEPS[@]}" "${VIS_DEPS[@]}")
+
+for dep in "${ALL_DEPS[@]}"; do
+ pip download --quiet --dest "$WHEEL_DIR" \
+ --platform win_amd64 --python-version "${PYTHON_SHORT}" \
+ --implementation cp --only-binary :all: \
+ "$dep" 2>/dev/null || \
+ pip download --quiet --dest "$WHEEL_DIR" "$dep"
+done
+
+# Install wheels into site-packages
+echo "Installing wheels..."
+for whl in "$WHEEL_DIR"/*.whl; do
+ unzip -qo "$whl" -d "$SITE_PACKAGES"
+done
+
+# --- Size optimization ---
+echo "Optimizing size..."
+find "$SITE_PACKAGES" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
+find "$SITE_PACKAGES" -type d -name tests -exec rm -rf {} + 2>/dev/null || true
+find "$SITE_PACKAGES" -type d -name "*.dist-info" -exec rm -rf {} + 2>/dev/null || true
+find "$SITE_PACKAGES" -name "*.pyi" -delete 2>/dev/null || true
+rm -rf "$SITE_PACKAGES"/{pip,setuptools,pkg_resources}* 2>/dev/null || true
+
+# Trim numpy if present
+rm -rf "$SITE_PACKAGES"/numpy/{tests,f2py,typing} 2>/dev/null || true
+
+# --- Verify frontend bundle ---
+if [ ! -f "media_server/static/dist/app.bundle.js" ]; then
+ echo "ERROR: Frontend bundle not found. Run 'npm ci && npm run build' first."
+ exit 1
+fi
+
+# --- Copy application ---
+echo "Copying application files..."
+mkdir -p "${DIST_DIR}/app"
+cp -r media_server "${DIST_DIR}/app/"
+
+# Remove source JS (bundle is in dist/)
+rm -rf "${DIST_DIR}/app/media_server/static/js"
+# Remove source maps from release
+rm -f "${DIST_DIR}/app/media_server/static/dist/"*.map
+
+# Copy config example
+cp config.example.yaml "${DIST_DIR}/"
+
+# Copy scripts needed for auto-start
+mkdir -p "${DIST_DIR}/scripts"
+cp scripts/start-hidden.vbs "${DIST_DIR}/scripts/"
+
+# --- Write version ---
+echo "$VERSION_CLEAN" > "${DIST_DIR}/VERSION"
+
+# --- Create launcher ---
+cat > "${DIST_DIR}/media-server.bat" << 'LAUNCHER'
+@echo off
+setlocal
+set "ROOT=%~dp0"
+"%ROOT%python\python.exe" -m media_server.main %*
+LAUNCHER
+
+# --- Package ---
+echo "Creating archives..."
+mkdir -p build
+
+# Portable ZIP
+cp -r "${DIST_DIR}" "${BUILD_OUTPUT}"
+cd build
+zip -qr "MediaServer-v${VERSION_CLEAN}-win-x64.zip" "MediaServer-v${VERSION_CLEAN}-win-x64"
+cd ..
+
+echo "Build complete: build/MediaServer-v${VERSION_CLEAN}-win-x64.zip"
+echo "Dist directory ready for NSIS: ${DIST_DIR}"
diff --git a/esbuild.mjs b/esbuild.mjs
new file mode 100644
index 0000000..9f0c8b6
--- /dev/null
+++ b/esbuild.mjs
@@ -0,0 +1,26 @@
+import * as esbuild from 'esbuild';
+
+const srcDir = 'media_server/static';
+const outDir = `${srcDir}/dist`;
+
+const watch = process.argv.includes('--watch');
+
+/** @type {esbuild.BuildOptions} */
+const jsOpts = {
+ entryPoints: [`${srcDir}/js/app.js`],
+ bundle: true,
+ format: 'iife',
+ outfile: `${outDir}/app.bundle.js`,
+ minify: true,
+ sourcemap: true,
+ target: ['es2020'],
+ logLevel: 'info',
+};
+
+if (watch) {
+ const jsCtx = await esbuild.context(jsOpts);
+ await jsCtx.watch();
+ console.log('Watching for changes...');
+} else {
+ await esbuild.build(jsOpts);
+}
diff --git a/installer.nsi b/installer.nsi
new file mode 100644
index 0000000..a930e00
--- /dev/null
+++ b/installer.nsi
@@ -0,0 +1,135 @@
+; Media Server NSIS Installer
+; Cross-compilable: apt install nsis && makensis -DVERSION="1.0.0" installer.nsi
+
+!include "MUI2.nsh"
+!include "FileFunc.nsh"
+
+; --- Configuration ---
+!define APPNAME "Media Server"
+!define EXENAME "media-server.bat"
+!define VBSNAME "start-hidden.vbs"
+!ifndef VERSION
+ !define VERSION "0.0.0"
+!endif
+
+Name "${APPNAME} ${VERSION}"
+OutFile "build\MediaServer-v${VERSION}-setup.exe"
+InstallDir "$LOCALAPPDATA\${APPNAME}"
+RequestExecutionLevel user
+
+; --- UI ---
+; To use a custom icon, convert icon.svg to icon.ico and uncomment:
+; !define MUI_ICON "media_server\static\icons\icon.ico"
+; !define MUI_UNICON "media_server\static\icons\icon.ico"
+!define MUI_ABORTWARNING
+
+!insertmacro MUI_PAGE_WELCOME
+!insertmacro MUI_PAGE_DIRECTORY
+!insertmacro MUI_PAGE_COMPONENTS
+!insertmacro MUI_PAGE_INSTFILES
+!insertmacro MUI_PAGE_FINISH
+
+!insertmacro MUI_UNPAGE_CONFIRM
+!insertmacro MUI_UNPAGE_INSTFILES
+
+!insertmacro MUI_LANGUAGE "English"
+
+; --- Sections ---
+Section "!Core (required)" SecCore
+ SectionIn RO
+
+ ; Stop running instance if any
+ nsExec::ExecToLog 'taskkill /F /IM python.exe /FI "WINDOWTITLE eq media_server*"'
+
+ SetOutPath "$INSTDIR"
+
+ ; Copy entire distribution
+ File /r "dist\media-server\*.*"
+
+ ; Create uninstaller
+ WriteUninstaller "$INSTDIR\uninstall.exe"
+
+ ; Start Menu shortcuts
+ CreateDirectory "$SMPROGRAMS\${APPNAME}"
+ CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME}.lnk" \
+ "wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
+ "$INSTDIR\python\python.exe" 0
+ CreateShortcut "$SMPROGRAMS\${APPNAME}\${APPNAME} (Console).lnk" \
+ "$INSTDIR\${EXENAME}" "" \
+ "$INSTDIR\python\python.exe" 0
+ CreateShortcut "$SMPROGRAMS\${APPNAME}\Uninstall.lnk" \
+ "$INSTDIR\uninstall.exe"
+
+ ; Registry for Add/Remove Programs
+ WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
+ "DisplayName" "${APPNAME}"
+ WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
+ "DisplayVersion" "${VERSION}"
+ WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
+ "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
+ WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
+ "InstallLocation" "$INSTDIR"
+ WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
+ "Publisher" "Alexei Dolgolyov"
+ WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
+ "NoModify" 1
+ WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
+ "NoRepair" 1
+
+ ; Calculate installed size
+ ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
+ IntFmt $0 "0x%08X" $0
+ WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}" \
+ "EstimatedSize" "$0"
+SectionEnd
+
+Section "Desktop shortcut" SecDesktop
+ CreateShortcut "$DESKTOP\${APPNAME}.lnk" \
+ "wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
+ "$INSTDIR\python\python.exe" 0
+SectionEnd
+
+Section "Start with Windows" SecAutostart
+ ; Create Startup folder shortcut (runs hidden via VBS)
+ CreateShortcut "$SMSTARTUP\${APPNAME}.lnk" \
+ "wscript.exe" '"$INSTDIR\scripts\${VBSNAME}"' \
+ "$INSTDIR\python\python.exe" 0
+SectionEnd
+
+; --- Section descriptions ---
+!insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN
+ !insertmacro MUI_DESCRIPTION_TEXT ${SecCore} \
+ "Core application files, embedded Python, and Start Menu shortcuts."
+ !insertmacro MUI_DESCRIPTION_TEXT ${SecDesktop} \
+ "Create a desktop shortcut to launch ${APPNAME}."
+ !insertmacro MUI_DESCRIPTION_TEXT ${SecAutostart} \
+ "Automatically start ${APPNAME} when you log in to Windows."
+!insertmacro MUI_FUNCTION_DESCRIPTION_END
+
+; --- Uninstaller ---
+Section "Uninstall"
+ ; Stop running instance
+ nsExec::ExecToLog 'taskkill /F /IM python.exe /FI "WINDOWTITLE eq media_server*"'
+
+ ; Remove application files
+ RMDir /r "$INSTDIR\python"
+ RMDir /r "$INSTDIR\app"
+ RMDir /r "$INSTDIR\scripts"
+ Delete "$INSTDIR\${EXENAME}"
+ Delete "$INSTDIR\VERSION"
+ Delete "$INSTDIR\uninstall.exe"
+
+ ; Preserve config.yaml (user data) — only remove the example
+ Delete "$INSTDIR\config.example.yaml"
+
+ ; Remove shortcuts
+ Delete "$DESKTOP\${APPNAME}.lnk"
+ Delete "$SMSTARTUP\${APPNAME}.lnk"
+ RMDir /r "$SMPROGRAMS\${APPNAME}"
+
+ ; Remove registry
+ DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APPNAME}"
+
+ ; Remove install dir only if empty (config.yaml may remain)
+ RMDir "$INSTDIR"
+SectionEnd
diff --git a/media_server/config.py b/media_server/config.py
index e2ba9eb..00f1faf 100644
--- a/media_server/config.py
+++ b/media_server/config.py
@@ -76,7 +76,10 @@ class Settings(BaseSettings):
# Audio device settings
audio_device: Optional[str] = Field(
default=None,
- description="Audio device name to control (None = default device). Use /api/audio/devices to list available devices.",
+ description=(
+ "Audio device name to control (None = default device)."
+ " Use /api/audio/devices to list available devices."
+ ),
)
# Logging
diff --git a/media_server/main.py b/media_server/main.py
index 8c396f0..2a45643 100644
--- a/media_server/main.py
+++ b/media_server/main.py
@@ -15,8 +15,17 @@ from fastapi.staticfiles import StaticFiles
from . import __version__
from .auth import get_token_label, token_label_var
-from .config import settings, generate_default_config, get_config_dir
-from .routes import audio_router, browser_router, callbacks_router, display_router, health_router, links_router, media_router, scripts_router
+from .config import generate_default_config, get_config_dir, settings
+from .routes import (
+ audio_router,
+ browser_router,
+ callbacks_router,
+ display_router,
+ health_router,
+ links_router,
+ media_router,
+ scripts_router,
+)
from .services import get_media_controller
from .services.websocket_manager import ws_manager
@@ -206,12 +215,12 @@ def main():
if args.generate_config:
config_path = generate_default_config()
print(f"Configuration file generated at: {config_path}")
- print(f"API Token has been saved to the config file.")
+ print("API Token has been saved to the config file.")
return
if args.show_token:
print(f"Config directory: {get_config_dir()}")
- print(f"\nAPI Tokens:")
+ print("\nAPI Tokens:")
for label, token in settings.api_tokens.items():
print(f" {label:20} {token}")
return
diff --git a/media_server/models/__init__.py b/media_server/models/__init__.py
index abf7966..637c173 100644
--- a/media_server/models/__init__.py
+++ b/media_server/models/__init__.py
@@ -1,11 +1,11 @@
"""Pydantic models for the media server API."""
from .media import (
+ MediaInfo,
MediaState,
MediaStatus,
- VolumeRequest,
SeekRequest,
- MediaInfo,
+ VolumeRequest,
)
__all__ = [
diff --git a/media_server/routes/__init__.py b/media_server/routes/__init__.py
index 5305084..d42ef41 100644
--- a/media_server/routes/__init__.py
+++ b/media_server/routes/__init__.py
@@ -9,4 +9,13 @@ from .links import router as links_router
from .media import router as media_router
from .scripts import router as scripts_router
-__all__ = ["audio_router", "browser_router", "callbacks_router", "display_router", "health_router", "links_router", "media_router", "scripts_router"]
+__all__ = [
+ "audio_router",
+ "browser_router",
+ "callbacks_router",
+ "display_router",
+ "health_router",
+ "links_router",
+ "media_router",
+ "scripts_router",
+]
diff --git a/media_server/routes/browser.py b/media_server/routes/browser.py
index 78882c4..259fc93 100644
--- a/media_server/routes/browser.py
+++ b/media_server/routes/browser.py
@@ -4,20 +4,19 @@ import asyncio
import logging
import tempfile
from pathlib import Path
-from typing import Optional
from urllib.parse import unquote
from fastapi import APIRouter, Depends, HTTPException, Query, Response
-from fastapi.responses import FileResponse, StreamingResponse
+from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from ..auth import verify_token, verify_token_or_query
from ..config import MediaFolderConfig, settings
from ..config_manager import config_manager
+from ..services import get_media_controller
from ..services.browser_service import BrowserService
from ..services.metadata_service import MetadataService
from ..services.thumbnail_service import ThumbnailService
-from ..services import get_media_controller
from ..services.websocket_manager import ws_manager
logger = logging.getLogger(__name__)
@@ -281,7 +280,7 @@ async def browse(
logger.warning(f"Folder temporarily unavailable: {e}")
raise HTTPException(
status_code=503,
- detail=f"Folder is temporarily unavailable. It may be a network share that is not accessible at the moment."
+ detail="Folder is temporarily unavailable. It may be a network share that is not accessible at the moment."
)
except Exception as e:
logger.error(f"Error browsing directory (type: {type(e).__name__}): {e}")
diff --git a/media_server/routes/callbacks.py b/media_server/routes/callbacks.py
index 8fce8e6..677321d 100644
--- a/media_server/routes/callbacks.py
+++ b/media_server/routes/callbacks.py
@@ -2,7 +2,6 @@
import asyncio
import logging
-import re
import subprocess
import time
from concurrent.futures import ThreadPoolExecutor
@@ -238,7 +237,10 @@ async def create_callback(
if callback_name in settings.callbacks:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
- detail=f"Callback '{callback_name}' already exists. Use PUT /api/callbacks/update/{callback_name} to update it.",
+ detail=(
+ f"Callback '{callback_name}' already exists."
+ f" Use PUT /api/callbacks/update/{callback_name} to update it."
+ ),
)
# Create callback config
@@ -283,7 +285,10 @@ async def update_callback(
if callback_name not in settings.callbacks:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
- detail=f"Callback '{callback_name}' not found. Use POST /api/callbacks/create/{callback_name} to create it.",
+ detail=(
+ f"Callback '{callback_name}' not found."
+ f" Use POST /api/callbacks/create/{callback_name} to create it."
+ ),
)
# Create updated callback config
diff --git a/media_server/routes/display.py b/media_server/routes/display.py
index cc9b434..a27b1dd 100644
--- a/media_server/routes/display.py
+++ b/media_server/routes/display.py
@@ -7,7 +7,6 @@ from pydantic import BaseModel, Field
from ..auth import verify_token
from ..services.display_service import (
- get_brightness,
list_monitors,
set_brightness,
set_power,
diff --git a/media_server/routes/media.py b/media_server/routes/media.py
index 971199c..eee667f 100644
--- a/media_server/routes/media.py
+++ b/media_server/routes/media.py
@@ -3,14 +3,13 @@
import asyncio
import logging
-from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
-from fastapi import status
+from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect, status
from fastapi.responses import Response
from ..auth import verify_token, verify_token_or_query
from ..config import settings
-from ..models import MediaStatus, VolumeRequest, SeekRequest
-from ..services import get_media_controller, get_current_album_art
+from ..models import MediaStatus, SeekRequest, VolumeRequest
+from ..services import get_current_album_art, get_media_controller
from ..services.websocket_manager import ws_manager
logger = logging.getLogger(__name__)
diff --git a/media_server/service/install_windows.py b/media_server/service/install_windows.py
index f0edd87..3e52b53 100644
--- a/media_server/service/install_windows.py
+++ b/media_server/service/install_windows.py
@@ -13,15 +13,12 @@ Usage:
import os
import sys
-import socket
-import logging
try:
- import win32serviceutil
- import win32service
- import win32event
import servicemanager
- import win32api
+ import win32event
+ import win32service
+ import win32serviceutil
WIN32_AVAILABLE = True
except ImportError:
@@ -64,8 +61,9 @@ class MediaServerService:
def main(self):
"""Main service loop."""
import uvicorn
- from media_server.main import app
+
from media_server.config import settings
+ from media_server.main import app
config = uvicorn.Config(
app,
@@ -95,10 +93,9 @@ def install_service():
try:
# Get the path to the Python executable
- python_exe = sys.executable
# Get the path to this module
- module_path = os.path.abspath(__file__)
+ os.path.abspath(__file__)
win32serviceutil.InstallService(
MediaServerService._svc_name_,
diff --git a/media_server/services/__init__.py b/media_server/services/__init__.py
index d8575d8..39de409 100644
--- a/media_server/services/__init__.py
+++ b/media_server/services/__init__.py
@@ -40,8 +40,8 @@ def get_media_controller() -> "MediaController":
system = platform.system()
if system == "Windows":
- from .windows_media import WindowsMediaController
from ..config import settings
+ from .windows_media import WindowsMediaController
_controller_instance = WindowsMediaController(audio_device=settings.audio_device)
elif system == "Linux":
diff --git a/media_server/services/android_media.py b/media_server/services/android_media.py
index df4c991..109a8df 100644
--- a/media_server/services/android_media.py
+++ b/media_server/services/android_media.py
@@ -10,11 +10,10 @@ Installation:
4. Grant necessary permissions to Termux:API
"""
-import asyncio
import json
import logging
import subprocess
-from typing import Optional, Any
+from typing import Any, Optional
from ..models import MediaState, MediaStatus
from .media_controller import MediaController
diff --git a/media_server/services/browser_service.py b/media_server/services/browser_service.py
index 72d751a..0be8f43 100644
--- a/media_server/services/browser_service.py
+++ b/media_server/services/browser_service.py
@@ -1,12 +1,10 @@
"""Browser service for media file browsing and path validation."""
import logging
-import os
import stat as stat_module
import time
from datetime import datetime
from pathlib import Path
-from typing import Optional
from ..config import settings
diff --git a/media_server/services/display_service.py b/media_server/services/display_service.py
index dbc9bbf..3500fe0 100644
--- a/media_server/services/display_service.py
+++ b/media_server/services/display_service.py
@@ -6,7 +6,7 @@ import logging
import platform
import struct
import time
-from dataclasses import dataclass, field
+from dataclasses import dataclass
logger = logging.getLogger(__name__)
diff --git a/media_server/services/linux_media.py b/media_server/services/linux_media.py
index a25eeec..c6628e8 100644
--- a/media_server/services/linux_media.py
+++ b/media_server/services/linux_media.py
@@ -3,7 +3,7 @@
import asyncio
import logging
import subprocess
-from typing import Optional, Any
+from typing import Any, Optional
from ..models import MediaState, MediaStatus
from .media_controller import MediaController
diff --git a/media_server/services/macos_media.py b/media_server/services/macos_media.py
index 3107851..2afdf26 100644
--- a/media_server/services/macos_media.py
+++ b/media_server/services/macos_media.py
@@ -3,7 +3,6 @@
import asyncio
import logging
import subprocess
-import json
from typing import Optional
from ..models import MediaState, MediaStatus
@@ -203,11 +202,6 @@ class MacOSMediaController(MediaController):
async def play(self) -> bool:
"""Resume playback using media key simulation."""
# Use system media key
- script = '''
- tell application "System Events"
- key code 16 using {command down, option down}
- end tell
- '''
# Fallback: try specific app
active_app = self._get_active_app()
if active_app == "Spotify":
diff --git a/media_server/services/metadata_service.py b/media_server/services/metadata_service.py
index c14a13a..ef17ff4 100644
--- a/media_server/services/metadata_service.py
+++ b/media_server/services/metadata_service.py
@@ -2,7 +2,6 @@
import logging
from pathlib import Path
-from typing import Optional
logger = logging.getLogger(__name__)
@@ -21,7 +20,6 @@ class MetadataService:
Dictionary with audio metadata.
"""
try:
- import mutagen
from mutagen import File as MutagenFile
audio = MutagenFile(str(file_path), easy=True)
@@ -68,7 +66,9 @@ class MetadataService:
metadata["album"] = tags["album"][0] if isinstance(tags["album"], list) else tags["album"]
if "albumartist" in tags:
- metadata["album_artist"] = tags["albumartist"][0] if isinstance(tags["albumartist"], list) else tags["albumartist"]
+ metadata["album_artist"] = (
+ tags["albumartist"][0] if isinstance(tags["albumartist"], list) else tags["albumartist"]
+ )
if "date" in tags:
metadata["date"] = tags["date"][0] if isinstance(tags["date"], list) else tags["date"]
@@ -77,7 +77,9 @@ class MetadataService:
metadata["genre"] = tags["genre"][0] if isinstance(tags["genre"], list) else tags["genre"]
if "tracknumber" in tags:
- metadata["track_number"] = tags["tracknumber"][0] if isinstance(tags["tracknumber"], list) else tags["tracknumber"]
+ metadata["track_number"] = (
+ tags["tracknumber"][0] if isinstance(tags["tracknumber"], list) else tags["tracknumber"]
+ )
# If no title tag, use filename
if "title" not in metadata:
@@ -110,7 +112,6 @@ class MetadataService:
Dictionary with video metadata.
"""
try:
- import mutagen
from mutagen import File as MutagenFile
video = MutagenFile(str(file_path))
diff --git a/media_server/services/thumbnail_service.py b/media_server/services/thumbnail_service.py
index cb6c3f2..36742f2 100644
--- a/media_server/services/thumbnail_service.py
+++ b/media_server/services/thumbnail_service.py
@@ -3,9 +3,7 @@
import asyncio
import hashlib
import logging
-import os
import shutil
-import subprocess
from pathlib import Path
from typing import Optional
@@ -151,10 +149,10 @@ class ThumbnailService:
Thumbnail bytes (JPEG) or None if no album art.
"""
try:
- import mutagen
+ from io import BytesIO
+
from mutagen import File as MutagenFile
from PIL import Image
- from io import BytesIO
audio = MutagenFile(str(file_path))
if audio is None:
@@ -232,9 +230,10 @@ class ThumbnailService:
Thumbnail bytes (JPEG) or None if ffmpeg not available.
"""
try:
- from PIL import Image
from io import BytesIO
+ from PIL import Image
+
# Check if ffmpeg is available
if not shutil.which("ffmpeg"):
logger.debug("ffmpeg not available, cannot generate video thumbnail")
@@ -247,7 +246,11 @@ class ThumbnailService:
cmd = [
"ffmpeg",
"-i", str(file_path),
- "-vf", f"thumbnail,scale={target_size[0]}:{target_size[1]}:force_original_aspect_ratio=increase,crop={target_size[0]}:{target_size[1]}",
+ "-vf", (
+ f"thumbnail,scale={target_size[0]}:{target_size[1]}"
+ f":force_original_aspect_ratio=increase"
+ f",crop={target_size[0]}:{target_size[1]}"
+ ),
"-frames:v", "1",
"-f", "image2pipe",
"-vcodec", "mjpeg",
diff --git a/media_server/services/windows_media.py b/media_server/services/windows_media.py
index b1cd063..2441ffe 100644
--- a/media_server/services/windows_media.py
+++ b/media_server/services/windows_media.py
@@ -5,7 +5,7 @@ import logging
import threading
import time as _time
from concurrent.futures import ThreadPoolExecutor
-from typing import Optional, Any
+from typing import Any
from ..models import MediaState, MediaStatus
from .media_controller import MediaController
@@ -47,6 +47,8 @@ def get_current_album_art() -> bytes | None:
try:
from winsdk.windows.media.control import (
GlobalSystemMediaTransportControlsSessionManager as MediaManager,
+ )
+ from winsdk.windows.media.control import (
GlobalSystemMediaTransportControlsSessionPlaybackStatus as PlaybackStatus,
)
@@ -61,11 +63,11 @@ _volume_control = None
_configured_device_name: str | None = None
try:
- from ctypes import cast, POINTER
- from comtypes import CLSCTX_ALL, CoInitialize, CoUninitialize
- from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
-
import warnings
+ from ctypes import POINTER, cast
+
+ from comtypes import CLSCTX_ALL
+ from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
# Suppress pycaw warnings about missing device properties
warnings.filterwarnings("ignore", category=UserWarning, module="pycaw")
@@ -240,13 +242,18 @@ def _sync_get_media_status() -> dict[str, Any]:
_track_skip_pending["stale_pos"] = -999 # Reset stale position tracking
skip_just_completed = True
# Reset position cache for new track
- new_track_id = f"{current_title}:{result.get('artist', '')}:{result.get('duration', 0)}"
+ new_track_id = (
+ f"{current_title}:{result.get('artist', '')}:{result.get('duration', 0)}"
+ )
_position_cache["track_id"] = new_track_id
_position_cache["base_position"] = 0.0
_position_cache["base_time"] = current_time
_position_cache["last_smtc_pos"] = -999 # Force fresh start
_position_cache["is_playing"] = is_playing
- logger.debug(f"Track skip complete, new title: {current_title}, grace until: {_track_skip_pending['grace_until']}")
+ logger.debug(
+ f"Track skip complete, new title: {current_title},"
+ f" grace until: {_track_skip_pending['grace_until']}"
+ )
elif current_time - _track_skip_pending["skip_time"] > 5.0:
# Timeout after 5 seconds
_track_skip_pending["active"] = False
@@ -298,7 +305,10 @@ def _sync_get_media_status() -> dict[str, Any]:
pos = smtc_pos
_track_skip_pending["grace_until"] = 0
_track_skip_pending["stale_pos"] = -999
- logger.debug(f"Grace period: accepting SMTC pos {smtc_pos} (low={smtc_pos < 10}, changed={smtc_changed})")
+ logger.debug(
+ f"Grace period: accepting SMTC pos {smtc_pos}"
+ f" (low={smtc_pos < 10}, changed={smtc_changed})"
+ )
else:
# SMTC is stale - keep interpolating
pos = interpolated_pos
@@ -307,7 +317,10 @@ def _sync_get_media_status() -> dict[str, Any]:
_track_skip_pending["stale_pos"] = smtc_pos
# Keep grace period active indefinitely while SMTC is stale
_track_skip_pending["grace_until"] = current_time + 300.0
- logger.debug(f"Grace period: SMTC stale ({smtc_pos}), using interpolated {interpolated_pos}")
+ logger.debug(
+ f"Grace period: SMTC stale ({smtc_pos}),"
+ f" using interpolated {interpolated_pos}"
+ )
else:
# Normal position tracking
# Create track ID from title + artist + duration
@@ -335,7 +348,9 @@ def _sync_get_media_status() -> dict[str, Any]:
# Update playing state
if _position_cache.get("is_playing") != is_playing:
- _position_cache["base_position"] = pos if is_playing else _position_cache.get("base_position", smtc_pos)
+ _position_cache["base_position"] = (
+ pos if is_playing else _position_cache.get("base_position", smtc_pos)
+ )
_position_cache["base_time"] = current_time
_position_cache["is_playing"] = is_playing
diff --git a/media_server/static/index.html b/media_server/static/index.html
index d99734a..3d7d13f 100644
--- a/media_server/static/index.html
+++ b/media_server/static/index.html
@@ -651,14 +651,6 @@
-
-
-
-
-
-
-
-
-
+