Compare commits
1 Commits
73a6f387e1
...
9404b37f05
| Author | SHA1 | Date | |
|---|---|---|---|
| 9404b37f05 |
@@ -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=["*"],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user