Files
media-player-server/media_server/services/windows_media.py
T
alexei.dolgolyov d131ba461c
Lint & Test / test (push) Successful in 20s
fix: production-readiness hardening — security, perf, a11y, observability
Security
- Default scripts_management, callbacks_management, links_management, and
  media_folders_management to False so a leaked token cannot escalate to RCE
  through admin CRUD endpoints.
- TokenSpec + scope hierarchy (read | control | admin); legacy bare-string
  api_tokens entries promote to admin for back-compat. Management endpoints
  now require admin scope.
- WebSocket subprotocol auth (Sec-WebSocket-Protocol: media-server.token.<T>)
  preferred over ?token= query so the token no longer lands in URL/history/
  Referer; query fallback retained for HA integration back-compat.
- Origin allow-list check on the WS endpoint (CSWSH defence).
- In-process token-bucket rate limiter: 5/min for failed auths,
  10/min for /api/scripts/execute and /api/callbacks/execute.
- shell=False subprocess path (shlex.split) + per-parameter regex `pattern`
  in ScriptParameterConfig to harden shell=true scripts against parameter
  injection (Windows cmd.exe env-var expansion).
- CSP gains form-action, worker-src, manifest-src directives.
- Refuse cors_origins=["*"] at startup; strip token=... from uvicorn access
  logs; validate Gitea release tag against strict SemVer regex.
- noopener noreferrer + no-referrer referrerpolicy on every outbound link.
- icacls hardening of config.yaml on Windows (current user + SYSTEM +
  Administrators only); 0600 still enforced on POSIX.
- WS volume handler clamps input and never drops the socket on bad messages.

Performance
- Album-art read in windows_media gated by track key — was decoding the
  WinRT thumbnail twice per second regardless of track changes.
- /api/media/artwork returns content-derived ETag + Cache-Control so the
  browser sends If-None-Match and gets 304s on track repeats.
- Foreground-service ctypes argtypes hoisted to one-time module init
  (was re-declaring ~14 prototypes per probe).
- display_service _static_cache keyed by (edid_hash, ...) tuple with
  eviction of disappeared monitors — fixes stale capabilities on hot-plug
  swaps where the new topology has the same monitor count.
- Visualizer rAF loop paused on document.hidden, resumed on visible.

Reliability / bug fixes
- Lifespan rewritten as try/yield/finally so a partial-startup failure
  cannot orphan background tasks or executors.
- _run_callback in routes/media.py keeps a strong task ref (GC-safe) and
  uses the dedicated callback executor instead of the default pool.
- macos_media.set_volume() no longer always returns True.
- TrayManager._restart_requested initialised in __init__; set before
  signalling exit so the main thread observes it correctly.
- Missing static_dir now logs a WARNING instead of silent UI disable.

