Codebase audit fixes: stability, performance, accessibility

- Fix CORS: set allow_credentials=False (token auth, not cookies)
- Add threading.Lock for position cache thread safety
- Add shutdown_executor() for clean ThreadPoolExecutor cleanup
- Dedicated ThreadPoolExecutors for script/callback execution
- Fix Mutagen file handle leaks with try/finally close
- Reduce idle WebSocket polling (0.5s → 2.0s when no clients)
- Add :focus-visible styles for playback control buttons
- Add aria-label to icon-only header buttons
- Dynamic album art alt text for screen readers
- Persist MDI icon cache to localStorage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 12:10:24 +03:00
parent 73a6f387e1
commit 9404b37f05
9 changed files with 260 additions and 195 deletions

View File

@@ -87,6 +87,13 @@ async def lifespan(app: FastAPI):
# Stop WebSocket status monitor
await ws_manager.stop_status_monitor()
# Clean up platform-specific resources
import platform as _platform
if _platform.system() == "Windows":
from .services.windows_media import shutdown_executor
shutdown_executor()
logger.info("Media Server shutting down")
@@ -103,10 +110,11 @@ def create_app() -> FastAPI:
app.add_middleware(GZipMiddleware, minimum_size=1000)
# Add CORS middleware for cross-origin requests
# Token auth is via Authorization header, not cookies, so credentials are not needed
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)

View File

