commit 67a89e8349c97ca9024935808009121b64c3a331 Author: alexei.dolgolyov Date: Wed Feb 4 13:08:40 2026 +0300 Initial commit: Media server and Home Assistant integration - FastAPI server for Windows media control via WinRT/SMTC - Home Assistant custom integration with media player entity - Script button entities for system commands - Position tracking with grace period for track skip handling - Server availability detection in HA entity Co-Authored-By: Claude Opus 4.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e9a10d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +ENV/ +env/ +.venv/ + +# IDE +.idea/ +.vscode/ +.claude/ +*.swp +*.swo +*~ + +# Config files with secrets +config.yaml +config.json +.env + +# Logs +*.log +logs/ + +# OS +.DS_Store +Thumbs.db diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f8c20c0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,50 @@ +# Media Server for Home Assistant + +## Project Overview + +A server-client system to control PC media playback from Home Assistant. + +- **Server**: FastAPI REST API on Windows controlling system-wide media via WinRT +- **Client**: Home Assistant custom integration exposing a media player entity + +## Running the Server + +### Manual Start + +```bash +cd c:\Users\Alexei\Documents\haos-integration-media-player +python -m media_server.main +``` + +### Auto-Start on Boot (Windows Task Scheduler) + +Run in **Administrator PowerShell** from the project root: + +```powershell +.\media_server\service\install_task_windows.ps1 +``` + +To remove the scheduled task: + +```powershell +Unregister-ScheduledTask -TaskName "MediaServer" -Confirm:$false +``` + +## Home Assistant Integration + +Copy `custom_components/remote_media_player/` to your Home Assistant config folder. + +Integration files location: `U:\custom_components\remote_media_player` + +## API Token + +The API token is generated on first run and displayed in the console output. +Configure the same token in Home Assistant integration settings. + +## Server Port + +Default: `8765` + +## Git Rules + +Always ask for user approval before committing changes to git. diff --git a/README.md b/README.md new file mode 100644 index 0000000..06ce0c5 --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# Remote Media Player for Home Assistant + +Control your PC's media playback from Home Assistant. + +## Components + +| Component | Description | Documentation | +|-----------|-------------|---------------| +| [Media Server](media_server/) | REST API server for your PC | [README](media_server/README.md) | +| [HAOS Integration](custom_components/remote_media_player/) | Home Assistant custom component | [README](custom_components/remote_media_player/README.md) | + +## Overview + +``` +┌─────────────────────┐ HTTP/REST ┌─────────────────────┐ +│ Home Assistant │◄─────────────────────────►│ Your PC │ +│ │ (Token Auth) │ │ +│ ┌───────────────┐ │ │ ┌───────────────┐ │ +│ │ Media Player │ │ │ │ Media Server │ │ +│ │ Entity │ │ │ │ (FastAPI) │ │ +│ └───────────────┘ │ │ └───────┬───────┘ │ +│ │ │ │ │ +└─────────────────────┘ │ ┌───────▼───────┐ │ + │ │ Media Control │ │ + │ │ - Windows │ │ + │ │ - Linux │ │ + │ │ - macOS │ │ + │ │ - Android │ │ + │ └───────────────┘ │ + └─────────────────────┘ +``` + +## Features + +- Play/Pause/Stop media +- Next/Previous track +- Volume control and mute +- Seek within tracks +- Display current track info (title, artist, album, artwork) +- Secure token-based authentication + +## Supported Platforms + +| Platform | Media Control | Volume Control | Status | +|----------|---------------|----------------|--------| +| Windows | WinRT Media Transport | pycaw | Fully tested | +| Linux | MPRIS D-Bus | PulseAudio/PipeWire | Not tested | +| macOS | AppleScript | System volume | Not tested | +| Android | Termux:API | Termux volume | Not tested | + +> **Note:** Windows is the primary supported platform. Linux, macOS, and Android implementations exist but have not been thoroughly tested and may have limited functionality. + +## Quick Start + +### 1. Set up the Server (on your PC) + +```bash +cd media_server +pip install -r requirements.txt +python -m media_server.main --generate-config +python -m media_server.main +``` + +See [Media Server README](media_server/README.md) for detailed instructions. + +### 2. Set up Home Assistant Integration + +1. Copy `custom_components/remote_media_player/` to your HA config +2. Restart Home Assistant +3. Add integration via UI with your server's IP and token + +See [Integration README](custom_components/remote_media_player/README.md) for detailed instructions. + +## Project Structure + +``` +haos-integration-media-player/ +├── media_server/ # Server component +│ ├── main.py # Entry point +│ ├── routes/ # API endpoints +│ ├── services/ # Platform media controllers +│ └── service/ # Service installers +│ +├── custom_components/ +│ └── remote_media_player/ # HAOS Integration +│ ├── media_player.py # Media player entity +│ └── config_flow.py # UI configuration +│ +└── README.md +``` + +## License + +MIT License diff --git a/custom_components/remote_media_player/README.md b/custom_components/remote_media_player/README.md new file mode 100644 index 0000000..ccbd78b --- /dev/null +++ b/custom_components/remote_media_player/README.md @@ -0,0 +1,324 @@ +# Remote Media Player - Home Assistant Integration + +A Home Assistant custom component that allows you to control a remote PC's media playback as a media player entity. + +## Features + +- Full media player controls (play, pause, stop, next, previous) +- Volume control and mute +- Seek support +- Displays current track info (title, artist, album, artwork) +- **Script buttons** - Execute pre-defined scripts (shutdown, restart, lock, sleep, etc.) +- Configurable via Home Assistant UI +- Polling with adjustable interval + +## Requirements + +- Home Assistant 2023.1 or newer +- A running [Media Server](../../media_server/README.md) on your PC + +## Installation + +### HACS (Recommended) + +1. Open HACS in Home Assistant +2. Click the three dots menu > Custom repositories +3. Add this repository URL and select "Integration" +4. Search for "Remote Media Player" and install +5. Restart Home Assistant + +### Manual Installation + +1. Copy the `remote_media_player` folder to your Home Assistant `config/custom_components/` directory: + ``` + config/ + └── custom_components/ + └── remote_media_player/ + ├── __init__.py + ├── api_client.py + ├── button.py + ├── config_flow.py + ├── const.py + ├── manifest.json + ├── media_player.py + ├── services.yaml + ├── strings.json + └── translations/ + └── en.json + ``` + +2. Restart Home Assistant + +## Configuration + +### Via UI (Recommended) + +1. Go to **Settings** > **Devices & Services** +2. Click **+ Add Integration** +3. Search for "Remote Media Player" +4. Enter the connection details: + - **Host**: IP address or hostname of your PC running Media Server + - **Port**: Server port (default: 8765) + - **API Token**: The authentication token from your server + - **Name**: Display name for this media player (optional) + - **Poll Interval**: How often to update status (default: 5 seconds) + +### Finding Your API Token + +On the PC running Media Server: +```bash +python -m media_server.main --show-token +``` + +Or check the config file: +- Windows: `%APPDATA%\media-server\config.yaml` +- Linux/macOS: `~/.config/media-server/config.yaml` + +## Usage + +Once configured, the integration creates a media player entity that you can: + +### Control via UI +- Use the media player card in Lovelace +- Control from the entity's detail page + +### Control via Services +```yaml +# Play +service: media_player.media_play +target: + entity_id: media_player.remote_media_player + +# Pause +service: media_player.media_pause +target: + entity_id: media_player.remote_media_player + +# Set volume (0.0 - 1.0) +service: media_player.volume_set +target: + entity_id: media_player.remote_media_player +data: + volume_level: 0.5 + +# Mute +service: media_player.volume_mute +target: + entity_id: media_player.remote_media_player +data: + is_volume_muted: true + +# Next/Previous track +service: media_player.media_next_track +target: + entity_id: media_player.remote_media_player + +# Seek to position (seconds) +service: media_player.media_seek +target: + entity_id: media_player.remote_media_player +data: + seek_position: 60 +``` + +### Automations + +Example: Pause PC media when leaving home +```yaml +automation: + - alias: "Pause PC media when leaving" + trigger: + - platform: state + entity_id: person.your_name + from: "home" + action: + - service: media_player.media_pause + target: + entity_id: media_player.remote_media_player +``` + +Example: Lower PC volume during quiet hours +```yaml +automation: + - alias: "Lower PC volume at night" + trigger: + - platform: time + at: "22:00:00" + action: + - service: media_player.volume_set + target: + entity_id: media_player.remote_media_player + data: + volume_level: 0.3 +``` + +## Script Buttons + +The integration automatically creates **button entities** for each script defined on your Media Server. These buttons allow you to: + +- Lock/unlock the workstation +- Shutdown, restart, or put the PC to sleep +- Hibernate the PC +- Execute custom commands + +### Available Buttons + +After setup, you'll see button entities like: +- `button.remote_media_player_lock_screen` +- `button.remote_media_player_shutdown` +- `button.remote_media_player_restart` +- `button.remote_media_player_sleep` +- `button.remote_media_player_hibernate` + +### Adding Scripts + +Scripts are configured on the Media Server in `config.yaml`: + +```yaml +scripts: + lock_screen: + command: "rundll32.exe user32.dll,LockWorkStation" + label: "Lock Screen" + description: "Lock the workstation" + timeout: 5 + shell: true +``` + +After adding scripts, restart the Media Server and reload the integration in Home Assistant. + +### Using Script Buttons + +#### Via UI +Add button entities to your dashboard using a button card or entities card. + +#### Via Automation +```yaml +automation: + - alias: "Lock PC when leaving home" + trigger: + - platform: state + entity_id: person.your_name + from: "home" + action: + - service: button.press + target: + entity_id: button.remote_media_player_lock_screen +``` + +### Execute Script Service + +You can also execute scripts with arguments using the service: + +```yaml +service: remote_media_player.execute_script +data: + script_name: "echo_test" + args: + - "arg1" + - "arg2" +``` + +## Lovelace Card Examples + +### Basic Media Control Card +```yaml +type: media-control +entity: media_player.remote_media_player +``` + +### Mini Media Player (requires custom card) +```yaml +type: custom:mini-media-player +entity: media_player.remote_media_player +artwork: cover +source: icon +``` + +### Entities Card +```yaml +type: entities +entities: + - entity: media_player.remote_media_player + type: custom:slider-entity-row + full_row: true +``` + +## Entity Attributes + +The media player entity exposes these attributes: + +| Attribute | Description | +|-----------|-------------| +| `media_title` | Current track title | +| `media_artist` | Current artist | +| `media_album_name` | Current album | +| `media_duration` | Track duration in seconds | +| `media_position` | Current position in seconds | +| `volume_level` | Volume (0.0 - 1.0) | +| `is_volume_muted` | Mute state | +| `source` | Media source/player name | + +## Options + +After initial setup, you can adjust options: + +1. Go to **Settings** > **Devices & Services** +2. Find "Remote Media Player" and click **Configure** +3. Adjust the poll interval as needed + +Lower poll intervals = more responsive but more network traffic. + +## Troubleshooting + +### Integration not found +- Restart Home Assistant after installing +- Check that all files are in the correct location +- Check Home Assistant logs for errors + +### Cannot connect to server +- Verify the server is running: `curl http://YOUR_PC_IP:8765/api/health` +- Check firewall settings on the PC +- Ensure the IP address is correct + +### Invalid token error +- Double-check the token matches exactly +- Regenerate token if needed: `python -m media_server.main --generate-config` + +### Entity shows unavailable +- Check server is running +- Check network connectivity +- Review Home Assistant logs for connection errors + +### Media controls don't work +- Ensure media is playing on the PC +- Check server logs for errors +- Verify the media player supports the requested action + +## Multiple PCs + +You can add multiple Media Server instances: + +1. Run Media Server on each PC (use different tokens) +2. Add the integration multiple times in Home Assistant +3. Give each a unique name + +## Supported Features + +| Feature | Supported | +|---------|-----------| +| Play | Yes | +| Pause | Yes | +| Stop | Yes | +| Next Track | Yes | +| Previous Track | Yes | +| Volume Set | Yes | +| Volume Mute | Yes | +| Seek | Yes | +| Script Buttons | Yes | +| Browse Media | No | +| Play Media | No | +| Shuffle/Repeat | No | + +## License + +MIT License diff --git a/custom_components/remote_media_player/__init__.py b/custom_components/remote_media_player/__init__.py new file mode 100644 index 0000000..ab976cf --- /dev/null +++ b/custom_components/remote_media_player/__init__.py @@ -0,0 +1,158 @@ +"""The Remote Media Player integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv + +from .api_client import MediaServerClient, MediaServerError +from .const import ( + ATTR_SCRIPT_ARGS, + ATTR_SCRIPT_NAME, + CONF_HOST, + CONF_PORT, + CONF_TOKEN, + DOMAIN, + SERVICE_EXECUTE_SCRIPT, +) + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.BUTTON] + +# Service schema for execute_script +SERVICE_EXECUTE_SCRIPT_SCHEMA = vol.Schema( + { + vol.Required(ATTR_SCRIPT_NAME): cv.string, + vol.Optional(ATTR_SCRIPT_ARGS, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + } +) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Remote Media Player from a config entry. + + Args: + hass: Home Assistant instance + entry: Config entry + + Returns: + True if setup was successful + """ + _LOGGER.debug("Setting up Remote Media Player: %s", entry.entry_id) + + # Create API client + client = MediaServerClient( + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + token=entry.data[CONF_TOKEN], + ) + + # Verify connection + if not await client.check_connection(): + _LOGGER.error("Failed to connect to Media Server") + await client.close() + return False + + # Store client in hass.data + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = { + "client": client, + } + + # Register services if not already registered + if not hass.services.has_service(DOMAIN, SERVICE_EXECUTE_SCRIPT): + async def async_execute_script(call: ServiceCall) -> dict[str, Any]: + """Execute a script on the media server.""" + script_name = call.data[ATTR_SCRIPT_NAME] + script_args = call.data.get(ATTR_SCRIPT_ARGS, []) + + _LOGGER.debug( + "Executing script '%s' with args: %s", script_name, script_args + ) + + # Get all clients and execute on all of them + results = {} + for entry_id, data in hass.data[DOMAIN].items(): + client: MediaServerClient = data["client"] + try: + result = await client.execute_script(script_name, script_args) + results[entry_id] = result + _LOGGER.info( + "Script '%s' executed on %s: success=%s", + script_name, + entry_id, + result.get("success", False), + ) + except MediaServerError as err: + _LOGGER.error( + "Failed to execute script '%s' on %s: %s", + script_name, + entry_id, + err, + ) + results[entry_id] = {"success": False, "error": str(err)} + + return results + + hass.services.async_register( + DOMAIN, + SERVICE_EXECUTE_SCRIPT, + async_execute_script, + schema=SERVICE_EXECUTE_SCRIPT_SCHEMA, + ) + + # Forward setup to platforms + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + # Register update listener for options + entry.async_on_unload(entry.add_update_listener(async_update_options)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry. + + Args: + hass: Home Assistant instance + entry: Config entry + + Returns: + True if unload was successful + """ + _LOGGER.debug("Unloading Remote Media Player: %s", entry.entry_id) + + # Unload platforms + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + # Close client and remove data + data = hass.data[DOMAIN].pop(entry.entry_id) + await data["client"].close() + + # Remove services if this was the last entry + if not hass.data[DOMAIN]: + hass.services.async_remove(DOMAIN, SERVICE_EXECUTE_SCRIPT) + + return unload_ok + + +async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update. + + Args: + hass: Home Assistant instance + entry: Config entry + """ + _LOGGER.debug("Options updated for: %s", entry.entry_id) + await hass.config_entries.async_reload(entry.entry_id) diff --git a/custom_components/remote_media_player/api_client.py b/custom_components/remote_media_player/api_client.py new file mode 100644 index 0000000..812ed0e --- /dev/null +++ b/custom_components/remote_media_player/api_client.py @@ -0,0 +1,267 @@ +"""API client for communicating with the Media Server.""" + +from __future__ import annotations + +import logging +from typing import Any + +import aiohttp +from aiohttp import ClientError, ClientResponseError + +from .const import ( + API_HEALTH, + API_STATUS, + API_PLAY, + API_PAUSE, + API_STOP, + API_NEXT, + API_PREVIOUS, + API_VOLUME, + API_MUTE, + API_SEEK, + API_SCRIPTS_LIST, + API_SCRIPTS_EXECUTE, +) + +_LOGGER = logging.getLogger(__name__) + + +class MediaServerError(Exception): + """Base exception for Media Server errors.""" + + +class MediaServerConnectionError(MediaServerError): + """Exception for connection errors.""" + + +class MediaServerAuthError(MediaServerError): + """Exception for authentication errors.""" + + +class MediaServerClient: + """Client for the Media Server REST API.""" + + def __init__( + self, + host: str, + port: int, + token: str, + session: aiohttp.ClientSession | None = None, + ) -> None: + """Initialize the client. + + Args: + host: Server hostname or IP address + port: Server port + token: API authentication token + session: Optional aiohttp session (will create one if not provided) + """ + self._host = host + self._port = int(port) # Ensure port is an integer + self._token = token + self._session = session + self._own_session = session is None + self._base_url = f"http://{host}:{self._port}" + + async def _ensure_session(self) -> aiohttp.ClientSession: + """Ensure we have an aiohttp session.""" + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession() + self._own_session = True + return self._session + + async def close(self) -> None: + """Close the client session.""" + if self._own_session and self._session and not self._session.closed: + await self._session.close() + + def _get_headers(self) -> dict[str, str]: + """Get headers for API requests.""" + return { + "Authorization": f"Bearer {self._token}", + "Content-Type": "application/json", + } + + async def _request( + self, + method: str, + endpoint: str, + json_data: dict | None = None, + auth_required: bool = True, + ) -> dict[str, Any]: + """Make an API request. + + Args: + method: HTTP method (GET, POST, etc.) + endpoint: API endpoint path + json_data: Optional JSON body data + auth_required: Whether to include authentication header + + Returns: + Response data as dictionary + + Raises: + MediaServerConnectionError: On connection errors + MediaServerAuthError: On authentication errors + MediaServerError: On other errors + """ + session = await self._ensure_session() + url = f"{self._base_url}{endpoint}" + headers = self._get_headers() if auth_required else {} + + try: + timeout = aiohttp.ClientTimeout(total=10) + async with session.request( + method, url, headers=headers, json=json_data, timeout=timeout + ) as response: + if response.status == 401: + raise MediaServerAuthError("Invalid API token") + if response.status == 403: + raise MediaServerAuthError("Access forbidden") + + response.raise_for_status() + return await response.json() + + except aiohttp.ClientConnectorError as err: + raise MediaServerConnectionError( + f"Cannot connect to server at {self._base_url}: {err}" + ) from err + except ClientResponseError as err: + raise MediaServerError(f"API error: {err.status} {err.message}") from err + except ClientError as err: + raise MediaServerConnectionError(f"Connection error: {err}") from err + + async def check_connection(self) -> bool: + """Check if the server is reachable and token is valid. + + Returns: + True if connection is successful + """ + try: + # First check health (no auth) + await self._request("GET", API_HEALTH, auth_required=False) + # Then check auth by getting status + await self._request("GET", API_STATUS) + return True + except MediaServerError: + return False + + async def get_health(self) -> dict[str, Any]: + """Get server health status (no authentication required). + + Returns: + Health status data + """ + return await self._request("GET", API_HEALTH, auth_required=False) + + async def get_status(self) -> dict[str, Any]: + """Get current media playback status. + + Returns: + Media status data including state, title, artist, volume, etc. + """ + data = await self._request("GET", API_STATUS) + + # Convert relative album_art_url to absolute URL with token and cache-buster + if data.get("album_art_url") and data["album_art_url"].startswith("/"): + # Add track info hash to force HA to re-fetch when track changes + import hashlib + track_id = f"{data.get('title', '')}-{data.get('artist', '')}" + track_hash = hashlib.md5(track_id.encode()).hexdigest()[:8] + data["album_art_url"] = f"{self._base_url}{data['album_art_url']}?token={self._token}&t={track_hash}" + + return data + + async def play(self) -> dict[str, Any]: + """Resume or start playback. + + Returns: + Response data + """ + return await self._request("POST", API_PLAY) + + async def pause(self) -> dict[str, Any]: + """Pause playback. + + Returns: + Response data + """ + return await self._request("POST", API_PAUSE) + + async def stop(self) -> dict[str, Any]: + """Stop playback. + + Returns: + Response data + """ + return await self._request("POST", API_STOP) + + async def next_track(self) -> dict[str, Any]: + """Skip to next track. + + Returns: + Response data + """ + return await self._request("POST", API_NEXT) + + async def previous_track(self) -> dict[str, Any]: + """Go to previous track. + + Returns: + Response data + """ + return await self._request("POST", API_PREVIOUS) + + async def set_volume(self, volume: int) -> dict[str, Any]: + """Set the volume level. + + Args: + volume: Volume level (0-100) + + Returns: + Response data + """ + return await self._request("POST", API_VOLUME, {"volume": volume}) + + async def toggle_mute(self) -> dict[str, Any]: + """Toggle mute state. + + Returns: + Response data with new mute state + """ + return await self._request("POST", API_MUTE) + + async def seek(self, position: float) -> dict[str, Any]: + """Seek to a position in the current track. + + Args: + position: Position in seconds + + Returns: + Response data + """ + return await self._request("POST", API_SEEK, {"position": position}) + + async def list_scripts(self) -> list[dict[str, Any]]: + """List available scripts on the server. + + Returns: + List of scripts with name, description, and timeout + """ + return await self._request("GET", API_SCRIPTS_LIST) + + async def execute_script( + self, script_name: str, args: list[str] | None = None + ) -> dict[str, Any]: + """Execute a script on the server. + + Args: + script_name: Name of the script to execute + args: Optional list of arguments to pass to the script + + Returns: + Execution result with success, exit_code, stdout, stderr + """ + endpoint = f"{API_SCRIPTS_EXECUTE}/{script_name}" + json_data = {"args": args or []} + return await self._request("POST", endpoint, json_data) diff --git a/custom_components/remote_media_player/button.py b/custom_components/remote_media_player/button.py new file mode 100644 index 0000000..9cc42be --- /dev/null +++ b/custom_components/remote_media_player/button.py @@ -0,0 +1,131 @@ +"""Button platform for Remote Media Player integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .api_client import MediaServerClient, MediaServerError +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up script buttons from a config entry.""" + client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"] + + try: + scripts = await client.list_scripts() + except MediaServerError as err: + _LOGGER.error("Failed to fetch scripts list: %s", err) + return + + entities = [ + ScriptButtonEntity( + client=client, + entry=entry, + script_name=script["name"], + script_label=script["label"], + script_description=script.get("description", ""), + ) + for script in scripts + ] + + if entities: + async_add_entities(entities) + _LOGGER.info("Added %d script button entities", len(entities)) + + +class ScriptButtonEntity(ButtonEntity): + """Button entity for executing a script on the media server.""" + + _attr_has_entity_name = True + + def __init__( + self, + client: MediaServerClient, + entry: ConfigEntry, + script_name: str, + script_label: str, + script_description: str, + ) -> None: + """Initialize the script button.""" + self._client = client + self._entry = entry + self._script_name = script_name + self._script_label = script_label + self._script_description = script_description + + # Entity attributes + self._attr_unique_id = f"{entry.entry_id}_script_{script_name}" + self._attr_name = script_label + self._attr_icon = self._get_icon_for_script(script_name) + + def _get_icon_for_script(self, script_name: str) -> str: + """Get an appropriate icon based on script name.""" + icon_map = { + "lock": "mdi:lock", + "unlock": "mdi:lock-open", + "shutdown": "mdi:power", + "restart": "mdi:restart", + "sleep": "mdi:sleep", + "hibernate": "mdi:power-sleep", + "cancel": "mdi:cancel", + } + + script_lower = script_name.lower() + for keyword, icon in icon_map.items(): + if keyword in script_lower: + return icon + + return "mdi:script-text" + + @property + def device_info(self) -> DeviceInfo: + """Return device info.""" + return DeviceInfo( + identifiers={(DOMAIN, self._entry.entry_id)}, + name=self._entry.title, + manufacturer="Remote Media Player", + model="Media Server", + ) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return extra state attributes.""" + return { + "script_name": self._script_name, + "description": self._script_description, + } + + async def async_press(self) -> None: + """Handle button press - execute the script.""" + _LOGGER.info("Executing script: %s", self._script_name) + try: + result = await self._client.execute_script(self._script_name) + if result.get("success"): + _LOGGER.info( + "Script '%s' executed successfully (exit_code=%s)", + self._script_name, + result.get("exit_code"), + ) + else: + _LOGGER.warning( + "Script '%s' failed: %s", + self._script_name, + result.get("stderr") or result.get("error"), + ) + except MediaServerError as err: + _LOGGER.error("Failed to execute script '%s': %s", self._script_name, err) + raise diff --git a/custom_components/remote_media_player/config_flow.py b/custom_components/remote_media_player/config_flow.py new file mode 100644 index 0000000..bd54f09 --- /dev/null +++ b/custom_components/remote_media_player/config_flow.py @@ -0,0 +1,224 @@ +"""Config flow for Remote Media Player integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector + +from .api_client import ( + MediaServerClient, + MediaServerConnectionError, + MediaServerAuthError, +) +from .const import ( + DOMAIN, + CONF_TOKEN, + CONF_POLL_INTERVAL, + DEFAULT_PORT, + DEFAULT_POLL_INTERVAL, + DEFAULT_NAME, +) + +_LOGGER = logging.getLogger(__name__) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input allows us to connect. + + Args: + hass: Home Assistant instance + data: User input data + + Returns: + Validated data with title + + Raises: + CannotConnect: If connection fails + InvalidAuth: If authentication fails + """ + client = MediaServerClient( + host=data[CONF_HOST], + port=data[CONF_PORT], + token=data[CONF_TOKEN], + ) + + try: + health = await client.get_health() + # Try authenticated endpoint + await client.get_status() + except MediaServerConnectionError as err: + await client.close() + raise CannotConnect(str(err)) from err + except MediaServerAuthError as err: + await client.close() + raise InvalidAuth(str(err)) from err + finally: + await client.close() + + # Return info to store in the config entry + return { + "title": data.get(CONF_NAME, DEFAULT_NAME), + "platform": health.get("platform", "Unknown"), + } + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Remote Media Player.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step. + + Args: + user_input: User provided configuration + + Returns: + Flow result + """ + errors: dict[str, str] = {} + + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + # Check if already configured + await self.async_set_unique_id( + f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" + ) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=info["title"], + data=user_input, + ) + + # Show configuration form + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT) + ), + vol.Required(CONF_PORT, default=DEFAULT_PORT): selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, + max=65535, + mode=selector.NumberSelectorMode.BOX, + ) + ), + vol.Required(CONF_TOKEN): selector.TextSelector( + selector.TextSelectorConfig( + type=selector.TextSelectorType.PASSWORD + ) + ), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT) + ), + vol.Optional( + CONF_POLL_INTERVAL, default=DEFAULT_POLL_INTERVAL + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, + max=60, + step=1, + unit_of_measurement="seconds", + mode=selector.NumberSelectorMode.SLIDER, + ) + ), + } + ), + errors=errors, + ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Create the options flow. + + Args: + config_entry: Config entry + + Returns: + Options flow handler + """ + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle options flow for Remote Media Player.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow. + + Args: + config_entry: Config entry + """ + self._config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options. + + Args: + user_input: User provided options + + Returns: + Flow result + """ + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_POLL_INTERVAL, + default=self._config_entry.options.get( + CONF_POLL_INTERVAL, + self._config_entry.data.get( + CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL + ), + ), + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, + max=60, + step=1, + unit_of_measurement="seconds", + mode=selector.NumberSelectorMode.SLIDER, + ) + ), + } + ), + ) + + +class CannotConnect(Exception): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(Exception): + """Error to indicate there is invalid auth.""" diff --git a/custom_components/remote_media_player/const.py b/custom_components/remote_media_player/const.py new file mode 100644 index 0000000..c437701 --- /dev/null +++ b/custom_components/remote_media_player/const.py @@ -0,0 +1,36 @@ +"""Constants for the Remote Media Player integration.""" + +DOMAIN = "remote_media_player" + +# Configuration keys +CONF_HOST = "host" +CONF_PORT = "port" +CONF_TOKEN = "token" +CONF_POLL_INTERVAL = "poll_interval" +CONF_NAME = "name" + +# Default values +DEFAULT_PORT = 8765 +DEFAULT_POLL_INTERVAL = 5 +DEFAULT_NAME = "Remote Media Player" + +# API endpoints +API_HEALTH = "/api/health" +API_STATUS = "/api/media/status" +API_PLAY = "/api/media/play" +API_PAUSE = "/api/media/pause" +API_STOP = "/api/media/stop" +API_NEXT = "/api/media/next" +API_PREVIOUS = "/api/media/previous" +API_VOLUME = "/api/media/volume" +API_MUTE = "/api/media/mute" +API_SEEK = "/api/media/seek" +API_SCRIPTS_LIST = "/api/scripts/list" +API_SCRIPTS_EXECUTE = "/api/scripts/execute" + +# Service names +SERVICE_EXECUTE_SCRIPT = "execute_script" + +# Service attributes +ATTR_SCRIPT_NAME = "script_name" +ATTR_SCRIPT_ARGS = "args" diff --git a/custom_components/remote_media_player/manifest.json b/custom_components/remote_media_player/manifest.json new file mode 100644 index 0000000..edaab2b --- /dev/null +++ b/custom_components/remote_media_player/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "remote_media_player", + "name": "Remote Media Player", + "codeowners": [], + "config_flow": true, + "dependencies": [], + "documentation": "https://github.com/your-username/haos-integration-media-player", + "integration_type": "device", + "iot_class": "local_polling", + "requirements": ["aiohttp>=3.8.0"], + "version": "1.0.0" +} diff --git a/custom_components/remote_media_player/media_player.py b/custom_components/remote_media_player/media_player.py new file mode 100644 index 0000000..2a5385c --- /dev/null +++ b/custom_components/remote_media_player/media_player.py @@ -0,0 +1,345 @@ +"""Media player platform for Remote Media Player integration.""" + +from __future__ import annotations + +import logging +from datetime import datetime, timedelta +from typing import Any + +from homeassistant.components.media_player import ( + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, + MediaType, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) + +from .api_client import MediaServerClient, MediaServerError +from .const import ( + DOMAIN, + CONF_HOST, + CONF_PORT, + CONF_POLL_INTERVAL, + DEFAULT_POLL_INTERVAL, + DEFAULT_NAME, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the media player platform. + + Args: + hass: Home Assistant instance + entry: Config entry + async_add_entities: Callback to add entities + """ + _LOGGER.debug("Setting up media player platform for %s", entry.entry_id) + + try: + client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"] + except KeyError: + _LOGGER.error("Client not found in hass.data for entry %s", entry.entry_id) + return + + # Get poll interval from options or data + poll_interval = entry.options.get( + CONF_POLL_INTERVAL, + entry.data.get(CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL), + ) + + # Create update coordinator + coordinator = MediaPlayerCoordinator( + hass, + client, + poll_interval, + ) + + # Fetch initial data - don't fail setup if this fails + try: + await coordinator.async_config_entry_first_refresh() + except Exception as err: + _LOGGER.warning("Initial data fetch failed, will retry: %s", err) + # Continue anyway - the coordinator will retry + + # Create and add entity + entity = RemoteMediaPlayerEntity( + coordinator, + entry, + ) + _LOGGER.info("Adding media player entity: %s", entity.unique_id) + async_add_entities([entity]) + + +class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Coordinator for fetching media player data.""" + + def __init__( + self, + hass: HomeAssistant, + client: MediaServerClient, + poll_interval: int, + ) -> None: + """Initialize the coordinator. + + Args: + hass: Home Assistant instance + client: Media Server API client + poll_interval: Update interval in seconds + """ + super().__init__( + hass, + _LOGGER, + name="Remote Media Player", + update_interval=timedelta(seconds=poll_interval), + ) + self.client = client + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from the API. + + Returns: + Media status data + + Raises: + UpdateFailed: On API errors + """ + try: + data = await self.client.get_status() + _LOGGER.debug("Received media status: %s", data) + return data + except MediaServerError as err: + raise UpdateFailed(f"Error communicating with server: {err}") from err + except Exception as err: + _LOGGER.exception("Unexpected error fetching media status") + raise UpdateFailed(f"Unexpected error: {err}") from err + + +class RemoteMediaPlayerEntity(CoordinatorEntity[MediaPlayerCoordinator], MediaPlayerEntity): + """Representation of a Remote Media Player.""" + + _attr_has_entity_name = True + _attr_name = None + + @property + def available(self) -> bool: + """Return True if entity is available.""" + # Use the coordinator's last_update_success to detect server availability + return self.coordinator.last_update_success + + def __init__( + self, + coordinator: MediaPlayerCoordinator, + entry: ConfigEntry, + ) -> None: + """Initialize the media player entity. + + Args: + coordinator: Data update coordinator + entry: Config entry + """ + super().__init__(coordinator) + self._entry = entry + self._attr_unique_id = f"{entry.entry_id}_media_player" + + # Device info - must match button.py identifiers + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=entry.title, + manufacturer="Remote Media Player", + model="Media Server", + sw_version="1.0.0", + configuration_url=f"http://{entry.data[CONF_HOST]}:{int(entry.data[CONF_PORT])}/docs", + ) + + @property + def supported_features(self) -> MediaPlayerEntityFeature: + """Return the supported features.""" + return ( + MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.STOP + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.SEEK + ) + + @property + def state(self) -> MediaPlayerState | None: + """Return the state of the player.""" + if self.coordinator.data is None: + return MediaPlayerState.OFF + + state = self.coordinator.data.get("state", "idle") + state_map = { + "playing": MediaPlayerState.PLAYING, + "paused": MediaPlayerState.PAUSED, + "stopped": MediaPlayerState.IDLE, + "idle": MediaPlayerState.IDLE, + } + return state_map.get(state, MediaPlayerState.IDLE) + + @property + def volume_level(self) -> float | None: + """Return the volume level (0..1).""" + if self.coordinator.data is None: + return None + volume = self.coordinator.data.get("volume", 0) + return volume / 100.0 + + @property + def is_volume_muted(self) -> bool | None: + """Return True if volume is muted.""" + if self.coordinator.data is None: + return None + return self.coordinator.data.get("muted", False) + + @property + def media_content_type(self) -> MediaType | None: + """Return the content type of current playing media.""" + return MediaType.MUSIC + + @property + def media_title(self) -> str | None: + """Return the title of current playing media.""" + if self.coordinator.data is None: + return None + return self.coordinator.data.get("title") + + @property + def media_artist(self) -> str | None: + """Return the artist of current playing media.""" + if self.coordinator.data is None: + return None + return self.coordinator.data.get("artist") + + @property + def media_album_name(self) -> str | None: + """Return the album name of current playing media.""" + if self.coordinator.data is None: + return None + return self.coordinator.data.get("album") + + @property + def media_image_url(self) -> str | None: + """Return the image URL of current playing media.""" + if self.coordinator.data is None: + return None + return self.coordinator.data.get("album_art_url") + + @property + def media_duration(self) -> int | None: + """Return the duration of current playing media in seconds.""" + if self.coordinator.data is None: + return None + duration = self.coordinator.data.get("duration") + return int(duration) if duration is not None else None + + @property + def media_position(self) -> int | None: + """Return the position of current playing media in seconds.""" + if self.coordinator.data is None: + return None + position = self.coordinator.data.get("position") + return int(position) if position is not None else None + + @property + def media_position_updated_at(self) -> datetime | None: + """Return when the position was last updated.""" + if self.coordinator.data is None: + return None + if self.coordinator.data.get("position") is not None: + # Use last_update_success_time if available, otherwise use current time + if hasattr(self.coordinator, 'last_update_success_time'): + return self.coordinator.last_update_success_time + return datetime.now() + return None + + @property + def source(self) -> str | None: + """Return the current media source.""" + if self.coordinator.data is None: + return None + return self.coordinator.data.get("source") + + async def async_media_play(self) -> None: + """Send play command.""" + try: + await self.coordinator.client.play() + await self.coordinator.async_request_refresh() + except MediaServerError as err: + _LOGGER.error("Failed to play: %s", err) + + async def async_media_pause(self) -> None: + """Send pause command.""" + try: + await self.coordinator.client.pause() + await self.coordinator.async_request_refresh() + except MediaServerError as err: + _LOGGER.error("Failed to pause: %s", err) + + async def async_media_stop(self) -> None: + """Send stop command.""" + try: + await self.coordinator.client.stop() + await self.coordinator.async_request_refresh() + except MediaServerError as err: + _LOGGER.error("Failed to stop: %s", err) + + async def async_media_next_track(self) -> None: + """Send next track command.""" + try: + await self.coordinator.client.next_track() + await self.coordinator.async_request_refresh() + except MediaServerError as err: + _LOGGER.error("Failed to skip to next track: %s", err) + + async def async_media_previous_track(self) -> None: + """Send previous track command.""" + try: + await self.coordinator.client.previous_track() + await self.coordinator.async_request_refresh() + except MediaServerError as err: + _LOGGER.error("Failed to go to previous track: %s", err) + + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level, range 0..1.""" + try: + await self.coordinator.client.set_volume(int(volume * 100)) + await self.coordinator.async_request_refresh() + except MediaServerError as err: + _LOGGER.error("Failed to set volume: %s", err) + + async def async_mute_volume(self, mute: bool) -> None: + """Mute/unmute the volume.""" + try: + # Toggle mute (API toggles, so call it if state differs) + if self.is_volume_muted != mute: + await self.coordinator.client.toggle_mute() + await self.coordinator.async_request_refresh() + except MediaServerError as err: + _LOGGER.error("Failed to toggle mute: %s", err) + + async def async_media_seek(self, position: float) -> None: + """Seek to a position.""" + try: + await self.coordinator.client.seek(position) + await self.coordinator.async_request_refresh() + except MediaServerError as err: + _LOGGER.error("Failed to seek: %s", err) diff --git a/custom_components/remote_media_player/services.yaml b/custom_components/remote_media_player/services.yaml new file mode 100644 index 0000000..3f22882 --- /dev/null +++ b/custom_components/remote_media_player/services.yaml @@ -0,0 +1,18 @@ +execute_script: + name: Execute Script + description: Execute a pre-defined script on the media server + fields: + script_name: + name: Script Name + description: Name of the script to execute (as defined in server config) + required: true + example: "launch_spotify" + selector: + text: + args: + name: Arguments + description: Optional list of arguments to pass to the script + required: false + example: '["arg1", "arg2"]' + selector: + object: diff --git a/custom_components/remote_media_player/strings.json b/custom_components/remote_media_player/strings.json new file mode 100644 index 0000000..1609d63 --- /dev/null +++ b/custom_components/remote_media_player/strings.json @@ -0,0 +1,61 @@ +{ + "config": { + "step": { + "user": { + "title": "Connect to Media Server", + "description": "Enter the connection details for your Media Server.", + "data": { + "host": "Host", + "port": "Port", + "token": "API Token", + "name": "Name", + "poll_interval": "Poll Interval" + }, + "data_description": { + "host": "Hostname or IP address of the Media Server", + "port": "Port number (default: 8765)", + "token": "API authentication token from the server configuration", + "name": "Display name for this media player", + "poll_interval": "How often to poll for status updates (seconds)" + } + } + }, + "error": { + "cannot_connect": "Failed to connect to the Media Server. Please check the host and port.", + "invalid_auth": "Invalid API token. Please check your token.", + "unknown": "An unexpected error occurred." + }, + "abort": { + "already_configured": "This Media Server is already configured." + } + }, + "options": { + "step": { + "init": { + "title": "Options", + "data": { + "poll_interval": "Poll Interval" + }, + "data_description": { + "poll_interval": "How often to poll for status updates (seconds)" + } + } + } + }, + "services": { + "execute_script": { + "name": "Execute Script", + "description": "Execute a pre-defined script on the media server.", + "fields": { + "script_name": { + "name": "Script Name", + "description": "Name of the script to execute (as defined in server config)" + }, + "args": { + "name": "Arguments", + "description": "Optional list of arguments to pass to the script" + } + } + } + } +} diff --git a/custom_components/remote_media_player/translations/en.json b/custom_components/remote_media_player/translations/en.json new file mode 100644 index 0000000..1609d63 --- /dev/null +++ b/custom_components/remote_media_player/translations/en.json @@ -0,0 +1,61 @@ +{ + "config": { + "step": { + "user": { + "title": "Connect to Media Server", + "description": "Enter the connection details for your Media Server.", + "data": { + "host": "Host", + "port": "Port", + "token": "API Token", + "name": "Name", + "poll_interval": "Poll Interval" + }, + "data_description": { + "host": "Hostname or IP address of the Media Server", + "port": "Port number (default: 8765)", + "token": "API authentication token from the server configuration", + "name": "Display name for this media player", + "poll_interval": "How often to poll for status updates (seconds)" + } + } + }, + "error": { + "cannot_connect": "Failed to connect to the Media Server. Please check the host and port.", + "invalid_auth": "Invalid API token. Please check your token.", + "unknown": "An unexpected error occurred." + }, + "abort": { + "already_configured": "This Media Server is already configured." + } + }, + "options": { + "step": { + "init": { + "title": "Options", + "data": { + "poll_interval": "Poll Interval" + }, + "data_description": { + "poll_interval": "How often to poll for status updates (seconds)" + } + } + } + }, + "services": { + "execute_script": { + "name": "Execute Script", + "description": "Execute a pre-defined script on the media server.", + "fields": { + "script_name": { + "name": "Script Name", + "description": "Name of the script to execute (as defined in server config)" + }, + "args": { + "name": "Arguments", + "description": "Optional list of arguments to pass to the script" + } + } + } + } +} diff --git a/custom_components/remote_media_player/translations/ru.json b/custom_components/remote_media_player/translations/ru.json new file mode 100644 index 0000000..90cbac2 --- /dev/null +++ b/custom_components/remote_media_player/translations/ru.json @@ -0,0 +1,61 @@ +{ + "config": { + "step": { + "user": { + "title": "Подключение к Media Server", + "description": "Введите данные для подключения к Media Server.", + "data": { + "host": "Хост", + "port": "Порт", + "token": "API токен", + "name": "Название", + "poll_interval": "Интервал опроса" + }, + "data_description": { + "host": "Имя хоста или IP-адрес Media Server", + "port": "Номер порта (по умолчанию: 8765)", + "token": "Токен аутентификации из конфигурации сервера", + "name": "Отображаемое имя медиаплеера", + "poll_interval": "Частота опроса статуса (в секундах)" + } + } + }, + "error": { + "cannot_connect": "Не удалось подключиться к Media Server. Проверьте хост и порт.", + "invalid_auth": "Неверный API токен. Проверьте токен.", + "unknown": "Произошла непредвиденная ошибка." + }, + "abort": { + "already_configured": "Этот Media Server уже настроен." + } + }, + "options": { + "step": { + "init": { + "title": "Настройки", + "data": { + "poll_interval": "Интервал опроса" + }, + "data_description": { + "poll_interval": "Частота опроса статуса (в секундах)" + } + } + } + }, + "services": { + "execute_script": { + "name": "Выполнить скрипт", + "description": "Выполнить предопределённый скрипт на медиасервере.", + "fields": { + "script_name": { + "name": "Имя скрипта", + "description": "Имя скрипта для выполнения (из конфигурации сервера)" + }, + "args": { + "name": "Аргументы", + "description": "Необязательный список аргументов для передачи скрипту" + } + } + } + } +} diff --git a/media_server/README.md b/media_server/README.md new file mode 100644 index 0000000..7defc70 --- /dev/null +++ b/media_server/README.md @@ -0,0 +1,382 @@ +# Media Server + +A REST API server for controlling system media playback on Windows, Linux, macOS, and Android. + +## Features + +- Control any media player via system-wide media transport controls +- Play/Pause/Stop/Next/Previous track +- Volume control and mute +- Seek within tracks +- Get current track info (title, artist, album, artwork) +- Token-based authentication +- Cross-platform support + +## Requirements + +- Python 3.10+ +- Platform-specific dependencies (see below) + +## Installation + +### Windows + +```bash +pip install -r requirements.txt +``` + +Required packages: `winsdk`, `pywin32`, `pycaw`, `comtypes` + +### Linux + +```bash +# Install system dependencies +sudo apt-get install python3-dbus python3-gi libdbus-1-dev libglib2.0-dev + +pip install -r requirements.txt +``` + +### macOS + +```bash +pip install -r requirements.txt +``` + +No additional dependencies - uses built-in `osascript`. + +### Android (Termux) + +```bash +# In Termux +pkg install python termux-api +pip install -r requirements.txt +``` + +Requires Termux and Termux:API apps from F-Droid. + +## Quick Start + +1. Generate configuration with API token: + ```bash + python -m media_server.main --generate-config + ``` + +2. View your API token: + ```bash + python -m media_server.main --show-token + ``` + +3. Start the server: + ```bash + python -m media_server.main + ``` + +4. Test the connection: + ```bash + curl http://localhost:8765/api/health + ``` + +5. Test with authentication: + ```bash + curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8765/api/media/status + ``` + +## Configuration + +Configuration file locations: +- Windows: `%APPDATA%\media-server\config.yaml` +- Linux/macOS: `~/.config/media-server/config.yaml` + +### config.yaml + +```yaml +host: 0.0.0.0 +port: 8765 +api_token: your-secret-token-here +poll_interval: 1.0 +log_level: INFO +``` + +### Environment Variables + +All settings can be overridden with environment variables (prefix: `MEDIA_SERVER_`): + +```bash +export MEDIA_SERVER_HOST=0.0.0.0 +export MEDIA_SERVER_PORT=8765 +export MEDIA_SERVER_API_TOKEN=your-token +export MEDIA_SERVER_LOG_LEVEL=DEBUG +``` + +## API Reference + +### Health Check + +``` +GET /api/health +``` + +No authentication required. Returns server status and platform info. + +**Response:** +```json +{ + "status": "healthy", + "platform": "Windows", + "version": "1.0.0" +} +``` + +### Get Media Status + +``` +GET /api/media/status +Authorization: Bearer +``` + +**Response:** +```json +{ + "state": "playing", + "title": "Song Title", + "artist": "Artist Name", + "album": "Album Name", + "album_art_url": "https://...", + "duration": 240.5, + "position": 120.3, + "volume": 75, + "muted": false, + "source": "Spotify" +} +``` + +### Media Controls + +All control endpoints require authentication and return `{"success": true}` on success. + +| Endpoint | Method | Body | Description | +|----------|--------|------|-------------| +| `/api/media/play` | POST | - | Resume playback | +| `/api/media/pause` | POST | - | Pause playback | +| `/api/media/stop` | POST | - | Stop playback | +| `/api/media/next` | POST | - | Next track | +| `/api/media/previous` | POST | - | Previous track | +| `/api/media/volume` | POST | `{"volume": 75}` | Set volume (0-100) | +| `/api/media/mute` | POST | - | Toggle mute | +| `/api/media/seek` | POST | `{"position": 60.0}` | Seek to position (seconds) | + +### Script Execution + +The server supports executing pre-defined scripts via API. + +#### List Scripts + +``` +GET /api/scripts/list +Authorization: Bearer +``` + +**Response:** +```json +[ + { + "name": "lock_screen", + "label": "Lock Screen", + "description": "Lock the workstation", + "timeout": 5 + } +] +``` + +#### Execute Script + +``` +POST /api/scripts/execute/{script_name} +Authorization: Bearer +Content-Type: application/json + +{"args": []} +``` + +**Response:** +```json +{ + "success": true, + "script": "lock_screen", + "exit_code": 0, + "stdout": "", + "stderr": "" +} +``` + +### Configuring Scripts + +Add scripts in your `config.yaml`: + +```yaml +scripts: + lock_screen: + command: "rundll32.exe user32.dll,LockWorkStation" + label: "Lock Screen" + description: "Lock the workstation" + timeout: 5 + shell: true + + shutdown: + command: "shutdown /s /t 0" + label: "Shutdown" + description: "Shutdown the PC immediately" + timeout: 10 + shell: true + + restart: + command: "shutdown /r /t 0" + label: "Restart" + description: "Restart the PC" + timeout: 10 + shell: true + + hibernate: + command: "shutdown /h" + label: "Hibernate" + description: "Hibernate the PC" + timeout: 10 + shell: true + + sleep: + command: "rundll32.exe powrprof.dll,SetSuspendState 0,1,0" + label: "Sleep" + description: "Put PC to sleep" + timeout: 10 + shell: true +``` + +Script configuration options: + +| Field | Required | Description | +|-------|----------|-------------| +| `command` | Yes | Command to execute | +| `label` | No | User-friendly display name (defaults to script name) | +| `description` | No | Description of what the script does | +| `timeout` | No | Execution timeout in seconds (default: 30, max: 300) | +| `working_dir` | No | Working directory for the command | +| `shell` | No | Run in shell (default: true) | + +## Running as a Service + +### Windows Task Scheduler (Recommended) + +Run in **Administrator PowerShell** from the project root: + +```powershell +.\media_server\service\install_task_windows.ps1 +``` + +To remove the scheduled task: + +```powershell +Unregister-ScheduledTask -TaskName "MediaServer" -Confirm:$false +``` + +### Windows Service + +Install: +```bash +python -m media_server.service.install_windows install +``` + +Start/Stop: +```bash +python -m media_server.service.install_windows start +python -m media_server.service.install_windows stop +``` + +Remove: +```bash +python -m media_server.service.install_windows remove +``` + +### Linux (systemd) + +Install: +```bash +sudo ./service/install_linux.sh install +``` + +Enable and start for your user: +```bash +sudo systemctl enable media-server@$USER +sudo systemctl start media-server@$USER +``` + +View logs: +```bash +journalctl -u media-server@$USER -f +``` + +## Command Line Options + +``` +python -m media_server.main [OPTIONS] + +Options: + --host TEXT Host to bind to (default: 0.0.0.0) + --port INTEGER Port to bind to (default: 8765) + --generate-config Generate default config file and exit + --show-token Show current API token and exit +``` + +## Security Recommendations + +1. **Use HTTPS in production** - Set up a reverse proxy (nginx, Caddy) with SSL +2. **Strong tokens** - Default tokens are 32 random characters; don't use weak tokens +3. **Firewall** - Only expose the port to trusted networks +4. **Secrets management** - Don't commit tokens to version control + +## Supported Media Players + +### Windows +- Spotify +- Windows Media Player +- VLC +- Groove Music +- Web browsers (Chrome, Edge, Firefox) +- Any app using Windows Media Transport Controls + +### Linux +- Any MPRIS-compliant player: + - Spotify + - VLC + - Rhythmbox + - Clementine + - Web browsers + - MPD (with MPRIS bridge) + +### macOS +- Spotify +- Apple Music +- VLC (partial) +- QuickTime Player + +### Android (via Termux) +- System media controls +- Limited seek support + +## Troubleshooting + +### "No active media session" +- Ensure a media player is running and has played content +- On Windows, check that the app supports media transport controls +- On Linux, verify MPRIS with: `dbus-send --print-reply --dest=org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus.ListNames | grep mpris` + +### Permission errors on Linux +- Ensure your user has access to the D-Bus session bus +- For systemd service, the `DBUS_SESSION_BUS_ADDRESS` must be set correctly + +### Volume control not working +- Windows: Run as administrator if needed +- Linux: Ensure PulseAudio/PipeWire is running + +## License + +MIT License diff --git a/media_server/__init__.py b/media_server/__init__.py new file mode 100644 index 0000000..385c459 --- /dev/null +++ b/media_server/__init__.py @@ -0,0 +1,3 @@ +"""Media Server - REST API for controlling system media playback.""" + +__version__ = "1.0.0" diff --git a/media_server/auth.py b/media_server/auth.py new file mode 100644 index 0000000..8b70090 --- /dev/null +++ b/media_server/auth.py @@ -0,0 +1,111 @@ +"""Authentication middleware and utilities.""" + +from typing import Optional + +from fastapi import Depends, HTTPException, Query, Request, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from .config import settings + +security = HTTPBearer(auto_error=False) + + +async def verify_token( + request: Request, + credentials: HTTPAuthorizationCredentials = Depends(security), +) -> str: + """Verify the API token from the Authorization header. + + Args: + request: The incoming request + credentials: The bearer token credentials + + Returns: + The validated token + + Raises: + HTTPException: If the token is missing or invalid + """ + if credentials is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing authentication token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if credentials.credentials != settings.api_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return credentials.credentials + + +class TokenAuth: + """Dependency class for token authentication.""" + + def __init__(self, auto_error: bool = True): + self.auto_error = auto_error + + async def __call__( + self, + request: Request, + credentials: HTTPAuthorizationCredentials = Depends(security), + ) -> str | None: + """Verify the token and return it or raise an exception.""" + if credentials is None: + if self.auto_error: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing authentication token", + headers={"WWW-Authenticate": "Bearer"}, + ) + return None + + if credentials.credentials != settings.api_token: + if self.auto_error: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication token", + headers={"WWW-Authenticate": "Bearer"}, + ) + return None + + return credentials.credentials + + +async def verify_token_or_query( + credentials: HTTPAuthorizationCredentials = Depends(security), + token: Optional[str] = Query(None, description="API token as query parameter"), +) -> str: + """Verify the API token from header or query parameter. + + Useful for endpoints that need to be accessed via URL (like images). + + Args: + credentials: The bearer token credentials from header + token: Token from query parameter + + Returns: + The validated token + + Raises: + HTTPException: If the token is missing or invalid + """ + # Try header first + if credentials is not None: + if credentials.credentials == settings.api_token: + return credentials.credentials + + # Try query parameter + if token is not None: + if token == settings.api_token: + return token + + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing or invalid authentication token", + headers={"WWW-Authenticate": "Bearer"}, + ) diff --git a/media_server/config.py b/media_server/config.py new file mode 100644 index 0000000..885958c --- /dev/null +++ b/media_server/config.py @@ -0,0 +1,141 @@ +"""Configuration management for the media server.""" + +import os +import secrets +from pathlib import Path +from typing import Optional + +import yaml +from pydantic import BaseModel, Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class ScriptConfig(BaseModel): + """Configuration for a custom script.""" + + command: str = Field(..., description="Command or script to execute") + label: Optional[str] = Field(default=None, description="User-friendly display label") + description: str = Field(default="", description="Script description") + timeout: int = Field(default=30, description="Execution timeout in seconds", ge=1, le=300) + working_dir: Optional[str] = Field(default=None, description="Working directory") + shell: bool = Field(default=True, description="Run command in shell") + + +class Settings(BaseSettings): + """Application settings loaded from environment or config file.""" + + model_config = SettingsConfigDict( + env_prefix="MEDIA_SERVER_", + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + # Server settings + host: str = Field(default="0.0.0.0", description="Server bind address") + port: int = Field(default=8765, description="Server port") + + # Authentication + api_token: str = Field( + default_factory=lambda: secrets.token_urlsafe(32), + description="API authentication token", + ) + + # Media controller settings + poll_interval: float = Field( + default=1.0, description="Media status poll interval in seconds" + ) + + # Logging + log_level: str = Field(default="INFO", description="Logging level") + + # Custom scripts (loaded separately from YAML) + scripts: dict[str, ScriptConfig] = Field( + default_factory=dict, + description="Custom scripts that can be executed via API", + ) + + @classmethod + def load_from_yaml(cls, path: Optional[Path] = None) -> "Settings": + """Load settings from a YAML configuration file.""" + if path is None: + # Look for config in standard locations + search_paths = [ + Path("config.yaml"), + Path("config.yml"), + ] + + # Add platform-specific config directory + if os.name == "nt": # Windows + appdata = os.environ.get("APPDATA", "") + if appdata: + search_paths.append(Path(appdata) / "media-server" / "config.yaml") + else: # Linux/Unix/macOS + search_paths.append(Path.home() / ".config" / "media-server" / "config.yaml") + search_paths.append(Path("/etc/media-server/config.yaml")) + + for search_path in search_paths: + if search_path.exists(): + path = search_path + break + + if path and path.exists(): + with open(path, "r", encoding="utf-8") as f: + config_data = yaml.safe_load(f) or {} + return cls(**config_data) + + return cls() + + +def get_config_dir() -> Path: + """Get the configuration directory path.""" + if os.name == "nt": # Windows + config_dir = Path(os.environ.get("APPDATA", "")) / "media-server" + else: # Linux/Unix + config_dir = Path.home() / ".config" / "media-server" + + config_dir.mkdir(parents=True, exist_ok=True) + return config_dir + + +def generate_default_config(path: Optional[Path] = None) -> Path: + """Generate a default configuration file with a new API token.""" + if path is None: + path = get_config_dir() / "config.yaml" + + config = { + "host": "0.0.0.0", + "port": 8765, + "api_token": secrets.token_urlsafe(32), + "poll_interval": 1.0, + "log_level": "INFO", + "scripts": { + "example_script": { + "command": "echo Hello from Media Server!", + "description": "Example script - echoes a message", + "timeout": 10, + "shell": True, + }, + # Add your custom scripts here: + # "shutdown": { + # "command": "shutdown /s /t 60", + # "description": "Shutdown computer in 60 seconds", + # "timeout": 5, + # }, + # "lock_screen": { + # "command": "rundll32.exe user32.dll,LockWorkStation", + # "description": "Lock the workstation", + # "timeout": 5, + # }, + }, + } + + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + yaml.dump(config, f, default_flow_style=False, sort_keys=False) + + return path + + +# Global settings instance +settings = Settings.load_from_yaml() diff --git a/media_server/main.py b/media_server/main.py new file mode 100644 index 0000000..1f57e66 --- /dev/null +++ b/media_server/main.py @@ -0,0 +1,112 @@ +"""Media Server - FastAPI application entry point.""" + +import argparse +import logging +import sys +from contextlib import asynccontextmanager + +import uvicorn +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from .config import settings, generate_default_config, get_config_dir +from .routes import health_router, media_router, scripts_router + + +def setup_logging(): + """Configure application logging.""" + logging.basicConfig( + level=getattr(logging, settings.log_level.upper()), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[logging.StreamHandler(sys.stdout)], + ) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan handler.""" + setup_logging() + logger = logging.getLogger(__name__) + logger.info(f"Media Server starting on {settings.host}:{settings.port}") + logger.info(f"API Token: {settings.api_token[:8]}...") + yield + logger.info("Media Server shutting down") + + +def create_app() -> FastAPI: + """Create and configure the FastAPI application.""" + app = FastAPI( + title="Media Server", + description="REST API for controlling system media playback", + version="1.0.0", + lifespan=lifespan, + ) + + # Add CORS middleware for cross-origin requests + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # Register routers + app.include_router(health_router) + app.include_router(media_router) + app.include_router(scripts_router) + + return app + + +app = create_app() + + +def main(): + """Main entry point for running the server.""" + parser = argparse.ArgumentParser(description="Media Server") + parser.add_argument( + "--host", + default=settings.host, + help=f"Host to bind to (default: {settings.host})", + ) + parser.add_argument( + "--port", + type=int, + default=settings.port, + help=f"Port to bind to (default: {settings.port})", + ) + parser.add_argument( + "--generate-config", + action="store_true", + help="Generate a default configuration file and exit", + ) + parser.add_argument( + "--show-token", + action="store_true", + help="Show the current API token and exit", + ) + + args = parser.parse_args() + + if args.generate_config: + config_path = generate_default_config() + print(f"Configuration file generated at: {config_path}") + print(f"API Token has been saved to the config file.") + return + + if args.show_token: + print(f"API Token: {settings.api_token}") + print(f"Config directory: {get_config_dir()}") + return + + uvicorn.run( + "media_server.main:app", + host=args.host, + port=args.port, + reload=False, + ) + + +if __name__ == "__main__": + main() diff --git a/media_server/models/__init__.py b/media_server/models/__init__.py new file mode 100644 index 0000000..abf7966 --- /dev/null +++ b/media_server/models/__init__.py @@ -0,0 +1,17 @@ +"""Pydantic models for the media server API.""" + +from .media import ( + MediaState, + MediaStatus, + VolumeRequest, + SeekRequest, + MediaInfo, +) + +__all__ = [ + "MediaState", + "MediaStatus", + "VolumeRequest", + "SeekRequest", + "MediaInfo", +] diff --git a/media_server/models/media.py b/media_server/models/media.py new file mode 100644 index 0000000..2241e97 --- /dev/null +++ b/media_server/models/media.py @@ -0,0 +1,61 @@ +"""Media-related Pydantic models.""" + +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, Field + + +class MediaState(str, Enum): + """Playback state enumeration.""" + + PLAYING = "playing" + PAUSED = "paused" + STOPPED = "stopped" + IDLE = "idle" + + +class MediaInfo(BaseModel): + """Information about the currently playing media.""" + + title: Optional[str] = Field(None, description="Track/media title") + artist: Optional[str] = Field(None, description="Artist name") + album: Optional[str] = Field(None, description="Album name") + album_art_url: Optional[str] = Field(None, description="URL to album artwork") + duration: Optional[float] = Field( + None, description="Total duration in seconds", ge=0 + ) + position: Optional[float] = Field( + None, description="Current position in seconds", ge=0 + ) + + +class MediaStatus(BaseModel): + """Complete media playback status.""" + + state: MediaState = Field(default=MediaState.IDLE, description="Playback state") + title: Optional[str] = Field(None, description="Track/media title") + artist: Optional[str] = Field(None, description="Artist name") + album: Optional[str] = Field(None, description="Album name") + album_art_url: Optional[str] = Field(None, description="URL to album artwork") + duration: Optional[float] = Field( + None, description="Total duration in seconds", ge=0 + ) + position: Optional[float] = Field( + None, description="Current position in seconds", ge=0 + ) + volume: int = Field(default=100, description="Volume level (0-100)", ge=0, le=100) + muted: bool = Field(default=False, description="Whether audio is muted") + source: Optional[str] = Field(None, description="Media source/player name") + + +class VolumeRequest(BaseModel): + """Request model for setting volume.""" + + volume: int = Field(..., description="Volume level (0-100)", ge=0, le=100) + + +class SeekRequest(BaseModel): + """Request model for seeking to a position.""" + + position: float = Field(..., description="Position in seconds to seek to", ge=0) diff --git a/media_server/requirements.txt b/media_server/requirements.txt new file mode 100644 index 0000000..806405c --- /dev/null +++ b/media_server/requirements.txt @@ -0,0 +1,28 @@ +# Core dependencies +fastapi>=0.109.0 +uvicorn[standard]>=0.27.0 +pydantic>=2.0 +pydantic-settings>=2.0 +pyyaml>=6.0 + +# Windows media control (install on Windows only) +# pip install winsdk pywin32 pycaw comtypes +winsdk>=1.0.0b10; sys_platform == "win32" +pywin32>=306; sys_platform == "win32" +comtypes>=1.2.0; sys_platform == "win32" +pycaw>=20230407; sys_platform == "win32" + +# Linux media control (install on Linux only) +# pip install dbus-python PyGObject +# Note: dbus-python requires system dependencies: +# sudo apt-get install libdbus-1-dev libglib2.0-dev python3-gi +# dbus-python>=1.3.2; sys_platform == "linux" +# PyGObject>=3.46.0; sys_platform == "linux" + +# macOS media control +# No additional dependencies needed - uses osascript (AppleScript) + +# Android media control (via Termux) +# Requires Termux and Termux:API apps from F-Droid +# In Termux: pkg install python termux-api +# No additional pip packages needed diff --git a/media_server/routes/__init__.py b/media_server/routes/__init__.py new file mode 100644 index 0000000..0fd6e84 --- /dev/null +++ b/media_server/routes/__init__.py @@ -0,0 +1,7 @@ +"""API route modules.""" + +from .health import router as health_router +from .media import router as media_router +from .scripts import router as scripts_router + +__all__ = ["health_router", "media_router", "scripts_router"] diff --git a/media_server/routes/health.py b/media_server/routes/health.py new file mode 100644 index 0000000..092e8b0 --- /dev/null +++ b/media_server/routes/health.py @@ -0,0 +1,22 @@ +"""Health check endpoint.""" + +import platform +from typing import Any + +from fastapi import APIRouter + +router = APIRouter(prefix="/api", tags=["health"]) + + +@router.get("/health") +async def health_check() -> dict[str, Any]: + """Health check endpoint - no authentication required. + + Returns: + Health status and server information + """ + return { + "status": "healthy", + "platform": platform.system(), + "version": "1.0.0", + } diff --git a/media_server/routes/media.py b/media_server/routes/media.py new file mode 100644 index 0000000..107ea3f --- /dev/null +++ b/media_server/routes/media.py @@ -0,0 +1,186 @@ +"""Media control API endpoints.""" + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import Response + +from ..auth import verify_token, verify_token_or_query +from ..models import MediaStatus, VolumeRequest, SeekRequest +from ..services import get_media_controller, get_current_album_art + +router = APIRouter(prefix="/api/media", tags=["media"]) + + +@router.get("/status", response_model=MediaStatus) +async def get_media_status(_: str = Depends(verify_token)) -> MediaStatus: + """Get current media playback status. + + Returns: + Current playback state, media info, volume, etc. + """ + controller = get_media_controller() + return await controller.get_status() + + +@router.post("/play") +async def play(_: str = Depends(verify_token)) -> dict: + """Resume or start playback. + + Returns: + Success status + """ + controller = get_media_controller() + success = await controller.play() + if not success: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Failed to start playback - no active media session", + ) + return {"success": True} + + +@router.post("/pause") +async def pause(_: str = Depends(verify_token)) -> dict: + """Pause playback. + + Returns: + Success status + """ + controller = get_media_controller() + success = await controller.pause() + if not success: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Failed to pause - no active media session", + ) + return {"success": True} + + +@router.post("/stop") +async def stop(_: str = Depends(verify_token)) -> dict: + """Stop playback. + + Returns: + Success status + """ + controller = get_media_controller() + success = await controller.stop() + if not success: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Failed to stop - no active media session", + ) + return {"success": True} + + +@router.post("/next") +async def next_track(_: str = Depends(verify_token)) -> dict: + """Skip to next track. + + Returns: + Success status + """ + controller = get_media_controller() + success = await controller.next_track() + if not success: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Failed to skip - no active media session", + ) + return {"success": True} + + +@router.post("/previous") +async def previous_track(_: str = Depends(verify_token)) -> dict: + """Go to previous track. + + Returns: + Success status + """ + controller = get_media_controller() + success = await controller.previous_track() + if not success: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Failed to go back - no active media session", + ) + return {"success": True} + + +@router.post("/volume") +async def set_volume( + request: VolumeRequest, _: str = Depends(verify_token) +) -> dict: + """Set the system volume. + + Args: + request: Volume level (0-100) + + Returns: + Success status with new volume level + """ + controller = get_media_controller() + success = await controller.set_volume(request.volume) + if not success: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Failed to set volume", + ) + return {"success": True, "volume": request.volume} + + +@router.post("/mute") +async def toggle_mute(_: str = Depends(verify_token)) -> dict: + """Toggle mute state. + + Returns: + Success status with new mute state + """ + controller = get_media_controller() + muted = await controller.toggle_mute() + return {"success": True, "muted": muted} + + +@router.post("/seek") +async def seek(request: SeekRequest, _: str = Depends(verify_token)) -> dict: + """Seek to a position in the current track. + + Args: + request: Position in seconds + + Returns: + Success status + """ + controller = get_media_controller() + success = await controller.seek(request.position) + if not success: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Failed to seek - no active media session or seek not supported", + ) + return {"success": True, "position": request.position} + + +@router.get("/artwork") +async def get_artwork(_: str = Depends(verify_token_or_query)) -> Response: + """Get the current album artwork. + + Returns: + The album art image as PNG/JPEG + """ + art_bytes = get_current_album_art() + if art_bytes is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No album artwork available", + ) + + # Try to detect image type from magic bytes + content_type = "image/png" # Default + if art_bytes[:3] == b"\xff\xd8\xff": + content_type = "image/jpeg" + elif art_bytes[:8] == b"\x89PNG\r\n\x1a\n": + content_type = "image/png" + elif art_bytes[:4] == b"RIFF" and art_bytes[8:12] == b"WEBP": + content_type = "image/webp" + + return Response(content=art_bytes, media_type=content_type) diff --git a/media_server/routes/scripts.py b/media_server/routes/scripts.py new file mode 100644 index 0000000..e518fc9 --- /dev/null +++ b/media_server/routes/scripts.py @@ -0,0 +1,167 @@ +"""Script execution API endpoints.""" + +import asyncio +import logging +import subprocess +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field + +from ..auth import verify_token +from ..config import settings + +router = APIRouter(prefix="/api/scripts", tags=["scripts"]) +logger = logging.getLogger(__name__) + + +class ScriptExecuteRequest(BaseModel): + """Request model for script execution with optional arguments.""" + + args: list[str] = Field(default_factory=list, description="Additional arguments") + + +class ScriptExecuteResponse(BaseModel): + """Response model for script execution.""" + + success: bool + script: str + exit_code: int | None = None + stdout: str = "" + stderr: str = "" + error: str | None = None + + +class ScriptInfo(BaseModel): + """Information about an available script.""" + + name: str + label: str + description: str + timeout: int + + +@router.get("/list") +async def list_scripts(_: str = Depends(verify_token)) -> list[ScriptInfo]: + """List all available scripts. + + Returns: + List of available scripts with their descriptions + """ + return [ + ScriptInfo( + name=name, + label=config.label or name.replace("_", " ").title(), + description=config.description, + timeout=config.timeout, + ) + for name, config in settings.scripts.items() + ] + + +@router.post("/execute/{script_name}") +async def execute_script( + script_name: str, + request: ScriptExecuteRequest | None = None, + _: str = Depends(verify_token), +) -> ScriptExecuteResponse: + """Execute a pre-defined script by name. + + Args: + script_name: Name of the script to execute (must be defined in config) + request: Optional arguments to pass to the script + + Returns: + Execution result including stdout, stderr, and exit code + """ + # Check if script exists + if script_name not in settings.scripts: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Script '{script_name}' not found. Use /api/scripts/list to see available scripts.", + ) + + script_config = settings.scripts[script_name] + args = request.args if request else [] + + logger.info(f"Executing script: {script_name}") + + try: + # Build command + command = script_config.command + if args: + # Append arguments to command + command = f"{command} {' '.join(args)}" + + # Execute in thread pool to not block + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, + lambda: _run_script( + command=command, + timeout=script_config.timeout, + shell=script_config.shell, + working_dir=script_config.working_dir, + ), + ) + + return ScriptExecuteResponse( + success=result["exit_code"] == 0, + script=script_name, + exit_code=result["exit_code"], + stdout=result["stdout"], + stderr=result["stderr"], + ) + + except Exception as e: + logger.error(f"Script execution error: {e}") + return ScriptExecuteResponse( + success=False, + script=script_name, + error=str(e), + ) + + +def _run_script( + command: str, + timeout: int, + shell: bool, + working_dir: str | None, +) -> dict[str, Any]: + """Run a script synchronously. + + Args: + command: Command to execute + timeout: Timeout in seconds + shell: Whether to run in shell + working_dir: Working directory + + Returns: + Dict with exit_code, stdout, stderr + """ + try: + result = subprocess.run( + command, + shell=shell, + cwd=working_dir, + capture_output=True, + text=True, + timeout=timeout, + ) + return { + "exit_code": result.returncode, + "stdout": result.stdout[:10000], # Limit output size + "stderr": result.stderr[:10000], + } + except subprocess.TimeoutExpired: + return { + "exit_code": -1, + "stdout": "", + "stderr": f"Script timed out after {timeout} seconds", + } + except Exception as e: + return { + "exit_code": -1, + "stdout": "", + "stderr": str(e), + } diff --git a/media_server/service/install_linux.sh b/media_server/service/install_linux.sh new file mode 100644 index 0000000..d5f6b12 --- /dev/null +++ b/media_server/service/install_linux.sh @@ -0,0 +1,144 @@ +#!/bin/bash +# Linux service installation script for Media Server + +set -e + +SERVICE_NAME="media-server" +INSTALL_DIR="/opt/media-server" +SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}@.service" +CURRENT_USER=$(whoami) + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +echo_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +echo_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +check_root() { + if [[ $EUID -ne 0 ]]; then + echo_error "This script must be run as root (use sudo)" + exit 1 + fi +} + +install_dependencies() { + echo_info "Installing system dependencies..." + + if command -v apt-get &> /dev/null; then + apt-get update + apt-get install -y python3 python3-pip python3-venv python3-dbus python3-gi libdbus-1-dev libglib2.0-dev + elif command -v dnf &> /dev/null; then + dnf install -y python3 python3-pip python3-dbus python3-gobject dbus-devel glib2-devel + elif command -v pacman &> /dev/null; then + pacman -S --noconfirm python python-pip python-dbus python-gobject + else + echo_warn "Unknown package manager. Please install dependencies manually:" + echo " - python3, python3-pip, python3-venv" + echo " - python3-dbus, python3-gi" + echo " - libdbus-1-dev, libglib2.0-dev" + fi +} + +install_service() { + echo_info "Installing Media Server..." + + # Create installation directory + mkdir -p "$INSTALL_DIR" + + # Copy source files + cp -r "$(dirname "$0")/../"* "$INSTALL_DIR/" + + # Create virtual environment + echo_info "Creating Python virtual environment..." + python3 -m venv "$INSTALL_DIR/venv" + + # Install Python dependencies + echo_info "Installing Python dependencies..." + "$INSTALL_DIR/venv/bin/pip" install --upgrade pip + "$INSTALL_DIR/venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt" + + # Install systemd service file + echo_info "Installing systemd service..." + cp "$INSTALL_DIR/service/media-server.service" "$SERVICE_FILE" + + # Reload systemd + systemctl daemon-reload + + # Generate config if not exists + if [[ ! -f "/home/$SUDO_USER/.config/media-server/config.yaml" ]]; then + echo_info "Generating configuration file..." + sudo -u "$SUDO_USER" "$INSTALL_DIR/venv/bin/python" -m media_server.main --generate-config + fi + + echo_info "Installation complete!" + echo "" + echo "To enable and start the service for user '$SUDO_USER':" + echo " sudo systemctl enable ${SERVICE_NAME}@${SUDO_USER}" + echo " sudo systemctl start ${SERVICE_NAME}@${SUDO_USER}" + echo "" + echo "To view the API token:" + echo " cat ~/.config/media-server/config.yaml" + echo "" + echo "To view logs:" + echo " journalctl -u ${SERVICE_NAME}@${SUDO_USER} -f" +} + +uninstall_service() { + echo_info "Uninstalling Media Server..." + + # Stop and disable service + systemctl stop "${SERVICE_NAME}@*" 2>/dev/null || true + systemctl disable "${SERVICE_NAME}@*" 2>/dev/null || true + + # Remove service file + rm -f "$SERVICE_FILE" + systemctl daemon-reload + + # Remove installation directory + rm -rf "$INSTALL_DIR" + + echo_info "Uninstallation complete!" + echo "Note: Configuration files in ~/.config/media-server were not removed." +} + +show_usage() { + echo "Usage: $0 [install|uninstall|deps]" + echo "" + echo "Commands:" + echo " install Install the Media Server as a systemd service" + echo " uninstall Remove the Media Server service" + echo " deps Install system dependencies only" +} + +# Main +case "${1:-}" in + install) + check_root + install_dependencies + install_service + ;; + uninstall) + check_root + uninstall_service + ;; + deps) + check_root + install_dependencies + ;; + *) + show_usage + exit 1 + ;; +esac diff --git a/media_server/service/install_task_windows.ps1 b/media_server/service/install_task_windows.ps1 new file mode 100644 index 0000000..4aca109 --- /dev/null +++ b/media_server/service/install_task_windows.ps1 @@ -0,0 +1,10 @@ +# Get the project root directory (two levels up from this script) +$projectRoot = (Get-Item $PSScriptRoot).Parent.Parent.FullName + +$action = New-ScheduledTaskAction -Execute "python" -Argument "-m media_server.main" -WorkingDirectory $projectRoot +$trigger = New-ScheduledTaskTrigger -AtStartup +$principal = New-ScheduledTaskPrincipal -UserId "$env:USERNAME" -LogonType S4U -RunLevel Highest +$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable +Register-ScheduledTask -TaskName "MediaServer" -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Description "Media Server for Home Assistant" + +Write-Host "Scheduled task 'MediaServer' created with working directory: $projectRoot" diff --git a/media_server/service/install_windows.py b/media_server/service/install_windows.py new file mode 100644 index 0000000..f0edd87 --- /dev/null +++ b/media_server/service/install_windows.py @@ -0,0 +1,151 @@ +"""Windows service installer for Media Server. + +This module allows the media server to be installed as a Windows service +that starts automatically on boot. + +Usage: + Install: python -m media_server.service.install_windows install + Start: python -m media_server.service.install_windows start + Stop: python -m media_server.service.install_windows stop + Remove: python -m media_server.service.install_windows remove + Debug: python -m media_server.service.install_windows debug +""" + +import os +import sys +import socket +import logging + +try: + import win32serviceutil + import win32service + import win32event + import servicemanager + import win32api + + WIN32_AVAILABLE = True +except ImportError: + WIN32_AVAILABLE = False + print("pywin32 not installed. Install with: pip install pywin32") + + +class MediaServerService: + """Windows service wrapper for the Media Server.""" + + _svc_name_ = "MediaServer" + _svc_display_name_ = "Media Server" + _svc_description_ = "REST API server for controlling system media playback" + + def __init__(self, args=None): + if WIN32_AVAILABLE: + win32serviceutil.ServiceFramework.__init__(self, args) + self.stop_event = win32event.CreateEvent(None, 0, 0, None) + self.is_running = False + self.server = None + + def SvcStop(self): + """Stop the service.""" + self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING) + win32event.SetEvent(self.stop_event) + self.is_running = False + if self.server: + self.server.should_exit = True + + def SvcDoRun(self): + """Run the service.""" + servicemanager.LogMsg( + servicemanager.EVENTLOG_INFORMATION_TYPE, + servicemanager.PYS_SERVICE_STARTED, + (self._svc_name_, ""), + ) + self.is_running = True + self.main() + + def main(self): + """Main service loop.""" + import uvicorn + from media_server.main import app + from media_server.config import settings + + config = uvicorn.Config( + app, + host=settings.host, + port=settings.port, + log_level=settings.log_level.lower(), + ) + self.server = uvicorn.Server(config) + self.server.run() + + +if WIN32_AVAILABLE: + # Dynamically inherit from ServiceFramework when available + MediaServerService = type( + "MediaServerService", + (win32serviceutil.ServiceFramework,), + dict(MediaServerService.__dict__), + ) + + +def install_service(): + """Install the Windows service.""" + if not WIN32_AVAILABLE: + print("Error: pywin32 is required for Windows service installation") + print("Install with: pip install pywin32") + return False + + try: + # Get the path to the Python executable + python_exe = sys.executable + + # Get the path to this module + module_path = os.path.abspath(__file__) + + win32serviceutil.InstallService( + MediaServerService._svc_name_, + MediaServerService._svc_name_, + MediaServerService._svc_display_name_, + startType=win32service.SERVICE_AUTO_START, + description=MediaServerService._svc_description_, + ) + print(f"Service '{MediaServerService._svc_display_name_}' installed successfully") + print("Start the service with: sc start MediaServer") + return True + except Exception as e: + print(f"Failed to install service: {e}") + return False + + +def remove_service(): + """Remove the Windows service.""" + if not WIN32_AVAILABLE: + print("Error: pywin32 is required") + return False + + try: + win32serviceutil.RemoveService(MediaServerService._svc_name_) + print(f"Service '{MediaServerService._svc_display_name_}' removed successfully") + return True + except Exception as e: + print(f"Failed to remove service: {e}") + return False + + +def main(): + """Main entry point for service management.""" + if not WIN32_AVAILABLE: + print("Error: pywin32 is required for Windows service support") + print("Install with: pip install pywin32") + sys.exit(1) + + if len(sys.argv) == 1: + # Running as a service + servicemanager.Initialize() + servicemanager.PrepareToHostSingle(MediaServerService) + servicemanager.StartServiceCtrlDispatcher() + else: + # Command line management + win32serviceutil.HandleCommandLine(MediaServerService) + + +if __name__ == "__main__": + main() diff --git a/media_server/service/media-server.service b/media_server/service/media-server.service new file mode 100644 index 0000000..ba06537 --- /dev/null +++ b/media_server/service/media-server.service @@ -0,0 +1,36 @@ +[Unit] +Description=Media Server - REST API for controlling system media playback +After=network.target sound.target +Wants=sound.target + +[Service] +Type=simple +User=%i +Group=%i + +# Environment variables (optional - can also use config file) +# Environment=MEDIA_SERVER_HOST=0.0.0.0 +# Environment=MEDIA_SERVER_PORT=8765 +# Environment=MEDIA_SERVER_API_TOKEN=your-secret-token + +# Working directory +WorkingDirectory=/opt/media-server + +# Start command - adjust path to your Python environment +ExecStart=/opt/media-server/venv/bin/python -m media_server.main + +# Restart policy +Restart=always +RestartSec=10 + +# Security hardening +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=read-only +PrivateTmp=true + +# Required for D-Bus access (MPRIS) +Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%U/bus + +[Install] +WantedBy=multi-user.target diff --git a/media_server/services/__init__.py b/media_server/services/__init__.py new file mode 100644 index 0000000..22f16ea --- /dev/null +++ b/media_server/services/__init__.py @@ -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"] diff --git a/media_server/services/android_media.py b/media_server/services/android_media.py new file mode 100644 index 0000000..df4c991 --- /dev/null +++ b/media_server/services/android_media.py @@ -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 diff --git a/media_server/services/linux_media.py b/media_server/services/linux_media.py new file mode 100644 index 0000000..e9d1f1d --- /dev/null +++ b/media_server/services/linux_media.py @@ -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 diff --git a/media_server/services/macos_media.py b/media_server/services/macos_media.py new file mode 100644 index 0000000..edb390b --- /dev/null +++ b/media_server/services/macos_media.py @@ -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 diff --git a/media_server/services/media_controller.py b/media_server/services/media_controller.py new file mode 100644 index 0000000..0c8ce62 --- /dev/null +++ b/media_server/services/media_controller.py @@ -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 diff --git a/media_server/services/windows_media.py b/media_server/services/windows_media.py new file mode 100644 index 0000000..ed9186e --- /dev/null +++ b/media_server/services/windows_media.py @@ -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