UX / accessibility / PWA
- manifest.json theme_color and background_color match the Studio Reference
  base (#0E0D0B); added id and scope for PWA installability.
- ARIA on mini-player icon buttons; inner SVGs marked aria-hidden.
- OS mediaSession API wired so headset / lockscreen / Bluetooth buttons
  drive play/pause/next/prev/seek and show track metadata + artwork.

Observability
- X-Request-ID middleware (accept upstream id if it matches a safe regex,
  otherwise UUID4); request_id_var added to ContextVars and included in
  every log line alongside the token label.
- Audit log (append-only JSONL) for every script + callback execution,
  including the on_play/on_pause/etc. event callbacks. Background-thread
  writer; queue capped; flushed in lifespan teardown.

Deployment
- proxy_headers + forwarded_allow_ips plumbed through Settings →
  uvicorn.Config for reverse-proxy installs.
- HTTPS support via ssl_certfile + ssl_keyfile (+ optional password);
  startup refuses to launch with only one of the pair set.
- Thumbnail cache moved from project-root .cache to
  %LOCALAPPDATA%/media-server/cache (Windows) and
  $XDG_CACHE_HOME/media-server/thumbnails (POSIX).

Tests
- 35 new tests across auth scopes, rate limiter, browser path traversal
  (../ NUL UNC absolute), script-param validation incl. regex, Gitea tag
  whitelist, config atomic write + POSIX perms. 47 passed / 4 skipped.
2026-05-22 22:25:54 +03:00

731 lines
31 KiB
Python

"""Windows media controller using WinRT APIs."""
import asyncio
import logging
import threading
import time as _time
from concurrent.futures import ThreadPoolExecutor
from typing import Any
from ..models import MediaState, MediaStatus
from .media_controller import MediaController
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). Guarded by _art_lock so the
# WinRT polling thread and the FastAPI handler thread don't race on swap.
_current_album_art_bytes: bytes | None = None
_art_lock = threading.Lock()
# Identity of the track whose art is currently in _current_album_art_bytes.
# Used to gate the expensive WinRT thumbnail.open_read_async() so the bytes
# aren't re-decoded on every 500ms status poll.
_current_album_art_key: tuple | None = None
# Lock protecting _position_cache and _track_skip_pending from concurrent access
_position_lock = threading.Lock()
# Global storage for position tracking
_position_cache = {
"track_id": "",
"base_position": 0.0,
"base_time": 0.0,
"is_playing": False,
"duration": 0.0,
}
# Flag to force position to 0 after track skip (until title changes)
_track_skip_pending = {
"active": False,
"old_title": "",
"skip_time": 0.0,
"grace_until": 0.0, # After title changes, ignore stale SMTC positions
"stale_pos": -999, # The stale SMTC position we're ignoring
}
def get_current_album_art() -> bytes | None:
"""Get the current album art bytes (thread-safe snapshot)."""
with _art_lock:
return _current_album_art_bytes
# Windows-specific imports
try:
from winsdk.windows.media.control import (
GlobalSystemMediaTransportControlsSessionManager as MediaManager,
)
from winsdk.windows.media.control import (
GlobalSystemMediaTransportControlsSessionPlaybackStatus as PlaybackStatus,
)
WINSDK_AVAILABLE = True
except ImportError:
WINSDK_AVAILABLE = False
logger.warning("winsdk not available")
# Volume control imports
PYCAW_AVAILABLE = False
_volume_control = None
_configured_device_name: str | None = None
try:
import warnings
from ctypes import POINTER, cast
from comtypes import CLSCTX_ALL
from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
# Suppress pycaw warnings about missing device properties
warnings.filterwarnings("ignore", category=UserWarning, module="pycaw")
def _get_all_audio_devices() -> list[dict[str, str]]:
"""Get list of all audio output devices."""
devices = []
try:
# Use pycaw's GetAllDevices which handles property retrieval
all_devices = AudioUtilities.GetAllDevices()
for device in all_devices:
# Only include render (output) devices with valid names
# Render devices have IDs starting with {0.0.0
if device.FriendlyName and device.id and device.id.startswith("{0.0.0"):
devices.append({
"id": device.id,
"name": device.FriendlyName,
})
except Exception as e:
logger.error(f"Error enumerating audio devices: {e}")
return devices
def _find_device_by_name(device_name: str):
"""Find an audio device by its friendly name (partial match).
Returns the AudioDevice wrapper for the matched device.
"""
try:
# Get all devices and find matching one
all_devices = AudioUtilities.GetAllDevices()
for device in all_devices:
if device.FriendlyName and device_name.lower() in device.FriendlyName.lower():
logger.info(f"Found audio device: {device.FriendlyName}")
return device
except Exception as e:
logger.error(f"Error finding device by name: {e}")
return None
def _init_volume_control(device_name: str | None = None):
"""Initialize volume control interface.
Args:
device_name: Name of the audio device to control (partial match).
If None, uses the default audio device.
"""
global _volume_control, _configured_device_name
if _volume_control is not None and device_name == _configured_device_name:
return _volume_control
_configured_device_name = device_name
try:
if device_name:
# Find specific device by name
device = _find_device_by_name(device_name)
if device is None:
logger.warning(f"Audio device '{device_name}' not found, using default")
device = AudioUtilities.GetSpeakers()
else:
# Use default device
device = AudioUtilities.GetSpeakers()
if hasattr(device, 'Activate'):
interface = device.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None)
elif hasattr(device, '_dev'):
interface = device._dev.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None)
else:
logger.warning("Could not activate audio device")
return None
_volume_control = cast(interface, POINTER(IAudioEndpointVolume))
return _volume_control
except Exception as e:
logger.error(f"Volume control init error: {e}")
return None
PYCAW_AVAILABLE = True
except ImportError as e:
logger.warning(f"pycaw not available: {e}")
def _get_all_audio_devices() -> list[dict[str, str]]:
return []
def _find_device_by_name(device_name: str):
return None
def _init_volume_control(device_name: str | None = None):
return None
WINDOWS_AVAILABLE = WINSDK_AVAILABLE
def _sync_get_media_status() -> dict[str, Any]:
"""Synchronously get media status (runs in thread pool)."""
result = {
"state": "idle",
"title": None,
"artist": None,
"album": None,
"duration": None,
"position": None,
"source": None,
}
try:
loop = _thread_loop()
try:
# Get media session manager
manager = loop.run_until_complete(MediaManager.request_async())
if manager is None:
return result
session = _find_best_session(manager, loop)
if session is None:
return result
# Get playback status
playback_info = session.get_playback_info()
if playback_info:
status = playback_info.playback_status
if status == PlaybackStatus.PLAYING:
result["state"] = "playing"
elif status == PlaybackStatus.PAUSED:
result["state"] = "paused"
elif status == PlaybackStatus.STOPPED:
result["state"] = "stopped"
# Get media properties FIRST (needed for track ID)
media_props = loop.run_until_complete(
session.try_get_media_properties_async()
)
if media_props:
result["title"] = media_props.title or None
result["artist"] = media_props.artist or None
result["album"] = media_props.album_title or None
# Get timeline
timeline = session.get_timeline_properties()
if timeline:
try:
# end_time and position are datetime.timedelta objects
end_time = timeline.end_time
position = timeline.position
# Get duration
if hasattr(end_time, 'total_seconds'):
duration = end_time.total_seconds()
# Sanity check: duration should be positive and reasonable (< 24 hours)
if 0 < duration < 86400:
result["duration"] = duration
# Get position from SMTC and interpolate for smooth updates
if hasattr(position, 'total_seconds'):
smtc_pos = position.total_seconds()
current_time = _time.time()
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"]:
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},"
f" 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["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
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
# 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)
# 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}"
f" (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}),"
f" 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
logger.debug(f"Timeline: duration={result['duration']}, position={result['position']}")
except Exception as e:
logger.debug(f"Timeline parse error: {e}")
# Try to get album art (requires media_props). Gated by track key so
# the WinRT IPC + bytes copy only runs when the track actually
# changes; otherwise we just preserve the existing cached bytes.
if media_props:
track_key = (
getattr(media_props, "title", "") or "",
getattr(media_props, "artist", "") or "",
getattr(media_props, "album_title", "") or "",
)
global _current_album_art_bytes, _current_album_art_key
if track_key == _current_album_art_key and _current_album_art_bytes:
# Same track — reuse cached art bytes without touching WinRT.
result["album_art_url"] = "/api/media/artwork"
else:
try:
thumbnail = media_props.thumbnail
if thumbnail:
stream = loop.run_until_complete(thumbnail.open_read_async())
if stream:
size = stream.size
if size > 0 and size < 10 * 1024 * 1024: # Max 10MB
from winsdk.windows.storage.streams import DataReader
reader = DataReader(stream)
loop.run_until_complete(reader.load_async(size))
buffer = bytearray(size)
reader.read_bytes(buffer)
reader.close()
stream.close()
with _art_lock:
_current_album_art_bytes = bytes(buffer)
_current_album_art_key = track_key
result["album_art_url"] = "/api/media/artwork"
else:
# No thumbnail on this track — drop stale bytes so
# the ETag flips and clients don't keep showing the
# previous album's cover.
with _art_lock:
_current_album_art_bytes = None
_current_album_art_key = track_key
except Exception as e:
logger.debug(f"Failed to get album art: {e}")
result["source"] = session.source_app_user_model_id
finally:
# Reuse the loop across calls — see _thread_loop above.
pass
except Exception as e:
logger.error(f"Error getting media status: {e}")
return result
def _find_best_session(manager, loop):
"""Find the best media session to control."""
# First try the current session
session = manager.get_current_session()
# Log all available sessions for debugging
sessions = manager.get_sessions()
if sessions:
logger.debug(f"Total sessions available: {sessions.size}")
for i in range(sessions.size):
s = sessions.get_at(i)
if s:
playback_info = s.get_playback_info()
status_name = "unknown"
if playback_info:
status_name = str(playback_info.playback_status)
logger.debug(f" Session {i}: {s.source_app_user_model_id} - status: {status_name}")
# If no current session, try to find any active session
if session is None:
if sessions and sessions.size > 0:
# Find a playing session, or use the first one
for i in range(sessions.size):
s = sessions.get_at(i)
if s:
playback_info = s.get_playback_info()
if playback_info and playback_info.playback_status == PlaybackStatus.PLAYING:
session = s
break
# If no playing session found, use the first available one
if session is None and sessions.size > 0:
session = sessions.get_at(0)
return session
def _sync_media_command(command: str) -> bool:
"""Synchronously execute a media command (runs in thread pool)."""
try:
loop = _thread_loop()
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())
return False
except Exception as e:
logger.error(f"Error executing media command {command}: {e}")
return False
def _sync_seek(position: float) -> bool:
"""Synchronously seek to position."""
try:
loop = _thread_loop()
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
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}")
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."""
def __init__(self, audio_device: str | None = None):
"""Initialize the Windows media controller.
Args:
audio_device: Name of the audio device to control (partial match).
If None, uses the default audio device.
"""
if not WINDOWS_AVAILABLE:
raise RuntimeError(
"Windows media control requires winsdk, pycaw, and comtypes packages"
)
self._volume_interface = None
self._volume_init_attempted = False
self._audio_device = audio_device
def _get_volume_interface(self):
"""Get the audio endpoint volume interface."""
if not self._volume_init_attempted:
self._volume_init_attempted = True
self._volume_interface = _init_volume_control(self._audio_device)
if self._volume_interface:
device_info = f" (device: {self._audio_device})" if self._audio_device else " (default device)"
logger.info(f"Volume control initialized successfully{device_info}")
else:
logger.warning("Volume control not available")
return self._volume_interface
@staticmethod
def get_audio_devices() -> list[dict[str, str]]:
"""Get list of available audio output devices."""
return _get_all_audio_devices()
async def get_status(self) -> MediaStatus:
"""Get current media playback status."""
status = MediaStatus()
# Get volume info (synchronous, fast)
volume_if = self._get_volume_interface()
if volume_if:
try:
volume_scalar = volume_if.GetMasterVolumeLevelScalar()
status.volume = int(volume_scalar * 100)
status.muted = bool(volume_if.GetMute())
except Exception as e:
logger.debug(f"Failed to get volume: {e}")
# Get media info in thread pool (avoids asyncio/WinRT issues)
try:
loop = asyncio.get_running_loop()
media_info = await asyncio.wait_for(
loop.run_in_executor(_executor, _sync_get_media_status),
timeout=5.0
)
state_map = {
"playing": MediaState.PLAYING,
"paused": MediaState.PAUSED,
"stopped": MediaState.STOPPED,
"idle": MediaState.IDLE,
}
status.state = state_map.get(media_info.get("state", "idle"), MediaState.IDLE)
status.title = media_info.get("title")
status.artist = media_info.get("artist")
status.album = media_info.get("album")
status.album_art_url = media_info.get("album_art_url")
status.duration = media_info.get("duration")
status.position = media_info.get("position")
status.source = media_info.get("source")
except asyncio.TimeoutError:
logger.warning("Media status request timed out")
status.state = MediaState.IDLE
except Exception as e:
logger.error(f"Error getting media status: {e}")
status.state = MediaState.IDLE
return status
async def _run_command(self, command: str) -> bool:
"""Run a media command in the thread pool."""
try:
loop = asyncio.get_running_loop()
return await asyncio.wait_for(
loop.run_in_executor(_executor, _sync_media_command, command),
timeout=5.0
)
except asyncio.TimeoutError:
logger.warning(f"Media command {command} timed out")
return False
except Exception as e:
logger.error(f"Error running media command {command}: {e}")
return False
async def play(self) -> bool:
"""Resume playback."""
return await self._run_command("play")
async def pause(self) -> bool:
"""Pause playback."""
return await self._run_command("pause")
async def stop(self) -> bool:
"""Stop playback."""
return await self._run_command("stop")
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(command)
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
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."""
return await self._skip_track("previous")
async def set_volume(self, volume: int) -> bool:
"""Set system volume."""
volume_if = self._get_volume_interface()
if volume_if is None:
return False
try:
volume_if.SetMasterVolumeLevelScalar(volume / 100.0, None)
return True
except Exception as e:
logger.error(f"Failed to set volume: {e}")
return False
async def toggle_mute(self) -> bool:
"""Toggle mute state."""
volume_if = self._get_volume_interface()
if volume_if is None:
return False
try:
current_mute = bool(volume_if.GetMute())
volume_if.SetMute(not current_mute, None)
return not current_mute
except Exception as e:
logger.error(f"Failed to toggle mute: {e}")
return False
async def seek(self, position: float) -> bool:
"""Seek to position in seconds."""
try:
loop = asyncio.get_running_loop()
return await asyncio.wait_for(
loop.run_in_executor(_executor, _sync_seek, position),
timeout=5.0
)
except asyncio.TimeoutError:
logger.warning("Seek command timed out")
return False
except Exception as e:
logger.error(f"Failed to seek: {e}")
return False
async def open_file(self, file_path: str) -> bool:
"""Open a media file with the default system player (Windows).
Uses os.startfile() to open the file with the default application.
Args:
file_path: Absolute path to the media file
Returns:
True if successful, False otherwise
"""
try:
import os
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
except Exception as e:
logger.error(f"Failed to open file {file_path}: {e}")
return False