@@ -5,6 +5,7 @@ import logging
import re
import subprocess
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
@@ -17,6 +18,9 @@ from ..config_manager import config_manager
router = APIRouter(prefix="/api/callbacks", tags=["callbacks"])
logger = logging.getLogger(__name__)
# Dedicated executor for callback/subprocess execution
_callback_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="callback")
class CallbackInfo(BaseModel):
"""Information about a configured callback."""
@@ -127,10 +131,10 @@ async def execute_callback(
logger.info(f"Executing callback for debugging: {callback_name}")
try:
# Execute in thread pool to not block
# Execute in dedicated thread pool to not block the default executor
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
_callback_executor,
lambda: _run_callback(
command=callback_config.command,
timeout=callback_config.timeout,

View File

@@ -5,6 +5,7 @@ import logging
import re
import subprocess
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
@@ -16,6 +17,9 @@ from ..config_manager import config_manager
from ..services.websocket_manager import ws_manager
router = APIRouter(prefix="/api/scripts", tags=["scripts"])
# Dedicated executor for script/subprocess execution (avoids blocking the default pool)
_script_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="script")
logger = logging.getLogger(__name__)
@@ -101,10 +105,10 @@ async def execute_script(
# Append arguments to command
command = f"{command} {' '.join(args)}"
# Execute in thread pool to not block
# Execute in dedicated thread pool to not block the default executor
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
_script_executor,
lambda: _run_script(
command=command,
timeout=script_config.timeout,

View File

@@ -28,6 +28,7 @@ class MetadataService:
if audio is None:
return {"error": "Unable to read audio file"}
try:
metadata = {
"type": "audio",
"filename": file_path.name,
@@ -83,6 +84,9 @@ class MetadataService:
metadata["title"] = file_path.stem
return metadata
finally:
if hasattr(audio, 'close'):
audio.close()
except ImportError:
logger.error("mutagen library not installed, cannot extract metadata")
@@ -117,6 +121,7 @@ class MetadataService:
"title": file_path.stem,
}
try:
metadata = {
"type": "video",
"filename": file_path.name,
@@ -151,6 +156,9 @@ class MetadataService:
metadata["title"] = file_path.stem
return metadata
finally:
if hasattr(video, 'close'):
video.close()
except ImportError:
logger.error("mutagen library not installed, cannot extract metadata")

View File

@@ -251,7 +251,7 @@ class ConnectionManager:
has_clients = len(self._active_connections) > 0
if not has_clients:
await asyncio.sleep(self._poll_interval)
await asyncio.sleep(2.0) # Sleep longer when no clients connected
continue
status = await get_status_func()

View File

@@ -2,6 +2,8 @@
import asyncio
import logging
import threading
import time as _time
from concurrent.futures import ThreadPoolExecutor
from typing import Optional, Any
@@ -16,8 +18,10 @@ _executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="winrt")
# Global storage for current album art (as bytes)
_current_album_art_bytes: bytes | None = None
# Lock protecting _position_cache and _track_skip_pending from concurrent access
_position_lock = threading.Lock()
# Global storage for position tracking
import time as _time
_position_cache = {
"track_id": "",
"base_position": 0.0,
@@ -224,6 +228,7 @@ def _sync_get_media_status() -> dict[str, Any]:
is_playing = result["state"] == "playing"
current_title = result.get('title', '')
with _position_lock:
# Check if track skip is pending and title changed
skip_just_completed = False
if _track_skip_pending["active"]:
@@ -483,6 +488,11 @@ def _sync_seek(position: float) -> bool:
return False
def shutdown_executor() -> None:
"""Shut down the WinRT thread pool executor."""
_executor.shutdown(wait=False)
class WindowsMediaController(MediaController):
"""Media controller for Windows using WinRT and pycaw."""
@@ -602,7 +612,7 @@ class WindowsMediaController(MediaController):
result = await self._run_command("next")
if result:
# Set flag to force position to 0 until title changes
with _position_lock:
_track_skip_pending["active"] = True
_track_skip_pending["old_title"] = old_title
_track_skip_pending["skip_time"] = _time.time()
@@ -620,7 +630,7 @@ class WindowsMediaController(MediaController):
result = await self._run_command("previous")
if result:
# Set flag to force position to 0 until title changes
with _position_lock:
_track_skip_pending["active"] = True
_track_skip_pending["old_title"] = old_title
_track_skip_pending["skip_time"] = _time.time()

View File

@@ -824,6 +824,20 @@ button:disabled {
transform: scale(1.05);
}
.controls button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
box-shadow: 0 0 0 4px rgba(29, 185, 84, 0.25);
}
.mute-btn:focus-visible,
.mini-control-btn:focus-visible,
.vinyl-toggle-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
box-shadow: 0 0 0 4px rgba(29, 185, 84, 0.25);
}
.controls button.primary {
width: 56px;
height: 56px;

View File

@@ -74,12 +74,12 @@
<div class="header-toolbar">
<div id="headerLinks" class="header-links"></div>
<div class="accent-picker">
<button class="header-btn" onclick="toggleAccentPicker()" title="Accent color">
<button class="header-btn" onclick="toggleAccentPicker()" title="Accent color" aria-label="Accent color">
<span class="accent-dot" id="accentDot"></span>
</button>
<div class="accent-picker-dropdown" id="accentDropdown"></div>
</div>
<button class="header-btn" onclick="toggleTheme()" data-i18n-title="player.theme" title="Toggle theme" id="theme-toggle">
<button class="header-btn" onclick="toggleTheme()" data-i18n-title="player.theme" title="Toggle theme" aria-label="Toggle theme" id="theme-toggle">
<svg id="theme-icon-sun" viewBox="0 0 24 24" style="display: none;">
<path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z"/>
</svg>
@@ -92,7 +92,7 @@
<option value="ru">RU</option>
</select>
<span class="header-toolbar-sep"></span>
<button class="header-btn header-btn-logout" onclick="clearToken()" data-i18n-title="auth.logout.title" title="Clear saved token">
<button class="header-btn header-btn-logout" onclick="clearToken()" data-i18n-title="auth.logout.title" title="Clear saved token" aria-label="Logout">
<svg viewBox="0 0 24 24"><path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/></svg>
</button>
</div>

View File

@@ -1368,6 +1368,13 @@
currentState = status.state;
updatePlaybackState(status.state);
// Update album art alt text for accessibility
const altText = status.title && status.artist
? `${status.artist} ${status.title}`
: status.title || t('player.no_media');
dom.albumArt.alt = altText;
dom.miniAlbumArt.alt = altText;
// Update album art (skip if same track to avoid redundant network requests)
const artworkSource = status.album_art_url || null;
const artworkKey = `${status.title || ''}|${status.artist || ''}|${artworkSource || ''}`;
@@ -3468,7 +3475,16 @@ async function toggleDisplayPower(monitorId, monitorName) {
// Header Quick Links
// ============================================================
const mdiIconCache = {};
// In-memory + localStorage cache for MDI icons (persists across reloads)
const mdiIconCache = (() => {
try {
return JSON.parse(localStorage.getItem('mdiIconCache') || '{}');
} catch { return {}; }
})();
function _persistMdiCache() {
try { localStorage.setItem('mdiIconCache', JSON.stringify(mdiIconCache)); } catch {}
}
async function fetchMdiIcon(iconName) {
// Parse "mdi:icon-name" → "icon-name"
@@ -3480,6 +3496,7 @@ async function fetchMdiIcon(iconName) {
if (response.ok) {
const svg = await response.text();
mdiIconCache[name] = svg;
_persistMdiCache();
return svg;
}
} catch (e) {