Initial commit: Media Server for remote media control
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>
This commit is contained in:
75
media_server/services/__init__.py
Normal file
75
media_server/services/__init__.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Media controller services."""
|
||||
|
||||
import os
|
||||
import platform
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .media_controller import MediaController
|
||||
|
||||
_controller_instance: "MediaController | None" = None
|
||||
|
||||
|
||||
def _is_android() -> bool:
|
||||
"""Check if running on Android (e.g., via Termux)."""
|
||||
# Check for Android-specific paths and environment
|
||||
android_indicators = [
|
||||
Path("/system/build.prop").exists(),
|
||||
Path("/data/data/com.termux").exists(),
|
||||
"ANDROID_ROOT" in os.environ,
|
||||
"TERMUX_VERSION" in os.environ,
|
||||
]
|
||||
return any(android_indicators)
|
||||
|
||||
|
||||
def get_media_controller() -> "MediaController":
|
||||
"""Get the platform-specific media controller instance.
|
||||
|
||||
Returns:
|
||||
The media controller for the current platform
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the platform is not supported
|
||||
"""
|
||||
global _controller_instance
|
||||
|
||||
if _controller_instance is not None:
|
||||
return _controller_instance
|
||||
|
||||
system = platform.system()
|
||||
|
||||
if system == "Windows":
|
||||
from .windows_media import WindowsMediaController
|
||||
|
||||
_controller_instance = WindowsMediaController()
|
||||
elif system == "Linux":
|
||||
# Check if running on Android
|
||||
if _is_android():
|
||||
from .android_media import AndroidMediaController
|
||||
|
||||
_controller_instance = AndroidMediaController()
|
||||
else:
|
||||
from .linux_media import LinuxMediaController
|
||||
|
||||
_controller_instance = LinuxMediaController()
|
||||
elif system == "Darwin": # macOS
|
||||
from .macos_media import MacOSMediaController
|
||||
|
||||
_controller_instance = MacOSMediaController()
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported platform: {system}")
|
||||
|
||||
return _controller_instance
|
||||
|
||||
|
||||
def get_current_album_art() -> bytes | None:
|
||||
"""Get the current album art bytes (Windows only for now)."""
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
from .windows_media import get_current_album_art as _get_art
|
||||
return _get_art()
|
||||
return None
|
||||
|
||||
|
||||
__all__ = ["get_media_controller", "get_current_album_art"]
|
||||
232
media_server/services/android_media.py
Normal file
232
media_server/services/android_media.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""Android media controller using Termux:API.
|
||||
|
||||
This controller is designed to run on Android devices using Termux.
|
||||
It requires the Termux:API app and termux-api package to be installed.
|
||||
|
||||
Installation:
|
||||
1. Install Termux from F-Droid (not Play Store)
|
||||
2. Install Termux:API from F-Droid
|
||||
3. In Termux: pkg install termux-api
|
||||
4. Grant necessary permissions to Termux:API
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from typing import Optional, Any
|
||||
|
||||
from ..models import MediaState, MediaStatus
|
||||
from .media_controller import MediaController
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _check_termux_api() -> bool:
|
||||
"""Check if termux-api is available."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["which", "termux-media-player"],
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
TERMUX_API_AVAILABLE = _check_termux_api()
|
||||
|
||||
|
||||
class AndroidMediaController(MediaController):
|
||||
"""Media controller for Android using Termux:API.
|
||||
|
||||
Requires:
|
||||
- Termux app
|
||||
- Termux:API app
|
||||
- termux-api package (pkg install termux-api)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
if not TERMUX_API_AVAILABLE:
|
||||
logger.warning(
|
||||
"Termux:API not available. Install with: pkg install termux-api"
|
||||
)
|
||||
|
||||
def _run_termux_command(
|
||||
self, command: list[str], timeout: int = 10
|
||||
) -> Optional[str]:
|
||||
"""Run a termux-api command and return the output."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
logger.error(f"Termux command failed: {result.stderr}")
|
||||
return None
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(f"Termux command timed out: {command}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Termux command error: {e}")
|
||||
return None
|
||||
|
||||
def _send_media_key(self, key: str) -> bool:
|
||||
"""Send a media key event.
|
||||
|
||||
Args:
|
||||
key: One of: play, pause, play-pause, stop, next, previous
|
||||
"""
|
||||
# termux-media-player command
|
||||
result = self._run_termux_command(["termux-media-player", key])
|
||||
return result is not None
|
||||
|
||||
def _get_media_info(self) -> dict[str, Any]:
|
||||
"""Get current media playback info using termux-media-player."""
|
||||
result = self._run_termux_command(["termux-media-player", "info"])
|
||||
if result:
|
||||
try:
|
||||
return json.loads(result)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def _get_volume(self) -> tuple[int, bool]:
|
||||
"""Get current volume using termux-volume."""
|
||||
result = self._run_termux_command(["termux-volume"])
|
||||
if result:
|
||||
try:
|
||||
volumes = json.loads(result)
|
||||
# Find music stream
|
||||
for stream in volumes:
|
||||
if stream.get("stream") == "music":
|
||||
volume = stream.get("volume", 0)
|
||||
max_volume = stream.get("max_volume", 15)
|
||||
# Convert to 0-100 scale
|
||||
percent = int((volume / max_volume) * 100) if max_volume > 0 else 0
|
||||
return percent, False
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
return 100, False
|
||||
|
||||
def _set_volume_internal(self, volume: int) -> bool:
|
||||
"""Set volume using termux-volume."""
|
||||
# termux-volume expects stream name and volume level
|
||||
# Convert 0-100 to device scale (usually 0-15)
|
||||
result = self._run_termux_command(["termux-volume"])
|
||||
if result:
|
||||
try:
|
||||
volumes = json.loads(result)
|
||||
for stream in volumes:
|
||||
if stream.get("stream") == "music":
|
||||
max_volume = stream.get("max_volume", 15)
|
||||
device_volume = int((volume / 100) * max_volume)
|
||||
self._run_termux_command(
|
||||
["termux-volume", "music", str(device_volume)]
|
||||
)
|
||||
return True
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
return False
|
||||
|
||||
async def get_status(self) -> MediaStatus:
|
||||
"""Get current media playback status."""
|
||||
status = MediaStatus()
|
||||
|
||||
# Get volume
|
||||
volume, muted = self._get_volume()
|
||||
status.volume = volume
|
||||
status.muted = muted
|
||||
|
||||
# Get media info
|
||||
info = self._get_media_info()
|
||||
if not info:
|
||||
status.state = MediaState.IDLE
|
||||
return status
|
||||
|
||||
# Parse playback status
|
||||
playback_status = info.get("status", "").lower()
|
||||
if playback_status == "playing":
|
||||
status.state = MediaState.PLAYING
|
||||
elif playback_status == "paused":
|
||||
status.state = MediaState.PAUSED
|
||||
elif playback_status == "stopped":
|
||||
status.state = MediaState.STOPPED
|
||||
else:
|
||||
status.state = MediaState.IDLE
|
||||
|
||||
# Parse track info
|
||||
status.title = info.get("title") or info.get("Track") or None
|
||||
status.artist = info.get("artist") or info.get("Artist") or None
|
||||
status.album = info.get("album") or info.get("Album") or None
|
||||
|
||||
# Duration and position (in milliseconds from some sources)
|
||||
duration = info.get("duration", 0)
|
||||
if duration > 1000: # Likely milliseconds
|
||||
duration = duration / 1000
|
||||
status.duration = duration if duration > 0 else None
|
||||
|
||||
position = info.get("position", info.get("current_position", 0))
|
||||
if position > 1000: # Likely milliseconds
|
||||
position = position / 1000
|
||||
status.position = position if position > 0 else None
|
||||
|
||||
status.source = "Android"
|
||||
|
||||
return status
|
||||
|
||||
async def play(self) -> bool:
|
||||
"""Resume playback."""
|
||||
return self._send_media_key("play")
|
||||
|
||||
async def pause(self) -> bool:
|
||||
"""Pause playback."""
|
||||
return self._send_media_key("pause")
|
||||
|
||||
async def stop(self) -> bool:
|
||||
"""Stop playback."""
|
||||
return self._send_media_key("stop")
|
||||
|
||||
async def next_track(self) -> bool:
|
||||
"""Skip to next track."""
|
||||
return self._send_media_key("next")
|
||||
|
||||
async def previous_track(self) -> bool:
|
||||
"""Go to previous track."""
|
||||
return self._send_media_key("previous")
|
||||
|
||||
async def set_volume(self, volume: int) -> bool:
|
||||
"""Set system volume."""
|
||||
return self._set_volume_internal(volume)
|
||||
|
||||
async def toggle_mute(self) -> bool:
|
||||
"""Toggle mute state.
|
||||
|
||||
Note: Android doesn't have a simple mute toggle via termux-api,
|
||||
so we set volume to 0 or restore previous volume.
|
||||
"""
|
||||
volume, _ = self._get_volume()
|
||||
if volume > 0:
|
||||
# Store current volume and mute
|
||||
self._previous_volume = volume
|
||||
self._set_volume_internal(0)
|
||||
return True
|
||||
else:
|
||||
# Restore previous volume
|
||||
prev = getattr(self, "_previous_volume", 50)
|
||||
self._set_volume_internal(prev)
|
||||
return False
|
||||
|
||||
async def seek(self, position: float) -> bool:
|
||||
"""Seek to position in seconds.
|
||||
|
||||
Note: Seek functionality may be limited depending on the media player.
|
||||
"""
|
||||
# termux-media-player doesn't support seek directly
|
||||
# This is a limitation of the API
|
||||
logger.warning("Seek not fully supported on Android via Termux:API")
|
||||
return False
|
||||
295
media_server/services/linux_media.py
Normal file
295
media_server/services/linux_media.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""Linux media controller using MPRIS D-Bus interface."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
from typing import Optional, Any
|
||||
|
||||
from ..models import MediaState, MediaStatus
|
||||
from .media_controller import MediaController
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Linux-specific imports
|
||||
try:
|
||||
import dbus
|
||||
from dbus.mainloop.glib import DBusGMainLoop
|
||||
|
||||
DBUS_AVAILABLE = True
|
||||
except ImportError:
|
||||
DBUS_AVAILABLE = False
|
||||
logger.warning("D-Bus libraries not available")
|
||||
|
||||
|
||||
class LinuxMediaController(MediaController):
|
||||
"""Media controller for Linux using MPRIS D-Bus interface."""
|
||||
|
||||
MPRIS_PATH = "/org/mpris/MediaPlayer2"
|
||||
MPRIS_INTERFACE = "org.mpris.MediaPlayer2.Player"
|
||||
MPRIS_PREFIX = "org.mpris.MediaPlayer2."
|
||||
PROPERTIES_INTERFACE = "org.freedesktop.DBus.Properties"
|
||||
|
||||
def __init__(self):
|
||||
if not DBUS_AVAILABLE:
|
||||
raise RuntimeError(
|
||||
"Linux media control requires dbus-python package. "
|
||||
"Install with: sudo apt-get install python3-dbus"
|
||||
)
|
||||
DBusGMainLoop(set_as_default=True)
|
||||
self._bus = dbus.SessionBus()
|
||||
|
||||
def _get_active_player(self) -> Optional[str]:
|
||||
"""Find an active MPRIS media player on the bus."""
|
||||
try:
|
||||
bus_names = self._bus.list_names()
|
||||
mpris_players = [
|
||||
name for name in bus_names if name.startswith(self.MPRIS_PREFIX)
|
||||
]
|
||||
|
||||
if not mpris_players:
|
||||
return None
|
||||
|
||||
# Prefer players that are currently playing
|
||||
for player in mpris_players:
|
||||
try:
|
||||
proxy = self._bus.get_object(player, self.MPRIS_PATH)
|
||||
props = dbus.Interface(proxy, self.PROPERTIES_INTERFACE)
|
||||
status = props.Get(self.MPRIS_INTERFACE, "PlaybackStatus")
|
||||
if status == "Playing":
|
||||
return player
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Return the first available player
|
||||
return mpris_players[0]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get active player: {e}")
|
||||
return None
|
||||
|
||||
def _get_player_interface(self, player_name: str):
|
||||
"""Get the MPRIS player interface."""
|
||||
proxy = self._bus.get_object(player_name, self.MPRIS_PATH)
|
||||
return dbus.Interface(proxy, self.MPRIS_INTERFACE)
|
||||
|
||||
def _get_properties_interface(self, player_name: str):
|
||||
"""Get the properties interface for a player."""
|
||||
proxy = self._bus.get_object(player_name, self.MPRIS_PATH)
|
||||
return dbus.Interface(proxy, self.PROPERTIES_INTERFACE)
|
||||
|
||||
def _get_property(self, player_name: str, property_name: str) -> Any:
|
||||
"""Get a property from the player."""
|
||||
try:
|
||||
props = self._get_properties_interface(player_name)
|
||||
return props.Get(self.MPRIS_INTERFACE, property_name)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get property {property_name}: {e}")
|
||||
return None
|
||||
|
||||
def _get_volume_pulseaudio(self) -> tuple[int, bool]:
|
||||
"""Get volume using pactl (PulseAudio/PipeWire)."""
|
||||
try:
|
||||
# Get default sink volume
|
||||
result = subprocess.run(
|
||||
["pactl", "get-sink-volume", "@DEFAULT_SINK@"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
# Parse volume from output like "Volume: front-left: 65536 / 100% / 0.00 dB"
|
||||
for part in result.stdout.split("/"):
|
||||
if "%" in part:
|
||||
volume = int(part.strip().rstrip("%"))
|
||||
break
|
||||
else:
|
||||
volume = 100
|
||||
else:
|
||||
volume = 100
|
||||
|
||||
# Get mute status
|
||||
result = subprocess.run(
|
||||
["pactl", "get-sink-mute", "@DEFAULT_SINK@"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
muted = "yes" in result.stdout.lower() if result.returncode == 0 else False
|
||||
|
||||
return volume, muted
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get volume via pactl: {e}")
|
||||
return 100, False
|
||||
|
||||
def _set_volume_pulseaudio(self, volume: int) -> bool:
|
||||
"""Set volume using pactl."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pactl", "set-sink-volume", "@DEFAULT_SINK@", f"{volume}%"],
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set volume: {e}")
|
||||
return False
|
||||
|
||||
def _toggle_mute_pulseaudio(self) -> bool:
|
||||
"""Toggle mute using pactl, returns new mute state."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pactl", "set-sink-mute", "@DEFAULT_SINK@", "toggle"],
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
_, muted = self._get_volume_pulseaudio()
|
||||
return muted
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to toggle mute: {e}")
|
||||
return False
|
||||
|
||||
async def get_status(self) -> MediaStatus:
|
||||
"""Get current media playback status."""
|
||||
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
|
||||
elif playback_status == "Paused":
|
||||
status.state = MediaState.PAUSED
|
||||
elif playback_status == "Stopped":
|
||||
status.state = MediaState.STOPPED
|
||||
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."""
|
||||
player_name = self._get_active_player()
|
||||
if player_name is None:
|
||||
return False
|
||||
try:
|
||||
player = self._get_player_interface(player_name)
|
||||
player.Play()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to play: {e}")
|
||||
return False
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
async def set_volume(self, volume: int) -> bool:
|
||||
"""Set system volume."""
|
||||
return self._set_volume_pulseaudio(volume)
|
||||
|
||||
async def toggle_mute(self) -> bool:
|
||||
"""Toggle mute state."""
|
||||
return self._toggle_mute_pulseaudio()
|
||||
|
||||
async def seek(self, position: float) -> bool:
|
||||
"""Seek to position in seconds."""
|
||||
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),
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to seek: {e}")
|
||||
return False
|
||||
296
media_server/services/macos_media.py
Normal file
296
media_server/services/macos_media.py
Normal file
@@ -0,0 +1,296 @@
|
||||
"""macOS media controller using AppleScript and system commands."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from ..models import MediaState, MediaStatus
|
||||
from .media_controller import MediaController
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MacOSMediaController(MediaController):
|
||||
"""Media controller for macOS using osascript and system commands."""
|
||||
|
||||
def _run_osascript(self, script: str) -> Optional[str]:
|
||||
"""Run an AppleScript and return the output."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["osascript", "-e", script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"osascript error: {e}")
|
||||
return None
|
||||
|
||||
def _get_active_app(self) -> Optional[str]:
|
||||
"""Get the currently active media application."""
|
||||
# Check common media apps in order of preference
|
||||
apps = ["Spotify", "Music", "TV", "VLC", "QuickTime Player"]
|
||||
|
||||
for app in apps:
|
||||
script = f'''
|
||||
tell application "System Events"
|
||||
if exists (processes where name is "{app}") then
|
||||
return "{app}"
|
||||
end if
|
||||
end tell
|
||||
return ""
|
||||
'''
|
||||
result = self._run_osascript(script)
|
||||
if result:
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
def _get_spotify_info(self) -> dict:
|
||||
"""Get playback info from Spotify."""
|
||||
script = '''
|
||||
tell application "Spotify"
|
||||
if player state is playing then
|
||||
set currentState to "playing"
|
||||
else if player state is paused then
|
||||
set currentState to "paused"
|
||||
else
|
||||
set currentState to "stopped"
|
||||
end if
|
||||
|
||||
try
|
||||
set trackName to name of current track
|
||||
set artistName to artist of current track
|
||||
set albumName to album of current track
|
||||
set trackDuration to duration of current track
|
||||
set trackPosition to player position
|
||||
set artUrl to artwork url of current track
|
||||
on error
|
||||
set trackName to ""
|
||||
set artistName to ""
|
||||
set albumName to ""
|
||||
set trackDuration to 0
|
||||
set trackPosition to 0
|
||||
set artUrl to ""
|
||||
end try
|
||||
|
||||
return currentState & "|" & trackName & "|" & artistName & "|" & albumName & "|" & trackDuration & "|" & trackPosition & "|" & artUrl
|
||||
end tell
|
||||
'''
|
||||
result = self._run_osascript(script)
|
||||
if result:
|
||||
parts = result.split("|")
|
||||
if len(parts) >= 7:
|
||||
return {
|
||||
"state": parts[0],
|
||||
"title": parts[1] or None,
|
||||
"artist": parts[2] or None,
|
||||
"album": parts[3] or None,
|
||||
"duration": float(parts[4]) / 1000 if parts[4] else None, # ms to seconds
|
||||
"position": float(parts[5]) if parts[5] else None,
|
||||
"art_url": parts[6] or None,
|
||||
}
|
||||
return {}
|
||||
|
||||
def _get_music_info(self) -> dict:
|
||||
"""Get playback info from Apple Music."""
|
||||
script = '''
|
||||
tell application "Music"
|
||||
if player state is playing then
|
||||
set currentState to "playing"
|
||||
else if player state is paused then
|
||||
set currentState to "paused"
|
||||
else
|
||||
set currentState to "stopped"
|
||||
end if
|
||||
|
||||
try
|
||||
set trackName to name of current track
|
||||
set artistName to artist of current track
|
||||
set albumName to album of current track
|
||||
set trackDuration to duration of current track
|
||||
set trackPosition to player position
|
||||
on error
|
||||
set trackName to ""
|
||||
set artistName to ""
|
||||
set albumName to ""
|
||||
set trackDuration to 0
|
||||
set trackPosition to 0
|
||||
end try
|
||||
|
||||
return currentState & "|" & trackName & "|" & artistName & "|" & albumName & "|" & trackDuration & "|" & trackPosition
|
||||
end tell
|
||||
'''
|
||||
result = self._run_osascript(script)
|
||||
if result:
|
||||
parts = result.split("|")
|
||||
if len(parts) >= 6:
|
||||
return {
|
||||
"state": parts[0],
|
||||
"title": parts[1] or None,
|
||||
"artist": parts[2] or None,
|
||||
"album": parts[3] or None,
|
||||
"duration": float(parts[4]) if parts[4] else None,
|
||||
"position": float(parts[5]) if parts[5] else None,
|
||||
}
|
||||
return {}
|
||||
|
||||
def _get_volume(self) -> tuple[int, bool]:
|
||||
"""Get system volume and mute state."""
|
||||
try:
|
||||
# Get volume level
|
||||
result = self._run_osascript("output volume of (get volume settings)")
|
||||
volume = int(result) if result else 100
|
||||
|
||||
# Get mute state
|
||||
result = self._run_osascript("output muted of (get volume settings)")
|
||||
muted = result == "true"
|
||||
|
||||
return volume, muted
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get volume: {e}")
|
||||
return 100, False
|
||||
|
||||
async def get_status(self) -> MediaStatus:
|
||||
"""Get current media playback status."""
|
||||
status = MediaStatus()
|
||||
|
||||
# Get system volume
|
||||
volume, muted = self._get_volume()
|
||||
status.volume = volume
|
||||
status.muted = muted
|
||||
|
||||
# Try to get info from active media app
|
||||
active_app = self._get_active_app()
|
||||
if active_app is None:
|
||||
status.state = MediaState.IDLE
|
||||
return status
|
||||
|
||||
status.source = active_app
|
||||
|
||||
if active_app == "Spotify":
|
||||
info = self._get_spotify_info()
|
||||
elif active_app == "Music":
|
||||
info = self._get_music_info()
|
||||
else:
|
||||
info = {}
|
||||
|
||||
if info:
|
||||
state = info.get("state", "stopped")
|
||||
if state == "playing":
|
||||
status.state = MediaState.PLAYING
|
||||
elif state == "paused":
|
||||
status.state = MediaState.PAUSED
|
||||
else:
|
||||
status.state = MediaState.STOPPED
|
||||
|
||||
status.title = info.get("title")
|
||||
status.artist = info.get("artist")
|
||||
status.album = info.get("album")
|
||||
status.duration = info.get("duration")
|
||||
status.position = info.get("position")
|
||||
status.album_art_url = info.get("art_url")
|
||||
else:
|
||||
status.state = MediaState.IDLE
|
||||
|
||||
return status
|
||||
|
||||
async def play(self) -> bool:
|
||||
"""Resume playback using media key simulation."""
|
||||
# Use system media key
|
||||
script = '''
|
||||
tell application "System Events"
|
||||
key code 16 using {command down, option down}
|
||||
end tell
|
||||
'''
|
||||
# Fallback: try specific app
|
||||
active_app = self._get_active_app()
|
||||
if active_app == "Spotify":
|
||||
self._run_osascript('tell application "Spotify" to play')
|
||||
return True
|
||||
elif active_app == "Music":
|
||||
self._run_osascript('tell application "Music" to play')
|
||||
return True
|
||||
|
||||
# Use media key simulation
|
||||
result = subprocess.run(
|
||||
["osascript", "-e", 'tell application "System Events" to key code 49'],
|
||||
capture_output=True,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
async def pause(self) -> bool:
|
||||
"""Pause playback."""
|
||||
active_app = self._get_active_app()
|
||||
if active_app == "Spotify":
|
||||
self._run_osascript('tell application "Spotify" to pause')
|
||||
return True
|
||||
elif active_app == "Music":
|
||||
self._run_osascript('tell application "Music" to pause')
|
||||
return True
|
||||
return False
|
||||
|
||||
async def stop(self) -> bool:
|
||||
"""Stop playback."""
|
||||
active_app = self._get_active_app()
|
||||
if active_app == "Spotify":
|
||||
self._run_osascript('tell application "Spotify" to pause')
|
||||
return True
|
||||
elif active_app == "Music":
|
||||
self._run_osascript('tell application "Music" to stop')
|
||||
return True
|
||||
return False
|
||||
|
||||
async def next_track(self) -> bool:
|
||||
"""Skip to next track."""
|
||||
active_app = self._get_active_app()
|
||||
if active_app == "Spotify":
|
||||
self._run_osascript('tell application "Spotify" to next track')
|
||||
return True
|
||||
elif active_app == "Music":
|
||||
self._run_osascript('tell application "Music" to next track')
|
||||
return True
|
||||
return False
|
||||
|
||||
async def previous_track(self) -> bool:
|
||||
"""Go to previous track."""
|
||||
active_app = self._get_active_app()
|
||||
if active_app == "Spotify":
|
||||
self._run_osascript('tell application "Spotify" to previous track')
|
||||
return True
|
||||
elif active_app == "Music":
|
||||
self._run_osascript('tell application "Music" to previous track')
|
||||
return True
|
||||
return False
|
||||
|
||||
async def set_volume(self, volume: int) -> bool:
|
||||
"""Set system volume."""
|
||||
result = self._run_osascript(f"set volume output volume {volume}")
|
||||
return result is not None or True # osascript returns empty on success
|
||||
|
||||
async def toggle_mute(self) -> bool:
|
||||
"""Toggle mute state."""
|
||||
_, current_mute = self._get_volume()
|
||||
new_mute = not current_mute
|
||||
self._run_osascript(f"set volume output muted {str(new_mute).lower()}")
|
||||
return new_mute
|
||||
|
||||
async def seek(self, position: float) -> bool:
|
||||
"""Seek to position in seconds."""
|
||||
active_app = self._get_active_app()
|
||||
if active_app == "Spotify":
|
||||
self._run_osascript(
|
||||
f'tell application "Spotify" to set player position to {position}'
|
||||
)
|
||||
return True
|
||||
elif active_app == "Music":
|
||||
self._run_osascript(
|
||||
f'tell application "Music" to set player position to {position}'
|
||||
)
|
||||
return True
|
||||
return False
|
||||
96
media_server/services/media_controller.py
Normal file
96
media_server/services/media_controller.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Abstract base class for media controllers."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from ..models import MediaStatus
|
||||
|
||||
|
||||
class MediaController(ABC):
|
||||
"""Abstract base class for platform-specific media controllers."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_status(self) -> MediaStatus:
|
||||
"""Get the current media playback status.
|
||||
|
||||
Returns:
|
||||
MediaStatus with current playback info
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def play(self) -> bool:
|
||||
"""Resume or start playback.
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def pause(self) -> bool:
|
||||
"""Pause playback.
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def stop(self) -> bool:
|
||||
"""Stop playback.
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def next_track(self) -> bool:
|
||||
"""Skip to the next track.
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def previous_track(self) -> bool:
|
||||
"""Go to the previous track.
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def set_volume(self, volume: int) -> bool:
|
||||
"""Set the system volume.
|
||||
|
||||
Args:
|
||||
volume: Volume level (0-100)
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def toggle_mute(self) -> bool:
|
||||
"""Toggle the mute state.
|
||||
|
||||
Returns:
|
||||
The new mute state (True = muted)
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def seek(self, position: float) -> bool:
|
||||
"""Seek to a position in the current track.
|
||||
|
||||
Args:
|
||||
position: Position in seconds
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
pass
|
||||
189
media_server/services/websocket_manager.py
Normal file
189
media_server/services/websocket_manager.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""WebSocket connection manager and status broadcaster."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Callable, Coroutine
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""Manages WebSocket connections and broadcasts status updates."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the connection manager."""
|
||||
self._active_connections: set[WebSocket] = set()
|
||||
self._lock = asyncio.Lock()
|
||||
self._last_status: dict[str, Any] | None = None
|
||||
self._broadcast_task: asyncio.Task | None = None
|
||||
self._poll_interval: float = 0.5 # Internal poll interval for change detection
|
||||
self._position_broadcast_interval: float = 5.0 # Send position updates every 5s during playback
|
||||
self._last_broadcast_time: float = 0.0
|
||||
self._running: bool = False
|
||||
|
||||
async def connect(self, websocket: WebSocket) -> None:
|
||||
"""Accept a new WebSocket connection."""
|
||||
await websocket.accept()
|
||||
async with self._lock:
|
||||
self._active_connections.add(websocket)
|
||||
logger.info(
|
||||
"WebSocket client connected. Total: %d", len(self._active_connections)
|
||||
)
|
||||
|
||||
# Send current status immediately upon connection
|
||||
if self._last_status:
|
||||
try:
|
||||
await websocket.send_json({"type": "status", "data": self._last_status})
|
||||
except Exception as e:
|
||||
logger.debug("Failed to send initial status: %s", e)
|
||||
|
||||
async def disconnect(self, websocket: WebSocket) -> None:
|
||||
"""Remove a WebSocket connection."""
|
||||
async with self._lock:
|
||||
self._active_connections.discard(websocket)
|
||||
logger.info(
|
||||
"WebSocket client disconnected. Total: %d", len(self._active_connections)
|
||||
)
|
||||
|
||||
async def broadcast(self, message: dict[str, Any]) -> None:
|
||||
"""Broadcast a message to all connected clients."""
|
||||
async with self._lock:
|
||||
connections = list(self._active_connections)
|
||||
|
||||
if not connections:
|
||||
return
|
||||
|
||||
disconnected = []
|
||||
for websocket in connections:
|
||||
try:
|
||||
await websocket.send_json(message)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to send to client: %s", e)
|
||||
disconnected.append(websocket)
|
||||
|
||||
# Clean up disconnected clients
|
||||
for ws in disconnected:
|
||||
await self.disconnect(ws)
|
||||
|
||||
def status_changed(
|
||||
self, old: dict[str, Any] | None, new: dict[str, Any]
|
||||
) -> bool:
|
||||
"""Detect if media status has meaningfully changed.
|
||||
|
||||
Position is NOT included for normal playback (let HA interpolate).
|
||||
But seeks (large unexpected jumps) are detected.
|
||||
"""
|
||||
if old is None:
|
||||
return True
|
||||
|
||||
# Fields to compare for changes (NO position - let HA interpolate)
|
||||
significant_fields = [
|
||||
"state",
|
||||
"title",
|
||||
"artist",
|
||||
"album",
|
||||
"volume",
|
||||
"muted",
|
||||
"duration",
|
||||
"source",
|
||||
"album_art_url",
|
||||
]
|
||||
|
||||
for field in significant_fields:
|
||||
if old.get(field) != new.get(field):
|
||||
return True
|
||||
|
||||
# Detect seeks - large position jumps that aren't normal playback
|
||||
old_pos = old.get("position") or 0
|
||||
new_pos = new.get("position") or 0
|
||||
pos_diff = new_pos - old_pos
|
||||
|
||||
# During playback, position should increase by ~0.5s (our poll interval)
|
||||
# A seek is when position jumps backwards OR forward by more than expected
|
||||
if new.get("state") == "playing":
|
||||
# Backward seek or forward jump > 3s indicates seek
|
||||
if pos_diff < -1.0 or pos_diff > 3.0:
|
||||
return True
|
||||
else:
|
||||
# When paused, any significant position change is a seek
|
||||
if abs(pos_diff) > 1.0:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def start_status_monitor(
|
||||
self,
|
||||
get_status_func: Callable[[], Coroutine[Any, Any, Any]],
|
||||
) -> None:
|
||||
"""Start the background status monitoring loop."""
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._broadcast_task = asyncio.create_task(
|
||||
self._status_monitor_loop(get_status_func)
|
||||
)
|
||||
logger.info("WebSocket status monitor started")
|
||||
|
||||
async def stop_status_monitor(self) -> None:
|
||||
"""Stop the background status monitoring loop."""
|
||||
self._running = False
|
||||
if self._broadcast_task:
|
||||
self._broadcast_task.cancel()
|
||||
try:
|
||||
await self._broadcast_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.info("WebSocket status monitor stopped")
|
||||
|
||||
async def _status_monitor_loop(
|
||||
self,
|
||||
get_status_func: Callable[[], Coroutine[Any, Any, Any]],
|
||||
) -> None:
|
||||
"""Background loop that polls for status changes and broadcasts."""
|
||||
while self._running:
|
||||
try:
|
||||
# Only poll if we have connected clients
|
||||
async with self._lock:
|
||||
has_clients = len(self._active_connections) > 0
|
||||
|
||||
if has_clients:
|
||||
status = await get_status_func()
|
||||
status_dict = status.model_dump()
|
||||
|
||||
# Only broadcast on actual state changes
|
||||
# Let HA handle position interpolation during playback
|
||||
if self.status_changed(self._last_status, status_dict):
|
||||
self._last_status = status_dict
|
||||
self._last_broadcast_time = time.time()
|
||||
await self.broadcast(
|
||||
{"type": "status_update", "data": status_dict}
|
||||
)
|
||||
logger.debug("Broadcast sent: status change")
|
||||
else:
|
||||
# Update cached status even without broadcast
|
||||
self._last_status = status_dict
|
||||
else:
|
||||
# Still update cache for when clients connect
|
||||
status = await get_status_func()
|
||||
self._last_status = status.model_dump()
|
||||
|
||||
await asyncio.sleep(self._poll_interval)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error in status monitor: %s", e)
|
||||
await asyncio.sleep(self._poll_interval)
|
||||
|
||||
@property
|
||||
def client_count(self) -> int:
|
||||
"""Return the number of connected clients."""
|
||||
return len(self._active_connections)
|
||||
|
||||
|
||||
# Global instance
|
||||
ws_manager = ConnectionManager()
|
||||
596
media_server/services/windows_media.py
Normal file
596
media_server/services/windows_media.py
Normal file
@@ -0,0 +1,596 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user