FastAPI REST API server for controlling system-wide media playback on Windows, Linux, macOS, and Android. Features: - Play/Pause/Stop/Next/Previous track controls - Volume control and mute - Seek within tracks - Current track info (title, artist, album, artwork) - WebSocket real-time status updates - Script execution API - Token-based authentication - Cross-platform support Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
597 lines
25 KiB
Python
597 lines
25 KiB
Python
"""Windows media controller using WinRT APIs."""
|
|
|
|
import asyncio
|
|
import logging
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from typing import Optional, 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")
|
|
|
|
# Global storage for current album art (as bytes)
|
|
_current_album_art_bytes: bytes | None = None
|
|
|
|
# Global storage for position tracking
|
|
import time as _time
|
|
_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."""
|
|
return _current_album_art_bytes
|
|
|
|
# Windows-specific imports
|
|
try:
|
|
from winsdk.windows.media.control import (
|
|
GlobalSystemMediaTransportControlsSessionManager as MediaManager,
|
|
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
|
|
|
|
try:
|
|
from ctypes import cast, POINTER
|
|
from comtypes import CLSCTX_ALL, CoInitialize, CoUninitialize
|
|
from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
|
|
|
|
def _init_volume_control():
|
|
"""Initialize volume control interface."""
|
|
global _volume_control
|
|
if _volume_control is not None:
|
|
return _volume_control
|
|
try:
|
|
devices = AudioUtilities.GetSpeakers()
|
|
interface = devices.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None)
|
|
_volume_control = cast(interface, POINTER(IAudioEndpointVolume))
|
|
return _volume_control
|
|
except AttributeError:
|
|
# Try accessing the underlying device
|
|
try:
|
|
devices = AudioUtilities.GetSpeakers()
|
|
if hasattr(devices, '_dev'):
|
|
interface = devices._dev.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None)
|
|
_volume_control = cast(interface, POINTER(IAudioEndpointVolume))
|
|
return _volume_control
|
|
except Exception as e:
|
|
logger.debug(f"Volume control init failed: {e}")
|
|
except Exception as e:
|
|
logger.debug(f"Volume control init error: {e}")
|
|
return None
|
|
|
|
PYCAW_AVAILABLE = True
|
|
except ImportError as e:
|
|
logger.warning(f"pycaw not available: {e}")
|
|
|
|
def _init_volume_control():
|
|
return None
|
|
|
|
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,
|
|
"artist": None,
|
|
"album": None,
|
|
"duration": None,
|
|
"position": None,
|
|
"source": None,
|
|
}
|
|
|
|
try:
|
|
# Create a new event loop for this thread
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(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', '')
|
|
|
|
# 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["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} (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
|
|
|
|
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)
|
|
if media_props:
|
|
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()
|
|
|
|
global _current_album_art_bytes
|
|
_current_album_art_bytes = bytes(buffer)
|
|
result["album_art_url"] = "/api/media/artwork"
|
|
except Exception as e:
|
|
logger.debug(f"Failed to get album art: {e}")
|
|
|
|
result["source"] = session.source_app_user_model_id
|
|
|
|
finally:
|
|
loop.close()
|
|
|
|
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)."""
|
|
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())
|
|
|
|
return False
|
|
finally:
|
|
loop.close()
|
|
|
|
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."""
|
|
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
|
|
|
|
position_ticks = int(position * 10_000_000)
|
|
return loop.run_until_complete(
|
|
session.try_change_playback_position_async(position_ticks)
|
|
)
|
|
finally:
|
|
loop.close()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error seeking: {e}")
|
|
return False
|
|
|
|
|
|
class WindowsMediaController(MediaController):
|
|
"""Media controller for Windows using WinRT and pycaw."""
|
|
|
|
def __init__(self):
|
|
if not WINDOWS_AVAILABLE:
|
|
raise RuntimeError(
|
|
"Windows media control requires winsdk, pycaw, and comtypes packages"
|
|
)
|
|
self._volume_interface = None
|
|
self._volume_init_attempted = False
|
|
|
|
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()
|
|
if self._volume_interface:
|
|
logger.info("Volume control initialized successfully")
|
|
else:
|
|
logger.warning("Volume control not available")
|
|
return self._volume_interface
|
|
|
|
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_event_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_event_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 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 = ""
|
|
|
|
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()
|
|
logger.debug(f"Track skip initiated, old title: {old_title}")
|
|
return result
|
|
|
|
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:
|
|
# 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()
|
|
logger.debug(f"Track skip initiated, old title: {old_title}")
|
|
return result
|
|
|
|
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_event_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
|