From 9404b37f0505200be328f0abcd107859dc2f81cf Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 28 Feb 2026 12:10:24 +0300 Subject: [PATCH] Codebase audit fixes: stability, performance, accessibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- media_server/main.py | 10 +- media_server/routes/callbacks.py | 8 +- media_server/routes/scripts.py | 8 +- media_server/services/metadata_service.py | 144 ++++++------ media_server/services/websocket_manager.py | 2 +- media_server/services/windows_media.py | 244 +++++++++++---------- media_server/static/css/styles.css | 14 ++ media_server/static/index.html | 6 +- media_server/static/js/app.js | 19 +- 9 files changed, 260 insertions(+), 195 deletions(-) diff --git a/media_server/main.py b/media_server/main.py index 4e34edd..0c7b387 100644 --- a/media_server/main.py +++ b/media_server/main.py @@ -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=["*"], ) diff --git a/media_server/routes/callbacks.py b/media_server/routes/callbacks.py index 3855e82..8fce8e6 100644 --- a/media_server/routes/callbacks.py +++ b/media_server/routes/callbacks.py @@ -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, diff --git a/media_server/routes/scripts.py b/media_server/routes/scripts.py index 631fd38..98ed407 100644 --- a/media_server/routes/scripts.py +++ b/media_server/routes/scripts.py @@ -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, diff --git a/media_server/services/metadata_service.py b/media_server/services/metadata_service.py index 3f4b923..c14a13a 100644 --- a/media_server/services/metadata_service.py +++ b/media_server/services/metadata_service.py @@ -28,61 +28,65 @@ class MetadataService: if audio is None: return {"error": "Unable to read audio file"} - metadata = { - "type": "audio", - "filename": file_path.name, - "path": str(file_path), - } + try: + metadata = { + "type": "audio", + "filename": file_path.name, + "path": str(file_path), + } - # Extract duration - if hasattr(audio.info, "length"): - metadata["duration"] = round(audio.info.length, 2) + # Extract duration + if hasattr(audio.info, "length"): + metadata["duration"] = round(audio.info.length, 2) - # Extract bitrate - if hasattr(audio.info, "bitrate"): - metadata["bitrate"] = audio.info.bitrate + # Extract bitrate + if hasattr(audio.info, "bitrate"): + metadata["bitrate"] = audio.info.bitrate - # Extract sample rate - if hasattr(audio.info, "sample_rate"): - metadata["sample_rate"] = audio.info.sample_rate - elif hasattr(audio.info, "samplerate"): - metadata["sample_rate"] = audio.info.samplerate + # Extract sample rate + if hasattr(audio.info, "sample_rate"): + metadata["sample_rate"] = audio.info.sample_rate + elif hasattr(audio.info, "samplerate"): + metadata["sample_rate"] = audio.info.samplerate - # Extract channels - if hasattr(audio.info, "channels"): - metadata["channels"] = audio.info.channels + # Extract channels + if hasattr(audio.info, "channels"): + metadata["channels"] = audio.info.channels - # Extract tags (use easy=True for consistent tag names) - if audio is not None and hasattr(audio, "tags") and audio.tags: - # Easy tags provide lists, so we take the first item - tags = audio.tags + # Extract tags (use easy=True for consistent tag names) + if audio is not None and hasattr(audio, "tags") and audio.tags: + # Easy tags provide lists, so we take the first item + tags = audio.tags - if "title" in tags: - metadata["title"] = tags["title"][0] if isinstance(tags["title"], list) else tags["title"] + if "title" in tags: + metadata["title"] = tags["title"][0] if isinstance(tags["title"], list) else tags["title"] - if "artist" in tags: - metadata["artist"] = tags["artist"][0] if isinstance(tags["artist"], list) else tags["artist"] + if "artist" in tags: + metadata["artist"] = tags["artist"][0] if isinstance(tags["artist"], list) else tags["artist"] - if "album" in tags: - metadata["album"] = tags["album"][0] if isinstance(tags["album"], list) else tags["album"] + if "album" in tags: + 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"] + if "albumartist" in tags: + 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"] + if "date" in tags: + metadata["date"] = tags["date"][0] if isinstance(tags["date"], list) else tags["date"] - if "genre" in tags: - metadata["genre"] = tags["genre"][0] if isinstance(tags["genre"], list) else tags["genre"] + if "genre" in tags: + 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"] + if "tracknumber" in tags: + 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: - metadata["title"] = file_path.stem + # If no title tag, use filename + if "title" not in metadata: + metadata["title"] = file_path.stem - return metadata + return metadata + finally: + if hasattr(audio, 'close'): + audio.close() except ImportError: logger.error("mutagen library not installed, cannot extract metadata") @@ -117,40 +121,44 @@ class MetadataService: "title": file_path.stem, } - metadata = { - "type": "video", - "filename": file_path.name, - "path": str(file_path), - } + try: + metadata = { + "type": "video", + "filename": file_path.name, + "path": str(file_path), + } - # Extract duration - if hasattr(video.info, "length"): - metadata["duration"] = round(video.info.length, 2) + # Extract duration + if hasattr(video.info, "length"): + metadata["duration"] = round(video.info.length, 2) - # Extract bitrate - if hasattr(video.info, "bitrate"): - metadata["bitrate"] = video.info.bitrate + # Extract bitrate + if hasattr(video.info, "bitrate"): + metadata["bitrate"] = video.info.bitrate - # Extract video-specific properties if available - if hasattr(video.info, "width"): - metadata["width"] = video.info.width + # Extract video-specific properties if available + if hasattr(video.info, "width"): + metadata["width"] = video.info.width - if hasattr(video.info, "height"): - metadata["height"] = video.info.height + if hasattr(video.info, "height"): + metadata["height"] = video.info.height - # Try to extract title from tags - if hasattr(video, "tags") and video.tags: - tags = video.tags - if hasattr(tags, "get"): - title = tags.get("title") or tags.get("TITLE") or tags.get("\xa9nam") - if title: - metadata["title"] = title[0] if isinstance(title, list) else str(title) + # Try to extract title from tags + if hasattr(video, "tags") and video.tags: + tags = video.tags + if hasattr(tags, "get"): + title = tags.get("title") or tags.get("TITLE") or tags.get("\xa9nam") + if title: + metadata["title"] = title[0] if isinstance(title, list) else str(title) - # If no title tag, use filename - if "title" not in metadata: - metadata["title"] = file_path.stem + # If no title tag, use filename + if "title" not in metadata: + metadata["title"] = file_path.stem - return metadata + return metadata + finally: + if hasattr(video, 'close'): + video.close() except ImportError: logger.error("mutagen library not installed, cannot extract metadata") diff --git a/media_server/services/websocket_manager.py b/media_server/services/websocket_manager.py index a5338a3..a23a3dd 100644 --- a/media_server/services/websocket_manager.py +++ b/media_server/services/websocket_manager.py @@ -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() diff --git a/media_server/services/windows_media.py b/media_server/services/windows_media.py index 00d2d63..b1cd063 100644 --- a/media_server/services/windows_media.py +++ b/media_server/services/windows_media.py @@ -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,124 +228,125 @@ def _sync_get_media_status() -> dict[str, Any]: is_playing = result["state"] == "playing" current_title = result.get('title', '') - # Check if track skip is pending and title changed - skip_just_completed = False - if _track_skip_pending["active"]: - if current_title and current_title != _track_skip_pending["old_title"]: - # Title changed - clear the skip flag and start grace period - _track_skip_pending["active"] = False - _track_skip_pending["old_title"] = "" - _track_skip_pending["grace_until"] = current_time + 300.0 # Long grace period - _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)}" - _position_cache["track_id"] = new_track_id + with _position_lock: + # Check if track skip is pending and title changed + skip_just_completed = False + if _track_skip_pending["active"]: + if current_title and current_title != _track_skip_pending["old_title"]: + # Title changed - clear the skip flag and start grace period + _track_skip_pending["active"] = False + _track_skip_pending["old_title"] = "" + _track_skip_pending["grace_until"] = current_time + 300.0 # Long grace period + _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)}" + _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']}") + elif current_time - _track_skip_pending["skip_time"] > 5.0: + # Timeout after 5 seconds + _track_skip_pending["active"] = False + logger.debug("Track skip timeout") + + # Check if we're in grace period (after skip, ignore high SMTC positions) + in_grace_period = current_time < _track_skip_pending.get("grace_until", 0) + + # If track skip is pending or just completed, use cached/reset position + if _track_skip_pending["active"]: + pos = 0.0 _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']}") - elif current_time - _track_skip_pending["skip_time"] > 5.0: - # Timeout after 5 seconds - _track_skip_pending["active"] = False - logger.debug("Track skip timeout") + elif skip_just_completed: + # Just completed skip - interpolate from 0 + if is_playing: + elapsed = current_time - _position_cache["base_time"] + pos = elapsed + else: + pos = 0.0 + elif in_grace_period: + # Grace period after track skip + # SMTC position is stale (from old track) and won't update until seek/pause + # We interpolate from 0 and only trust SMTC when it changes or reports low value - # Check if we're in grace period (after skip, ignore high SMTC positions) - in_grace_period = current_time < _track_skip_pending.get("grace_until", 0) + # Calculate interpolated position from start of new track + if is_playing: + elapsed = current_time - _position_cache.get("base_time", current_time) + interpolated_pos = _position_cache.get("base_position", 0.0) + elapsed + else: + interpolated_pos = _position_cache.get("base_position", 0.0) - # If track skip is pending or just completed, use cached/reset position - if _track_skip_pending["active"]: - pos = 0.0 - _position_cache["base_position"] = 0.0 - _position_cache["base_time"] = current_time - _position_cache["is_playing"] = is_playing - elif skip_just_completed: - # Just completed skip - interpolate from 0 - if is_playing: - elapsed = current_time - _position_cache["base_time"] - pos = elapsed + # Get the stale position we've been tracking + stale_pos = _track_skip_pending.get("stale_pos", -999) + + # Detect if SMTC position changed significantly from the stale value (user seeked) + smtc_changed = stale_pos >= 0 and abs(smtc_pos - stale_pos) > 3.0 + + # Trust SMTC if: + # 1. It reports a low position (indicating new track started) + # 2. It changed from the stale value (user seeked) + if smtc_pos < 10.0 or smtc_changed: + # SMTC is now trustworthy + _position_cache["base_position"] = smtc_pos + _position_cache["base_time"] = current_time + _position_cache["last_smtc_pos"] = smtc_pos + _position_cache["is_playing"] = is_playing + 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})") + else: + # SMTC is stale - keep interpolating + pos = interpolated_pos + # Record the stale position for change detection + if stale_pos < 0: + _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}") else: - pos = 0.0 - elif in_grace_period: - # Grace period after track skip - # SMTC position is stale (from old track) and won't update until seek/pause - # We interpolate from 0 and only trust SMTC when it changes or reports low value + # Normal position tracking + # Create track ID from title + artist + duration + track_id = f"{current_title}:{result.get('artist', '')}:{result.get('duration', 0)}" - # Calculate interpolated position from start of new track - if is_playing: - elapsed = current_time - _position_cache.get("base_time", current_time) - interpolated_pos = _position_cache.get("base_position", 0.0) + elapsed - else: - interpolated_pos = _position_cache.get("base_position", 0.0) + # Detect if SMTC position changed (new track, seek, or state change) + smtc_pos_changed = abs(smtc_pos - _position_cache.get("last_smtc_pos", -999)) > 0.5 + track_changed = track_id != _position_cache.get("track_id", "") - # Get the stale position we've been tracking - stale_pos = _track_skip_pending.get("stale_pos", -999) + if smtc_pos_changed or track_changed: + # SMTC updated - store new baseline + _position_cache["track_id"] = track_id + _position_cache["last_smtc_pos"] = smtc_pos + _position_cache["base_position"] = smtc_pos + _position_cache["base_time"] = current_time + _position_cache["is_playing"] = is_playing + pos = smtc_pos + elif is_playing: + # Interpolate position based on elapsed time + elapsed = current_time - _position_cache.get("base_time", current_time) + pos = _position_cache.get("base_position", smtc_pos) + elapsed + else: + # Paused - use base position + pos = _position_cache.get("base_position", smtc_pos) - # Detect if SMTC position changed significantly from the stale value (user seeked) - smtc_changed = stale_pos >= 0 and abs(smtc_pos - stale_pos) > 3.0 + # 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_time"] = current_time + _position_cache["is_playing"] = is_playing - # Trust SMTC if: - # 1. It reports a low position (indicating new track started) - # 2. It changed from the stale value (user seeked) - if smtc_pos < 10.0 or smtc_changed: - # SMTC is now trustworthy - _position_cache["base_position"] = smtc_pos - _position_cache["base_time"] = current_time - _position_cache["last_smtc_pos"] = smtc_pos - _position_cache["is_playing"] = is_playing - 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})") - else: - # SMTC is stale - keep interpolating - pos = interpolated_pos - # Record the stale position for change detection - if stale_pos < 0: - _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}") - else: - # Normal position tracking - # Create track ID from title + artist + duration - track_id = f"{current_title}:{result.get('artist', '')}:{result.get('duration', 0)}" - - # Detect if SMTC position changed (new track, seek, or state change) - smtc_pos_changed = abs(smtc_pos - _position_cache.get("last_smtc_pos", -999)) > 0.5 - track_changed = track_id != _position_cache.get("track_id", "") - - if smtc_pos_changed or track_changed: - # SMTC updated - store new baseline - _position_cache["track_id"] = track_id - _position_cache["last_smtc_pos"] = smtc_pos - _position_cache["base_position"] = smtc_pos - _position_cache["base_time"] = current_time - _position_cache["is_playing"] = is_playing - pos = smtc_pos - elif is_playing: - # Interpolate position based on elapsed time - elapsed = current_time - _position_cache.get("base_time", current_time) - pos = _position_cache.get("base_position", smtc_pos) + elapsed - else: - # Paused - use base position - pos = _position_cache.get("base_position", smtc_pos) - - # 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_time"] = current_time - _position_cache["is_playing"] = is_playing - - # Sanity check: position should be non-negative and <= duration - if pos >= 0: - if result["duration"] and pos <= result["duration"]: - result["position"] = pos - elif result["duration"] and pos > result["duration"]: - result["position"] = result["duration"] - elif not result["duration"]: - result["position"] = pos + # Sanity check: position should be non-negative and <= duration + if pos >= 0: + if result["duration"] and pos <= result["duration"]: + result["position"] = pos + elif result["duration"] and pos > result["duration"]: + result["position"] = result["duration"] + elif not result["duration"]: + result["position"] = pos logger.debug(f"Timeline: duration={result['duration']}, position={result['position']}") except Exception as e: @@ -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,10 +612,10 @@ class WindowsMediaController(MediaController): result = await self._run_command("next") if result: - # Set flag to force position to 0 until title changes - _track_skip_pending["active"] = True - _track_skip_pending["old_title"] = old_title - _track_skip_pending["skip_time"] = _time.time() + with _position_lock: + _track_skip_pending["active"] = True + _track_skip_pending["old_title"] = old_title + _track_skip_pending["skip_time"] = _time.time() logger.debug(f"Track skip initiated, old title: {old_title}") return result @@ -620,10 +630,10 @@ class WindowsMediaController(MediaController): result = await self._run_command("previous") if result: - # Set flag to force position to 0 until title changes - _track_skip_pending["active"] = True - _track_skip_pending["old_title"] = old_title - _track_skip_pending["skip_time"] = _time.time() + with _position_lock: + _track_skip_pending["active"] = True + _track_skip_pending["old_title"] = old_title + _track_skip_pending["skip_time"] = _time.time() logger.debug(f"Track skip initiated, old title: {old_title}") return result diff --git a/media_server/static/css/styles.css b/media_server/static/css/styles.css index 435e262..e42cf21 100644 --- a/media_server/static/css/styles.css +++ b/media_server/static/css/styles.css @@ -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; diff --git a/media_server/static/index.html b/media_server/static/index.html index a5bc6c9..eb25792 100644 --- a/media_server/static/index.html +++ b/media_server/static/index.html @@ -74,12 +74,12 @@
-
-
diff --git a/media_server/static/js/app.js b/media_server/static/js/app.js index 4fc6bca..8099501 100644 --- a/media_server/static/js/app.js +++ b/media_server/static/js/app.js @@ -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) {