Compare commits

...

1 Commits

Author SHA1 Message Date
9404b37f05 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>
2026-02-28 12:10:24 +03:00
9 changed files with 260 additions and 195 deletions

View File

@@ -87,6 +87,13 @@ async def lifespan(app: FastAPI):
# Stop WebSocket status monitor # Stop WebSocket status monitor
await ws_manager.stop_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") logger.info("Media Server shutting down")
@@ -103,10 +110,11 @@ def create_app() -> FastAPI:
app.add_middleware(GZipMiddleware, minimum_size=1000) app.add_middleware(GZipMiddleware, minimum_size=1000)
# Add CORS middleware for cross-origin requests # Add CORS middleware for cross-origin requests
# Token auth is via Authorization header, not cookies, so credentials are not needed
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],
allow_credentials=True, allow_credentials=False,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )

View File

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

View File

@@ -5,6 +5,7 @@ import logging
import re import re
import subprocess import subprocess
import time import time
from concurrent.futures import ThreadPoolExecutor
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
@@ -16,6 +17,9 @@ from ..config_manager import config_manager
from ..services.websocket_manager import ws_manager from ..services.websocket_manager import ws_manager
router = APIRouter(prefix="/api/scripts", tags=["scripts"]) 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__) logger = logging.getLogger(__name__)
@@ -101,10 +105,10 @@ async def execute_script(
# Append arguments to command # Append arguments to command
command = f"{command} {' '.join(args)}" 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() loop = asyncio.get_event_loop()
result = await loop.run_in_executor( result = await loop.run_in_executor(
None, _script_executor,
lambda: _run_script( lambda: _run_script(
command=command, command=command,
timeout=script_config.timeout, timeout=script_config.timeout,

View File

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

View File

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

View File

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

View File

@@ -824,6 +824,20 @@ button:disabled {
transform: scale(1.05); 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 { .controls button.primary {
width: 56px; width: 56px;
height: 56px; height: 56px;

View File

@@ -74,12 +74,12 @@
<div class="header-toolbar"> <div class="header-toolbar">
<div id="headerLinks" class="header-links"></div> <div id="headerLinks" class="header-links"></div>
<div class="accent-picker"> <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> <span class="accent-dot" id="accentDot"></span>
</button> </button>
<div class="accent-picker-dropdown" id="accentDropdown"></div> <div class="accent-picker-dropdown" id="accentDropdown"></div>
</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;"> <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"/> <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> </svg>
@@ -92,7 +92,7 @@
<option value="ru">RU</option> <option value="ru">RU</option>
</select> </select>
<span class="header-toolbar-sep"></span> <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> <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> </button>
</div> </div>

View File

@@ -1368,6 +1368,13 @@
currentState = status.state; currentState = status.state;
updatePlaybackState(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) // Update album art (skip if same track to avoid redundant network requests)
const artworkSource = status.album_art_url || null; const artworkSource = status.album_art_url || null;
const artworkKey = `${status.title || ''}|${status.artist || ''}|${artworkSource || ''}`; const artworkKey = `${status.title || ''}|${status.artist || ''}|${artworkSource || ''}`;
@@ -3468,7 +3475,16 @@ async function toggleDisplayPower(monitorId, monitorName) {
// Header Quick Links // 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) { async function fetchMdiIcon(iconName) {
// Parse "mdi:icon-name" → "icon-name" // Parse "mdi:icon-name" → "icon-name"
@@ -3480,6 +3496,7 @@ async function fetchMdiIcon(iconName) {
if (response.ok) { if (response.ok) {
const svg = await response.text(); const svg = await response.text();
mdiIconCache[name] = svg; mdiIconCache[name] = svg;
_persistMdiCache();
return svg; return svg;
} }
} catch (e) { } catch (e) {