fix: comprehensive security, bug, performance, and UI/UX audit
Lint & Test / test (push) Successful in 20s
Lint & Test / test (push) Successful in 20s
Security
- Default bind 127.0.0.1; first-run bootstrap generates random api_token
and refuses to bind non-loopback without auth unless explicitly opted in
- Path-traversal hardened: BrowserService.validate_path rejects absolute
paths, drive letters, UNC, NUL bytes. /api/browser/{play,metadata,
thumbnail} now require folder_id and a folder-relative path
- Pydantic validators on links: http(s) URLs only, mdi:<slug> icons only
- Scripts/callbacks/links create/update/delete gated by *_management flags
- Strict CSP, X-Frame-Options DENY, Referrer-Policy no-referrer,
X-Content-Type-Options nosniff
- CORS locked to localhost:<port> + 127.0.0.1:<port> by default; configurable
- config.yaml writes atomic (tmp + os.replace) and 0o600 on POSIX
- Subprocesses spawned in their own process group / new session so timeout
kills the whole tree (Windows CREATE_NEW_PROCESS_GROUP, POSIX
start_new_session=True)
- Frontend XSS: monitor name + details escapeHtml'd; power button moved to
delegated data-action handler; remote MDI SVGs parsed and sanitized
(strip script/foreignObject/on*/javascript: hrefs) before innerHTML
- All dynamic URL segments now wrapped in encodeURIComponent
Bugs
- WebSocket reconnect: close previous socket before opening new, clear
ping interval per-socket, clear reconnectTimeout up-front, retry on
online/visibilitychange, try/catch JSON.parse
- Artwork fetch race: AbortController + generation guard
- _broadcast_after_open: initialize status, swallow per-poll errors,
background tasks tracked in a strong-ref set with done-callback cleanup
- Audio analyzer: sticky _unavailable flag prevents infinite start/stop
spin when no loopback device exists; cleared by set_device()
- Volume short-circuit cache invalidated when server reports remote volume
- Browser thumbnail race: per-folder generation counter + isConnected
checks; aborts in-flight fetches on navigation
- Track-skip uses cached title instead of full WinRT status round-trip
Performance
- Linux MPRIS/pactl and /api/display DDC-CI handlers wrapped in
asyncio.to_thread so blocking IO never stalls the event loop
- browse_directory moved off the event loop (SMB shares could freeze it)
- Windows status poll caches one asyncio loop per worker thread via
threading.local instead of new_event_loop/close on every 0.5s tick
- broadcast() serializes JSON once and uses send_text to all clients
- Hourly thumbnail cache cleanup scheduled in lifespan (was never invoked
— cache grew unbounded)
- Progress drag listeners attached only while dragging
Quality
- All asyncio.get_event_loop() in coroutines → get_running_loop()
- ThreadPoolExecutors shut down cleanly during lifespan teardown
- config_manager dedup: 12 near-identical methods collapsed onto generic
_upsert/_delete helpers (~290 lines removed)
- Service worker no longer pass-throughs every fetch
- M3U playlist written via NamedTemporaryFile (no fixed-path symlink
clobber race)
- __version__ now prefers live pyproject.toml in dev checkouts so
pip install -e . users see the source-of-truth version, not the stale
package-metadata version baked in at install time
UI/UX (Studio Reference)
- Green leftover focus rings (rgba(29,185,84,...)) all replaced with
copper accent (rgba(var(--copper-rgb),...))
- Dialogs: square corners, copper top hairline, unified with editorial
chrome
- .browser-item: transparent with copper hover border (was filled card)
- Audio device select uses var(--sans) instead of generic system font
- Mobile container padding tuned for ≤480px screens
- Breadcrumb home is a real <button> with aria-label; aria-current on root
- i18n: filled display.msg.power_*, execution.*, scripts.params.execute,
callbacks.empty in both en + ru
This commit is contained in:
@@ -72,6 +72,11 @@ class AudioAnalyzer:
|
||||
self._lifecycle_lock = threading.Lock()
|
||||
self._data: dict | None = None
|
||||
self._current_device_name: str | None = None
|
||||
# Sticky "no usable device" flag — flipped to True if a capture
|
||||
# attempt fails because no loopback device exists. Prevents the
|
||||
# WebSocket manager from looping on start()/stop()/start() forever
|
||||
# when there's nothing to capture. Cleared by set_device().
|
||||
self._unavailable = False
|
||||
# Generation counter — bumped each time _data is refreshed.
|
||||
# Lets the broadcast loop dedupe without comparing dict identity
|
||||
# (which is fragile because we always allocate a new dict).
|
||||
@@ -123,6 +128,10 @@ class AudioAnalyzer:
|
||||
return True
|
||||
if not self.available:
|
||||
return False
|
||||
if self._unavailable:
|
||||
# We already tried and failed to acquire a device. Don't
|
||||
# spin a new capture thread for each new subscriber.
|
||||
return False
|
||||
|
||||
# Reset AGC envelope so a long silent gap between sessions
|
||||
# doesn't make the first new transients clip at the ceiling.
|
||||
@@ -235,6 +244,9 @@ class AudioAnalyzer:
|
||||
|
||||
self.device_name = device_name
|
||||
self._current_device_name = None
|
||||
# Clear the "no device" sticky flag — the user is asking for a
|
||||
# different device so it's worth attempting capture again.
|
||||
self._unavailable = False
|
||||
|
||||
if was_running:
|
||||
return self.start()
|
||||
@@ -269,6 +281,7 @@ class AudioAnalyzer:
|
||||
if device is None:
|
||||
logger.warning("No loopback audio device found - visualizer disabled")
|
||||
self._running = False
|
||||
self._unavailable = True
|
||||
return
|
||||
|
||||
interval = 1.0 / self.target_fps
|
||||
|
||||
@@ -63,14 +63,28 @@ class BrowserService:
|
||||
if not base_path.is_dir():
|
||||
raise ValueError(f"Media folder path is not a directory: {base_path}")
|
||||
|
||||
# Handle relative vs absolute paths
|
||||
if requested_path.startswith("/") or requested_path.startswith("\\"):
|
||||
# Relative to folder root (remove leading slash)
|
||||
requested_path = requested_path.lstrip("/\\")
|
||||
# Reject absolute paths, drive letters, UNC paths, and NUL bytes outright.
|
||||
# Only true folder-relative paths are accepted.
|
||||
if "\x00" in requested_path:
|
||||
raise ValueError("Path contains NUL byte")
|
||||
|
||||
# Strip a single leading "/" or "\\" (legacy callers send "/sub/dir") but
|
||||
# then refuse anything that still looks absolute.
|
||||
cleaned = requested_path.lstrip("/\\")
|
||||
# Detect Windows drive letter like "C:/..." after stripping.
|
||||
if len(cleaned) >= 2 and cleaned[1] == ":":
|
||||
raise ValueError("Absolute paths are not allowed")
|
||||
# Detect raw UNC ("\\\\server\\share") — the lstrip above strips at most
|
||||
# one leading slash, so a UNC original starts with another "\\" or "/".
|
||||
if cleaned.startswith("\\") or cleaned.startswith("/"):
|
||||
raise ValueError("Absolute paths are not allowed")
|
||||
candidate = Path(cleaned) if cleaned else None
|
||||
if candidate is not None and candidate.is_absolute():
|
||||
raise ValueError("Absolute paths are not allowed")
|
||||
|
||||
# Build and resolve full path
|
||||
if requested_path:
|
||||
full_path = (base_path / requested_path).resolve()
|
||||
if cleaned:
|
||||
full_path = (base_path / cleaned).resolve()
|
||||
else:
|
||||
full_path = base_path
|
||||
|
||||
|
||||
@@ -151,22 +151,19 @@ class LinuxMediaController(MediaController):
|
||||
logger.error(f"Failed to toggle mute: {e}")
|
||||
return False
|
||||
|
||||
async def get_status(self) -> MediaStatus:
|
||||
"""Get current media playback status."""
|
||||
def _sync_get_status(self) -> MediaStatus:
|
||||
"""Synchronous status read (called from a worker thread)."""
|
||||
status = MediaStatus()
|
||||
|
||||
# Get system volume
|
||||
volume, muted = self._get_volume_pulseaudio()
|
||||
status.volume = volume
|
||||
status.muted = muted
|
||||
|
||||
# Get active player
|
||||
player_name = self._get_active_player()
|
||||
if player_name is None:
|
||||
status.state = MediaState.IDLE
|
||||
return status
|
||||
|
||||
# Get playback status
|
||||
playback_status = self._get_property(player_name, "PlaybackStatus")
|
||||
if playback_status == "Playing":
|
||||
status.state = MediaState.PLAYING
|
||||
@@ -177,114 +174,70 @@ class LinuxMediaController(MediaController):
|
||||
else:
|
||||
status.state = MediaState.IDLE
|
||||
|
||||
# Get metadata
|
||||
metadata = self._get_property(player_name, "Metadata")
|
||||
if metadata:
|
||||
status.title = str(metadata.get("xesam:title", "")) or None
|
||||
|
||||
artists = metadata.get("xesam:artist", [])
|
||||
if artists:
|
||||
status.artist = str(artists[0]) if isinstance(artists, list) else str(artists)
|
||||
|
||||
status.album = str(metadata.get("xesam:album", "")) or None
|
||||
status.album_art_url = str(metadata.get("mpris:artUrl", "")) or None
|
||||
|
||||
# Duration in microseconds
|
||||
length = metadata.get("mpris:length", 0)
|
||||
if length:
|
||||
status.duration = int(length) / 1_000_000
|
||||
|
||||
# Get position (in microseconds)
|
||||
position = self._get_property(player_name, "Position")
|
||||
if position is not None:
|
||||
status.position = int(position) / 1_000_000
|
||||
|
||||
# Get source name
|
||||
status.source = player_name.replace(self.MPRIS_PREFIX, "")
|
||||
|
||||
return status
|
||||
|
||||
async def play(self) -> bool:
|
||||
"""Resume playback."""
|
||||
async def get_status(self) -> MediaStatus:
|
||||
"""Get current media playback status (off the event loop)."""
|
||||
# pactl + DBus calls each take 5-100ms on a Pi and would block every
|
||||
# other coroutine on the server. Run them in a worker thread.
|
||||
return await asyncio.to_thread(self._sync_get_status)
|
||||
|
||||
def _call_player(self, method_name: str) -> bool:
|
||||
player_name = self._get_active_player()
|
||||
if player_name is None:
|
||||
return False
|
||||
try:
|
||||
player = self._get_player_interface(player_name)
|
||||
player.Play()
|
||||
getattr(player, method_name)()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to play: {e}")
|
||||
logger.error(f"Failed to call player.{method_name}: {e}")
|
||||
return False
|
||||
|
||||
async def play(self) -> bool:
|
||||
return await asyncio.to_thread(self._call_player, "Play")
|
||||
|
||||
async def pause(self) -> bool:
|
||||
"""Pause playback."""
|
||||
player_name = self._get_active_player()
|
||||
if player_name is None:
|
||||
return False
|
||||
try:
|
||||
player = self._get_player_interface(player_name)
|
||||
player.Pause()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to pause: {e}")
|
||||
return False
|
||||
return await asyncio.to_thread(self._call_player, "Pause")
|
||||
|
||||
async def stop(self) -> bool:
|
||||
"""Stop playback."""
|
||||
player_name = self._get_active_player()
|
||||
if player_name is None:
|
||||
return False
|
||||
try:
|
||||
player = self._get_player_interface(player_name)
|
||||
player.Stop()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stop: {e}")
|
||||
return False
|
||||
return await asyncio.to_thread(self._call_player, "Stop")
|
||||
|
||||
async def next_track(self) -> bool:
|
||||
"""Skip to next track."""
|
||||
player_name = self._get_active_player()
|
||||
if player_name is None:
|
||||
return False
|
||||
try:
|
||||
player = self._get_player_interface(player_name)
|
||||
player.Next()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to skip next: {e}")
|
||||
return False
|
||||
return await asyncio.to_thread(self._call_player, "Next")
|
||||
|
||||
async def previous_track(self) -> bool:
|
||||
"""Go to previous track."""
|
||||
player_name = self._get_active_player()
|
||||
if player_name is None:
|
||||
return False
|
||||
try:
|
||||
player = self._get_player_interface(player_name)
|
||||
player.Previous()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to skip previous: {e}")
|
||||
return False
|
||||
return await asyncio.to_thread(self._call_player, "Previous")
|
||||
|
||||
async def set_volume(self, volume: int) -> bool:
|
||||
"""Set system volume."""
|
||||
return self._set_volume_pulseaudio(volume)
|
||||
return await asyncio.to_thread(self._set_volume_pulseaudio, volume)
|
||||
|
||||
async def toggle_mute(self) -> bool:
|
||||
"""Toggle mute state."""
|
||||
return self._toggle_mute_pulseaudio()
|
||||
return await asyncio.to_thread(self._toggle_mute_pulseaudio)
|
||||
|
||||
async def seek(self, position: float) -> bool:
|
||||
"""Seek to position in seconds."""
|
||||
def _sync_seek(self, position: float) -> bool:
|
||||
player_name = self._get_active_player()
|
||||
if player_name is None:
|
||||
return False
|
||||
try:
|
||||
player = self._get_player_interface(player_name)
|
||||
# MPRIS expects position in microseconds
|
||||
player.SetPosition(
|
||||
self._get_property(player_name, "Metadata").get("mpris:trackid", "/"),
|
||||
int(position * 1_000_000),
|
||||
@@ -294,6 +247,9 @@ class LinuxMediaController(MediaController):
|
||||
logger.error(f"Failed to seek: {e}")
|
||||
return False
|
||||
|
||||
async def seek(self, position: float) -> bool:
|
||||
return await asyncio.to_thread(self._sync_seek, position)
|
||||
|
||||
async def open_file(self, file_path: str) -> bool:
|
||||
"""Open a media file with the default system player (Linux).
|
||||
|
||||
|
||||
@@ -321,7 +321,7 @@ class ThumbnailService:
|
||||
|
||||
if suffix in AUDIO_EXTENSIONS:
|
||||
# Audio files - run in executor (sync operation)
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
thumbnail_data = await loop.run_in_executor(
|
||||
None,
|
||||
ThumbnailService.generate_audio_thumbnail,
|
||||
|
||||
@@ -70,16 +70,27 @@ class ConnectionManager:
|
||||
)
|
||||
|
||||
async def broadcast(self, message: dict[str, Any]) -> None:
|
||||
"""Broadcast a message to all connected clients concurrently."""
|
||||
"""Broadcast a message to all connected clients concurrently.
|
||||
|
||||
The payload is serialized once and pushed via ``send_text`` to every
|
||||
client, instead of having Starlette/Pydantic encode it N times via
|
||||
``send_json``.
|
||||
"""
|
||||
async with self._lock:
|
||||
connections = list(self._active_connections)
|
||||
|
||||
if not connections:
|
||||
return
|
||||
|
||||
try:
|
||||
payload = json.dumps(message, default=str)
|
||||
except (TypeError, ValueError) as e:
|
||||
logger.error("Failed to encode broadcast message: %s", e)
|
||||
return
|
||||
|
||||
async def _send(ws: WebSocket) -> WebSocket | None:
|
||||
try:
|
||||
await ws.send_json(message)
|
||||
await ws.send_text(payload)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug("Failed to send to client: %s", e)
|
||||
@@ -129,7 +140,7 @@ class ConnectionManager:
|
||||
async def _maybe_start_capture(self) -> None:
|
||||
"""Start audio capture if not already running (called on first subscriber)."""
|
||||
if self._audio_analyzer and not self._audio_analyzer.running:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
started = await loop.run_in_executor(None, self._audio_analyzer.start)
|
||||
if started:
|
||||
logger.info("Audio capture started (first subscriber)")
|
||||
@@ -139,7 +150,7 @@ class ConnectionManager:
|
||||
async def _maybe_stop_capture(self) -> None:
|
||||
"""Stop audio capture if running (called when last subscriber leaves)."""
|
||||
if self._audio_analyzer and self._audio_analyzer.running:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, self._audio_analyzer.stop)
|
||||
logger.info("Audio capture stopped (no subscribers)")
|
||||
|
||||
@@ -171,7 +182,7 @@ class ConnectionManager:
|
||||
idle_interval = 1.0 / max(1, settings.visualizer_fps)
|
||||
# Bounded wait so we still notice subscribe/unsubscribe transitions.
|
||||
wake_timeout = max(0.05, idle_interval)
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
last_seq = -1
|
||||
|
||||
|
||||
@@ -15,6 +15,22 @@ logger = logging.getLogger(__name__)
|
||||
# Thread pool for WinRT operations (they don't play well with asyncio)
|
||||
_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="winrt")
|
||||
|
||||
# Cache an asyncio event loop per worker thread so the 500ms status poll
|
||||
# doesn't allocate + tear down a new loop on every tick. Creating a loop
|
||||
# every 0.5s churns CPU and leaks finalized loop references that linger in
|
||||
# WinRT callbacks. With this helper a thread reuses one loop forever and
|
||||
# we only pay the setup cost once per worker.
|
||||
_thread_local = threading.local()
|
||||
|
||||
|
||||
def _thread_loop() -> asyncio.AbstractEventLoop:
|
||||
loop = getattr(_thread_local, "loop", None)
|
||||
if loop is None or loop.is_closed():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
_thread_local.loop = loop
|
||||
return loop
|
||||
|
||||
# Global storage for current album art (as bytes)
|
||||
_current_album_art_bytes: bytes | None = None
|
||||
|
||||
@@ -161,8 +177,6 @@ WINDOWS_AVAILABLE = WINSDK_AVAILABLE
|
||||
|
||||
def _sync_get_media_status() -> dict[str, Any]:
|
||||
"""Synchronously get media status (runs in thread pool)."""
|
||||
import asyncio
|
||||
|
||||
result = {
|
||||
"state": "idle",
|
||||
"title": None,
|
||||
@@ -174,9 +188,7 @@ def _sync_get_media_status() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
try:
|
||||
# Create a new event loop for this thread
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop = _thread_loop()
|
||||
|
||||
try:
|
||||
# Get media session manager
|
||||
@@ -393,7 +405,8 @@ def _sync_get_media_status() -> dict[str, Any]:
|
||||
result["source"] = session.source_app_user_model_id
|
||||
|
||||
finally:
|
||||
loop.close()
|
||||
# Reuse the loop across calls — see _thread_loop above.
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting media status: {e}")
|
||||
@@ -439,35 +452,28 @@ def _find_best_session(manager, loop):
|
||||
|
||||
def _sync_media_command(command: str) -> bool:
|
||||
"""Synchronously execute a media command (runs in thread pool)."""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
try:
|
||||
manager = loop.run_until_complete(MediaManager.request_async())
|
||||
if manager is None:
|
||||
return False
|
||||
|
||||
session = _find_best_session(manager, loop)
|
||||
if session is None:
|
||||
return False
|
||||
|
||||
if command == "play":
|
||||
return loop.run_until_complete(session.try_play_async())
|
||||
elif command == "pause":
|
||||
return loop.run_until_complete(session.try_pause_async())
|
||||
elif command == "stop":
|
||||
return loop.run_until_complete(session.try_stop_async())
|
||||
elif command == "next":
|
||||
return loop.run_until_complete(session.try_skip_next_async())
|
||||
elif command == "previous":
|
||||
return loop.run_until_complete(session.try_skip_previous_async())
|
||||
|
||||
loop = _thread_loop()
|
||||
manager = loop.run_until_complete(MediaManager.request_async())
|
||||
if manager is None:
|
||||
return False
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
session = _find_best_session(manager, loop)
|
||||
if session is None:
|
||||
return False
|
||||
|
||||
if command == "play":
|
||||
return loop.run_until_complete(session.try_play_async())
|
||||
elif command == "pause":
|
||||
return loop.run_until_complete(session.try_pause_async())
|
||||
elif command == "stop":
|
||||
return loop.run_until_complete(session.try_stop_async())
|
||||
elif command == "next":
|
||||
return loop.run_until_complete(session.try_skip_next_async())
|
||||
elif command == "previous":
|
||||
return loop.run_until_complete(session.try_skip_previous_async())
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing media command {command}: {e}")
|
||||
@@ -476,27 +482,20 @@ def _sync_media_command(command: str) -> bool:
|
||||
|
||||
def _sync_seek(position: float) -> bool:
|
||||
"""Synchronously seek to position."""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop = _thread_loop()
|
||||
manager = loop.run_until_complete(MediaManager.request_async())
|
||||
if manager is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
manager = loop.run_until_complete(MediaManager.request_async())
|
||||
if manager is None:
|
||||
return False
|
||||
session = _find_best_session(manager, loop)
|
||||
if session is None:
|
||||
return False
|
||||
|
||||
session = _find_best_session(manager, loop)
|
||||
if session is None:
|
||||
return False
|
||||
|
||||
position_ticks = int(position * 10_000_000)
|
||||
return loop.run_until_complete(
|
||||
session.try_change_playback_position_async(position_ticks)
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
position_ticks = int(position * 10_000_000)
|
||||
return loop.run_until_complete(
|
||||
session.try_change_playback_position_async(position_ticks)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error seeking: {e}")
|
||||
@@ -559,7 +558,7 @@ class WindowsMediaController(MediaController):
|
||||
|
||||
# Get media info in thread pool (avoids asyncio/WinRT issues)
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
media_info = await asyncio.wait_for(
|
||||
loop.run_in_executor(_executor, _sync_get_media_status),
|
||||
timeout=5.0
|
||||
@@ -592,7 +591,7 @@ class WindowsMediaController(MediaController):
|
||||
async def _run_command(self, command: str) -> bool:
|
||||
"""Run a media command in the thread pool."""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
return await asyncio.wait_for(
|
||||
loop.run_in_executor(_executor, _sync_media_command, command),
|
||||
timeout=5.0
|
||||
@@ -616,16 +615,15 @@ class WindowsMediaController(MediaController):
|
||||
"""Stop playback."""
|
||||
return await self._run_command("stop")
|
||||
|
||||
async def next_track(self) -> bool:
|
||||
"""Skip to next track."""
|
||||
# Get current title before skipping
|
||||
try:
|
||||
status = await self.get_status()
|
||||
old_title = status.title or ""
|
||||
except Exception:
|
||||
old_title = ""
|
||||
async def _skip_track(self, command: str) -> bool:
|
||||
# Read the current title from the position cache instead of doing a
|
||||
# full WinRT round-trip (which can take up to 5s) just for one field.
|
||||
with _position_lock:
|
||||
track_id = _position_cache.get("track_id") or ""
|
||||
# track_id is "title:artist:duration" — extract just the title.
|
||||
old_title = track_id.split(":", 1)[0] if track_id else ""
|
||||
|
||||
result = await self._run_command("next")
|
||||
result = await self._run_command(command)
|
||||
if result:
|
||||
with _position_lock:
|
||||
_track_skip_pending["active"] = True
|
||||
@@ -634,23 +632,13 @@ class WindowsMediaController(MediaController):
|
||||
logger.debug(f"Track skip initiated, old title: {old_title}")
|
||||
return result
|
||||
|
||||
async def next_track(self) -> bool:
|
||||
"""Skip to next track."""
|
||||
return await self._skip_track("next")
|
||||
|
||||
async def previous_track(self) -> bool:
|
||||
"""Go to previous track."""
|
||||
# Get current title before skipping
|
||||
try:
|
||||
status = await self.get_status()
|
||||
old_title = status.title or ""
|
||||
except Exception:
|
||||
old_title = ""
|
||||
|
||||
result = await self._run_command("previous")
|
||||
if result:
|
||||
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
|
||||
return await self._skip_track("previous")
|
||||
|
||||
async def set_volume(self, volume: int) -> bool:
|
||||
"""Set system volume."""
|
||||
@@ -680,7 +668,7 @@ class WindowsMediaController(MediaController):
|
||||
async def seek(self, position: float) -> bool:
|
||||
"""Seek to position in seconds."""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
return await asyncio.wait_for(
|
||||
loop.run_in_executor(_executor, _sync_seek, position),
|
||||
timeout=5.0
|
||||
@@ -705,7 +693,7 @@ class WindowsMediaController(MediaController):
|
||||
"""
|
||||
try:
|
||||
import os
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, lambda: os.startfile(file_path))
|
||||
logger.info(f"Opened file with default player: {file_path}")
|
||||
return True
|
||||
|
||||
Reference in New Issue
Block a user