From fb7155583766c899bf831110c98e82cc6f7f203c Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Wed, 4 Feb 2026 14:42:09 +0300 Subject: [PATCH] Convert subprojects to git submodules - haos-integration -> haos-hacs-emby-media-player.git - media-server -> media-player.git Each component is now an independent repository that can be developed and versioned separately. Co-Authored-By: Claude Opus 4.5 --- .gitmodules | 6 + haos-integration | 1 + haos-integration/.gitignore | 48 -- haos-integration/CLAUDE.md | 25 - haos-integration/README.md | 98 --- .../remote_media_player/README.md | 324 ---------- .../remote_media_player/__init__.py | 164 ----- .../remote_media_player/api_client.py | 407 ------------ .../remote_media_player/button.py | 134 ---- .../remote_media_player/config_flow.py | 224 ------- .../remote_media_player/const.py | 40 -- .../remote_media_player/manifest.json | 12 - .../remote_media_player/media_player.py | 452 ------------- .../remote_media_player/services.yaml | 18 - .../remote_media_player/strings.json | 61 -- .../remote_media_player/translations/en.json | 61 -- .../remote_media_player/translations/ru.json | 61 -- haos-integration/hacs.json | 5 - media-server | 1 + media-server/.gitignore | 48 -- media-server/CLAUDE.md | 39 -- media-server/README.md | 383 ----------- media-server/config.example.yaml | 47 -- media-server/media_server/__init__.py | 3 - media-server/media_server/auth.py | 111 ---- media-server/media_server/config.py | 142 ----- media-server/media_server/main.py | 123 ---- media-server/media_server/models/__init__.py | 17 - media-server/media_server/models/media.py | 61 -- media-server/media_server/routes/__init__.py | 7 - media-server/media_server/routes/health.py | 22 - media-server/media_server/routes/media.py | 242 ------- media-server/media_server/routes/scripts.py | 169 ----- .../media_server/service/install_linux.sh | 144 ----- .../service/install_task_windows.ps1 | 10 - .../media_server/service/install_windows.py | 151 ----- .../media_server/service/media-server.service | 36 -- .../media_server/services/__init__.py | 75 --- .../media_server/services/android_media.py | 232 ------- .../media_server/services/linux_media.py | 295 --------- .../media_server/services/macos_media.py | 296 --------- .../media_server/services/media_controller.py | 96 --- .../services/websocket_manager.py | 189 ------ .../media_server/services/windows_media.py | 596 ------------------ media-server/requirements.txt | 28 - 45 files changed, 8 insertions(+), 5696 deletions(-) create mode 100644 .gitmodules create mode 160000 haos-integration delete mode 100644 haos-integration/.gitignore delete mode 100644 haos-integration/CLAUDE.md delete mode 100644 haos-integration/README.md delete mode 100644 haos-integration/custom_components/remote_media_player/README.md delete mode 100644 haos-integration/custom_components/remote_media_player/__init__.py delete mode 100644 haos-integration/custom_components/remote_media_player/api_client.py delete mode 100644 haos-integration/custom_components/remote_media_player/button.py delete mode 100644 haos-integration/custom_components/remote_media_player/config_flow.py delete mode 100644 haos-integration/custom_components/remote_media_player/const.py delete mode 100644 haos-integration/custom_components/remote_media_player/manifest.json delete mode 100644 haos-integration/custom_components/remote_media_player/media_player.py delete mode 100644 haos-integration/custom_components/remote_media_player/services.yaml delete mode 100644 haos-integration/custom_components/remote_media_player/strings.json delete mode 100644 haos-integration/custom_components/remote_media_player/translations/en.json delete mode 100644 haos-integration/custom_components/remote_media_player/translations/ru.json delete mode 100644 haos-integration/hacs.json create mode 160000 media-server delete mode 100644 media-server/.gitignore delete mode 100644 media-server/CLAUDE.md delete mode 100644 media-server/README.md delete mode 100644 media-server/config.example.yaml delete mode 100644 media-server/media_server/__init__.py delete mode 100644 media-server/media_server/auth.py delete mode 100644 media-server/media_server/config.py delete mode 100644 media-server/media_server/main.py delete mode 100644 media-server/media_server/models/__init__.py delete mode 100644 media-server/media_server/models/media.py delete mode 100644 media-server/media_server/routes/__init__.py delete mode 100644 media-server/media_server/routes/health.py delete mode 100644 media-server/media_server/routes/media.py delete mode 100644 media-server/media_server/routes/scripts.py delete mode 100644 media-server/media_server/service/install_linux.sh delete mode 100644 media-server/media_server/service/install_task_windows.ps1 delete mode 100644 media-server/media_server/service/install_windows.py delete mode 100644 media-server/media_server/service/media-server.service delete mode 100644 media-server/media_server/services/__init__.py delete mode 100644 media-server/media_server/services/android_media.py delete mode 100644 media-server/media_server/services/linux_media.py delete mode 100644 media-server/media_server/services/macos_media.py delete mode 100644 media-server/media_server/services/media_controller.py delete mode 100644 media-server/media_server/services/websocket_manager.py delete mode 100644 media-server/media_server/services/windows_media.py delete mode 100644 media-server/requirements.txt diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..e99ebca --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "haos-integration"] + path = haos-integration + url = https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-emby-media-player.git +[submodule "media-server"] + path = media-server + url = https://git.dolgolyov-family.by/alexei.dolgolyov/media-player.git diff --git a/haos-integration b/haos-integration new file mode 160000 index 0000000..7837714 --- /dev/null +++ b/haos-integration @@ -0,0 +1 @@ +Subproject commit 783771489a6b3bef3c077710a0ac256d9517617d diff --git a/haos-integration/.gitignore b/haos-integration/.gitignore deleted file mode 100644 index e9a10d7..0000000 --- a/haos-integration/.gitignore +++ /dev/null @@ -1,48 +0,0 @@ -# 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/haos-integration/CLAUDE.md b/haos-integration/CLAUDE.md deleted file mode 100644 index 461bf90..0000000 --- a/haos-integration/CLAUDE.md +++ /dev/null @@ -1,25 +0,0 @@ -# HAOS Integration - Development Guide - -## Overview - -HACS-ready Home Assistant custom integration for controlling remote PC media playback. - -## Installation - -Copy `custom_components/remote_media_player/` to your Home Assistant config folder. - -Or install via HACS as a custom repository. - -## Requirements - -Requires Media Server running on the target PC. - -Media Server Repository: `TODO: Add repository URL` - -## Integration Location - -Integration files location: `U:\custom_components\remote_media_player` - -## Git Rules - -Always ask for user approval before committing changes to git. diff --git a/haos-integration/README.md b/haos-integration/README.md deleted file mode 100644 index 0d32bc7..0000000 --- a/haos-integration/README.md +++ /dev/null @@ -1,98 +0,0 @@ -# Remote Media Player - Home Assistant Integration - -[![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration) -[![GitHub Release](https://img.shields.io/github/v/release/YOUR_USERNAME/haos-remote-media-player)](https://github.com/YOUR_USERNAME/haos-remote-media-player/releases) - -A Home Assistant custom integration 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 with smooth timeline updates -- Displays current track info (title, artist, album, artwork) -- Real-time updates via WebSocket (with HTTP polling fallback) -- **Script buttons** - Execute pre-defined scripts (shutdown, restart, lock, sleep, etc.) -- Configurable via Home Assistant UI - -## Requirements - -- Home Assistant 2024.1.0 or newer -- A running **Media Server** on your PC - -### Media Server - -This integration requires the Media Server to be running on the PC you want to control. - -**Media Server Repository:** `TODO: Add repository URL` - -See the Media Server documentation for installation and setup instructions. - -## Installation - -### HACS (Recommended) - -1. Open HACS in Home Assistant -2. Click the three dots menu > **Custom repositories** -3. Add this repository URL: `https://github.com/YOUR_USERNAME/haos-remote-media-player` -4. Select category: **Integration** -5. Click **Add** -6. Search for "Remote Media Player" and click **Download** -7. Restart Home Assistant - -### Manual Installation - -1. Download the latest release from the [Releases](https://github.com/YOUR_USERNAME/haos-remote-media-player/releases) page -2. Extract and copy the `custom_components/remote_media_player` folder to your Home Assistant `config/custom_components/` directory -3. Restart Home Assistant - -## Configuration - -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 Media Server - - **Name**: Display name for this media player (optional) - -## Usage - -Once configured, the integration creates: - -### Media Player Entity - -A full-featured media player entity with: -- Play/Pause/Stop controls -- Next/Previous track -- Volume control and mute -- Seek functionality -- Current track information - -### Script Button Entities - -Button entities for each script defined on your Media Server: -- Lock/unlock workstation -- Shutdown, restart, sleep, hibernate -- Custom scripts - -## Example Lovelace Card - -```yaml -type: media-control -entity: media_player.remote_media_player -``` - -## Documentation - -For detailed documentation, see [custom_components/remote_media_player/README.md](custom_components/remote_media_player/README.md). - -## Support - -- [Report an Issue](https://github.com/YOUR_USERNAME/haos-remote-media-player/issues) -- [Media Server Repository](TODO) - -## License - -MIT License diff --git a/haos-integration/custom_components/remote_media_player/README.md b/haos-integration/custom_components/remote_media_player/README.md deleted file mode 100644 index 6a433a5..0000000 --- a/haos-integration/custom_components/remote_media_player/README.md +++ /dev/null @@ -1,324 +0,0 @@ -# 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 2024.1.0 or newer -- A running Media Server on your PC (see Media Server repository) - -## 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/haos-integration/custom_components/remote_media_player/__init__.py b/haos-integration/custom_components/remote_media_player/__init__.py deleted file mode 100644 index f71f3bb..0000000 --- a/haos-integration/custom_components/remote_media_player/__init__.py +++ /dev/null @@ -1,164 +0,0 @@ -"""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) - - # Shutdown coordinator (WebSocket cleanup) - if "coordinator" in data: - await data["coordinator"].async_shutdown() - - # Close HTTP client - 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/haos-integration/custom_components/remote_media_player/api_client.py b/haos-integration/custom_components/remote_media_player/api_client.py deleted file mode 100644 index 7dbcfc0..0000000 --- a/haos-integration/custom_components/remote_media_player/api_client.py +++ /dev/null @@ -1,407 +0,0 @@ -"""API client for communicating with the Media Server.""" - -from __future__ import annotations - -import asyncio -import hashlib -import logging -from collections.abc import Callable -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) - - -class MediaServerWebSocket: - """WebSocket client for real-time media status updates.""" - - def __init__( - self, - host: str, - port: int, - token: str, - on_status_update: Callable[[dict[str, Any]], None], - on_disconnect: Callable[[], None] | None = None, - ) -> None: - """Initialize the WebSocket client. - - Args: - host: Server hostname or IP - port: Server port - token: API authentication token - on_status_update: Callback when status update received - on_disconnect: Callback when connection lost - """ - self._host = host - self._port = int(port) - self._token = token - self._on_status_update = on_status_update - self._on_disconnect = on_disconnect - self._ws_url = f"ws://{host}:{self._port}/api/media/ws?token={token}" - self._session: aiohttp.ClientSession | None = None - self._ws: aiohttp.ClientWebSocketResponse | None = None - self._receive_task: asyncio.Task | None = None - self._running = False - - async def connect(self) -> bool: - """Establish WebSocket connection. - - Returns: - True if connection successful - """ - try: - if self._session is None or self._session.closed: - self._session = aiohttp.ClientSession() - - self._ws = await self._session.ws_connect( - self._ws_url, - heartbeat=30, - timeout=aiohttp.ClientTimeout(total=10), - ) - self._running = True - - # Start receive loop - self._receive_task = asyncio.create_task(self._receive_loop()) - - _LOGGER.info("WebSocket connected to %s:%s", self._host, self._port) - return True - - except Exception as err: - _LOGGER.warning("WebSocket connection failed: %s", err) - return False - - async def disconnect(self) -> None: - """Close WebSocket connection.""" - self._running = False - - if self._receive_task: - self._receive_task.cancel() - try: - await self._receive_task - except asyncio.CancelledError: - pass - self._receive_task = None - - if self._ws and not self._ws.closed: - await self._ws.close() - self._ws = None - - if self._session and not self._session.closed: - await self._session.close() - self._session = None - - _LOGGER.debug("WebSocket disconnected") - - async def _receive_loop(self) -> None: - """Background loop to receive WebSocket messages.""" - while self._running and self._ws and not self._ws.closed: - try: - msg = await self._ws.receive(timeout=60) - - if msg.type == aiohttp.WSMsgType.TEXT: - data = msg.json() - msg_type = data.get("type") - - if msg_type in ("status", "status_update"): - status_data = data.get("data", {}) - # Convert album art URL to absolute - if ( - status_data.get("album_art_url") - and status_data["album_art_url"].startswith("/") - ): - track_id = f"{status_data.get('title', '')}-{status_data.get('artist', '')}" - track_hash = hashlib.md5(track_id.encode()).hexdigest()[:8] - status_data["album_art_url"] = ( - f"http://{self._host}:{self._port}" - f"{status_data['album_art_url']}?token={self._token}&t={track_hash}" - ) - self._on_status_update(status_data) - elif msg_type == "pong": - _LOGGER.debug("Received pong") - - elif msg.type == aiohttp.WSMsgType.CLOSED: - _LOGGER.warning("WebSocket closed by server") - break - elif msg.type == aiohttp.WSMsgType.ERROR: - _LOGGER.error("WebSocket error: %s", self._ws.exception()) - break - - except asyncio.TimeoutError: - # Send ping to keep connection alive - if self._ws and not self._ws.closed: - try: - await self._ws.send_json({"type": "ping"}) - except Exception: - break - except asyncio.CancelledError: - break - except Exception as err: - _LOGGER.error("WebSocket receive error: %s", err) - break - - # Connection lost, notify callback - if self._on_disconnect: - self._on_disconnect() - - @property - def is_connected(self) -> bool: - """Return True if WebSocket is connected.""" - return self._ws is not None and not self._ws.closed diff --git a/haos-integration/custom_components/remote_media_player/button.py b/haos-integration/custom_components/remote_media_player/button.py deleted file mode 100644 index 8be1e76..0000000 --- a/haos-integration/custom_components/remote_media_player/button.py +++ /dev/null @@ -1,134 +0,0 @@ -"""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", ""), - script_icon=script.get("icon"), - ) - 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, - script_icon: str | None = None, - ) -> 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 - # Use custom icon if provided, otherwise auto-resolve from script name - self._attr_icon = script_icon or 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/haos-integration/custom_components/remote_media_player/config_flow.py b/haos-integration/custom_components/remote_media_player/config_flow.py deleted file mode 100644 index bd54f09..0000000 --- a/haos-integration/custom_components/remote_media_player/config_flow.py +++ /dev/null @@ -1,224 +0,0 @@ -"""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/haos-integration/custom_components/remote_media_player/const.py b/haos-integration/custom_components/remote_media_player/const.py deleted file mode 100644 index c9dafd5..0000000 --- a/haos-integration/custom_components/remote_media_player/const.py +++ /dev/null @@ -1,40 +0,0 @@ -"""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" -CONF_USE_WEBSOCKET = "use_websocket" - -# Default values -DEFAULT_PORT = 8765 -DEFAULT_POLL_INTERVAL = 5 -DEFAULT_NAME = "Remote Media Player" -DEFAULT_USE_WEBSOCKET = True -DEFAULT_RECONNECT_INTERVAL = 30 - -# 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" -API_WEBSOCKET = "/api/media/ws" - -# Service names -SERVICE_EXECUTE_SCRIPT = "execute_script" - -# Service attributes -ATTR_SCRIPT_NAME = "script_name" -ATTR_SCRIPT_ARGS = "args" diff --git a/haos-integration/custom_components/remote_media_player/manifest.json b/haos-integration/custom_components/remote_media_player/manifest.json deleted file mode 100644 index 725b890..0000000 --- a/haos-integration/custom_components/remote_media_player/manifest.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "domain": "remote_media_player", - "name": "Remote Media Player", - "codeowners": [], - "config_flow": true, - "dependencies": [], - "documentation": "https://github.com/YOUR_USERNAME/haos-remote-media-player", - "integration_type": "device", - "iot_class": "local_push", - "requirements": ["aiohttp>=3.8.0"], - "version": "1.0.0" -} diff --git a/haos-integration/custom_components/remote_media_player/media_player.py b/haos-integration/custom_components/remote_media_player/media_player.py deleted file mode 100644 index 319afb7..0000000 --- a/haos-integration/custom_components/remote_media_player/media_player.py +++ /dev/null @@ -1,452 +0,0 @@ -"""Media player platform for Remote Media Player integration.""" - -from __future__ import annotations - -import asyncio -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, callback -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, MediaServerWebSocket -from .const import ( - DOMAIN, - CONF_HOST, - CONF_PORT, - CONF_TOKEN, - CONF_POLL_INTERVAL, - CONF_USE_WEBSOCKET, - DEFAULT_POLL_INTERVAL, - DEFAULT_NAME, - DEFAULT_USE_WEBSOCKET, - DEFAULT_RECONNECT_INTERVAL, -) - -_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), - ) - - # Get WebSocket setting from options or data - use_websocket = entry.options.get( - CONF_USE_WEBSOCKET, - entry.data.get(CONF_USE_WEBSOCKET, DEFAULT_USE_WEBSOCKET), - ) - - # Create update coordinator with WebSocket support - coordinator = MediaPlayerCoordinator( - hass, - client, - poll_interval, - host=entry.data[CONF_HOST], - port=entry.data[CONF_PORT], - token=entry.data[CONF_TOKEN], - use_websocket=use_websocket, - ) - - # Set up WebSocket connection if enabled - await coordinator.async_setup() - - # 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 - - # Store coordinator for cleanup - hass.data[DOMAIN][entry.entry_id]["coordinator"] = coordinator - - # 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 with WebSocket support.""" - - def __init__( - self, - hass: HomeAssistant, - client: MediaServerClient, - poll_interval: int, - host: str, - port: int, - token: str, - use_websocket: bool = True, - ) -> None: - """Initialize the coordinator. - - Args: - hass: Home Assistant instance - client: Media Server API client - poll_interval: Update interval in seconds - host: Server hostname - port: Server port - token: API token - use_websocket: Whether to use WebSocket for updates - """ - super().__init__( - hass, - _LOGGER, - name="Remote Media Player", - update_interval=timedelta(seconds=poll_interval), - ) - self.client = client - self._host = host - self._port = port - self._token = token - self._use_websocket = use_websocket - self._ws_client: MediaServerWebSocket | None = None - self._ws_connected = False - self._reconnect_task: asyncio.Task | None = None - self._poll_interval = poll_interval - - async def async_setup(self) -> None: - """Set up the coordinator with WebSocket if enabled.""" - if self._use_websocket: - await self._connect_websocket() - - async def _connect_websocket(self) -> None: - """Establish WebSocket connection.""" - if self._ws_client: - await self._ws_client.disconnect() - - self._ws_client = MediaServerWebSocket( - host=self._host, - port=self._port, - token=self._token, - on_status_update=self._handle_ws_status_update, - on_disconnect=self._handle_ws_disconnect, - ) - - if await self._ws_client.connect(): - self._ws_connected = True - # Disable polling - WebSocket handles all updates including position - self.update_interval = None - _LOGGER.info("WebSocket connected, polling disabled") - else: - self._ws_connected = False - # Keep polling as fallback - self.update_interval = timedelta(seconds=self._poll_interval) - _LOGGER.warning("WebSocket failed, falling back to polling") - # Schedule reconnect attempt - self._schedule_reconnect() - - @callback - def _handle_ws_status_update(self, status_data: dict[str, Any]) -> None: - """Handle status update from WebSocket.""" - self.async_set_updated_data(status_data) - - @callback - def _handle_ws_disconnect(self) -> None: - """Handle WebSocket disconnection.""" - self._ws_connected = False - # Re-enable polling as fallback - self.update_interval = timedelta(seconds=self._poll_interval) - _LOGGER.warning("WebSocket disconnected, falling back to polling") - # Schedule reconnect attempt - self._schedule_reconnect() - - def _schedule_reconnect(self) -> None: - """Schedule a WebSocket reconnection attempt.""" - if self._reconnect_task and not self._reconnect_task.done(): - return # Already scheduled - - async def reconnect() -> None: - await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL) - if self._use_websocket and not self._ws_connected: - _LOGGER.info("Attempting WebSocket reconnect...") - await self._connect_websocket() - - self._reconnect_task = self.hass.async_create_task(reconnect()) - - async def _async_update_data(self) -> dict[str, Any]: - """Fetch data from the API (fallback when WebSocket unavailable). - - Returns: - Media status data - - Raises: - UpdateFailed: On API errors - """ - try: - data = await self.client.get_status() - _LOGGER.debug("HTTP poll received status: %s", data.get("state")) - 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 - - async def async_shutdown(self) -> None: - """Clean up resources.""" - if self._reconnect_task: - self._reconnect_task.cancel() - try: - await self._reconnect_task - except asyncio.CancelledError: - pass - if self._ws_client: - await self._ws_client.disconnect() - - -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/haos-integration/custom_components/remote_media_player/services.yaml b/haos-integration/custom_components/remote_media_player/services.yaml deleted file mode 100644 index 3f22882..0000000 --- a/haos-integration/custom_components/remote_media_player/services.yaml +++ /dev/null @@ -1,18 +0,0 @@ -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/haos-integration/custom_components/remote_media_player/strings.json b/haos-integration/custom_components/remote_media_player/strings.json deleted file mode 100644 index 1609d63..0000000 --- a/haos-integration/custom_components/remote_media_player/strings.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "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/haos-integration/custom_components/remote_media_player/translations/en.json b/haos-integration/custom_components/remote_media_player/translations/en.json deleted file mode 100644 index 1609d63..0000000 --- a/haos-integration/custom_components/remote_media_player/translations/en.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "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/haos-integration/custom_components/remote_media_player/translations/ru.json b/haos-integration/custom_components/remote_media_player/translations/ru.json deleted file mode 100644 index 90cbac2..0000000 --- a/haos-integration/custom_components/remote_media_player/translations/ru.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "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/haos-integration/hacs.json b/haos-integration/hacs.json deleted file mode 100644 index c5a2abf..0000000 --- a/haos-integration/hacs.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "Remote Media Player", - "render_readme": true, - "homeassistant": "2024.1.0" -} diff --git a/media-server b/media-server new file mode 160000 index 0000000..83acf5f --- /dev/null +++ b/media-server @@ -0,0 +1 @@ +Subproject commit 83acf5f1ec62f135def2601dd8a64911697d39b8 diff --git a/media-server/.gitignore b/media-server/.gitignore deleted file mode 100644 index e9a10d7..0000000 --- a/media-server/.gitignore +++ /dev/null @@ -1,48 +0,0 @@ -# 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/media-server/CLAUDE.md b/media-server/CLAUDE.md deleted file mode 100644 index c642099..0000000 --- a/media-server/CLAUDE.md +++ /dev/null @@ -1,39 +0,0 @@ -# Media Server - Development Guide - -## Overview - -Standalone REST API server (FastAPI) for controlling system-wide media playback on Windows, Linux, macOS, and Android. - -## Running the Server - -### Manual Start - -```bash -python -m media_server.main -``` - -### Auto-Start on Boot (Windows Task Scheduler) - -Run in **Administrator PowerShell** from the media-server directory: - -```powershell -.\media_server\service\install_task_windows.ps1 -``` - -To remove the scheduled task: - -```powershell -Unregister-ScheduledTask -TaskName "MediaServer" -Confirm:$false -``` - -## Configuration - -Copy `config.example.yaml` to `config.yaml` and customize. - -The API token is generated on first run and displayed in the console output. - -Default port: `8765` - -## Git Rules - -Always ask for user approval before committing changes to git. diff --git a/media-server/README.md b/media-server/README.md deleted file mode 100644 index d36500a..0000000 --- a/media-server/README.md +++ /dev/null @@ -1,383 +0,0 @@ -# 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 | -| `icon` | No | Custom MDI icon (e.g., `mdi:power`) | -| `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/config.example.yaml b/media-server/config.example.yaml deleted file mode 100644 index 48e3433..0000000 --- a/media-server/config.example.yaml +++ /dev/null @@ -1,47 +0,0 @@ -# Media Server Configuration -# Copy this file to config.yaml and customize as needed. -# A secure token will be auto-generated on first run if not specified. - -# API Token (generate a secure random token) -api_token: "your-secure-token-here" - -# Server settings -host: "0.0.0.0" -port: 8765 - -# Custom scripts -scripts: - lock_screen: - command: "rundll32.exe user32.dll,LockWorkStation" - label: "Lock Screen" - description: "Lock the workstation" - timeout: 5 - 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 - - 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 immediately" - timeout: 10 - shell: true \ No newline at end of file diff --git a/media-server/media_server/__init__.py b/media-server/media_server/__init__.py deleted file mode 100644 index 385c459..0000000 --- a/media-server/media_server/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Media Server - REST API for controlling system media playback.""" - -__version__ = "1.0.0" diff --git a/media-server/media_server/auth.py b/media-server/media_server/auth.py deleted file mode 100644 index 8b70090..0000000 --- a/media-server/media_server/auth.py +++ /dev/null @@ -1,111 +0,0 @@ -"""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/media_server/config.py b/media-server/media_server/config.py deleted file mode 100644 index 8b0b8ed..0000000 --- a/media-server/media_server/config.py +++ /dev/null @@ -1,142 +0,0 @@ -"""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") - icon: Optional[str] = Field(default=None, description="Custom icon (e.g., 'mdi:power')") - 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/media_server/main.py b/media-server/media_server/main.py deleted file mode 100644 index 1cecaa6..0000000 --- a/media-server/media_server/main.py +++ /dev/null @@ -1,123 +0,0 @@ -"""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 -from .services import get_media_controller -from .services.websocket_manager import ws_manager - - -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]}...") - - # Start WebSocket status monitor - controller = get_media_controller() - await ws_manager.start_status_monitor(controller.get_status) - logger.info("WebSocket status monitor started") - - yield - - # Stop WebSocket status monitor - await ws_manager.stop_status_monitor() - 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/media_server/models/__init__.py b/media-server/media_server/models/__init__.py deleted file mode 100644 index abf7966..0000000 --- a/media-server/media_server/models/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""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/media_server/models/media.py b/media-server/media_server/models/media.py deleted file mode 100644 index 2241e97..0000000 --- a/media-server/media_server/models/media.py +++ /dev/null @@ -1,61 +0,0 @@ -"""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/media_server/routes/__init__.py b/media-server/media_server/routes/__init__.py deleted file mode 100644 index 0fd6e84..0000000 --- a/media-server/media_server/routes/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""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/media_server/routes/health.py b/media-server/media_server/routes/health.py deleted file mode 100644 index 092e8b0..0000000 --- a/media-server/media_server/routes/health.py +++ /dev/null @@ -1,22 +0,0 @@ -"""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/media_server/routes/media.py b/media-server/media_server/routes/media.py deleted file mode 100644 index 618d0aa..0000000 --- a/media-server/media_server/routes/media.py +++ /dev/null @@ -1,242 +0,0 @@ -"""Media control API endpoints.""" - -import logging - -from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect -from fastapi import status -from fastapi.responses import Response - -from ..auth import verify_token, verify_token_or_query -from ..config import settings -from ..models import MediaStatus, VolumeRequest, SeekRequest -from ..services import get_media_controller, get_current_album_art -from ..services.websocket_manager import ws_manager - -logger = logging.getLogger(__name__) - -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) - - -@router.websocket("/ws") -async def websocket_endpoint( - websocket: WebSocket, - token: str = Query(..., description="API authentication token"), -) -> None: - """WebSocket endpoint for real-time media status updates. - - Authentication is done via query parameter since WebSocket - doesn't support custom headers in the browser. - - Messages sent to client: - - {"type": "status", "data": {...}} - Initial status on connect - - {"type": "status_update", "data": {...}} - Status changes - - {"type": "error", "message": "..."} - Error messages - - Client can send: - - {"type": "ping"} - Keepalive, server responds with {"type": "pong"} - - {"type": "get_status"} - Request current status - """ - # Verify token - if token != settings.api_token: - await websocket.close(code=4001, reason="Invalid authentication token") - return - - await ws_manager.connect(websocket) - - try: - while True: - # Wait for messages from client (for keepalive/ping) - data = await websocket.receive_json() - - if data.get("type") == "ping": - await websocket.send_json({"type": "pong"}) - elif data.get("type") == "get_status": - # Allow manual status request - controller = get_media_controller() - status_data = await controller.get_status() - await websocket.send_json({ - "type": "status", - "data": status_data.model_dump(), - }) - - except WebSocketDisconnect: - await ws_manager.disconnect(websocket) - except Exception as e: - logger.error("WebSocket error: %s", e) - await ws_manager.disconnect(websocket) diff --git a/media-server/media_server/routes/scripts.py b/media-server/media_server/routes/scripts.py deleted file mode 100644 index ae7d743..0000000 --- a/media-server/media_server/routes/scripts.py +++ /dev/null @@ -1,169 +0,0 @@ -"""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 - icon: str | None = None - 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, - icon=config.icon, - 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/media_server/service/install_linux.sh b/media-server/media_server/service/install_linux.sh deleted file mode 100644 index d5f6b12..0000000 --- a/media-server/media_server/service/install_linux.sh +++ /dev/null @@ -1,144 +0,0 @@ -#!/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/media_server/service/install_task_windows.ps1 b/media-server/media_server/service/install_task_windows.ps1 deleted file mode 100644 index 4aca109..0000000 --- a/media-server/media_server/service/install_task_windows.ps1 +++ /dev/null @@ -1,10 +0,0 @@ -# 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/media_server/service/install_windows.py b/media-server/media_server/service/install_windows.py deleted file mode 100644 index f0edd87..0000000 --- a/media-server/media_server/service/install_windows.py +++ /dev/null @@ -1,151 +0,0 @@ -"""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/media_server/service/media-server.service b/media-server/media_server/service/media-server.service deleted file mode 100644 index ba06537..0000000 --- a/media-server/media_server/service/media-server.service +++ /dev/null @@ -1,36 +0,0 @@ -[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/media_server/services/__init__.py b/media-server/media_server/services/__init__.py deleted file mode 100644 index 22f16ea..0000000 --- a/media-server/media_server/services/__init__.py +++ /dev/null @@ -1,75 +0,0 @@ -"""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/media_server/services/android_media.py b/media-server/media_server/services/android_media.py deleted file mode 100644 index df4c991..0000000 --- a/media-server/media_server/services/android_media.py +++ /dev/null @@ -1,232 +0,0 @@ -"""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/media_server/services/linux_media.py b/media-server/media_server/services/linux_media.py deleted file mode 100644 index e9d1f1d..0000000 --- a/media-server/media_server/services/linux_media.py +++ /dev/null @@ -1,295 +0,0 @@ -"""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/media_server/services/macos_media.py b/media-server/media_server/services/macos_media.py deleted file mode 100644 index edb390b..0000000 --- a/media-server/media_server/services/macos_media.py +++ /dev/null @@ -1,296 +0,0 @@ -"""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/media_server/services/media_controller.py b/media-server/media_server/services/media_controller.py deleted file mode 100644 index 0c8ce62..0000000 --- a/media-server/media_server/services/media_controller.py +++ /dev/null @@ -1,96 +0,0 @@ -"""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/media_server/services/websocket_manager.py b/media-server/media_server/services/websocket_manager.py deleted file mode 100644 index 7289cb7..0000000 --- a/media-server/media_server/services/websocket_manager.py +++ /dev/null @@ -1,189 +0,0 @@ -"""WebSocket connection manager and status broadcaster.""" - -import asyncio -import logging -import time -from typing import Any, Callable, Coroutine - -from fastapi import WebSocket - -logger = logging.getLogger(__name__) - - -class ConnectionManager: - """Manages WebSocket connections and broadcasts status updates.""" - - def __init__(self) -> None: - """Initialize the connection manager.""" - self._active_connections: set[WebSocket] = set() - self._lock = asyncio.Lock() - self._last_status: dict[str, Any] | None = None - self._broadcast_task: asyncio.Task | None = None - self._poll_interval: float = 0.5 # Internal poll interval for change detection - self._position_broadcast_interval: float = 5.0 # Send position updates every 5s during playback - self._last_broadcast_time: float = 0.0 - self._running: bool = False - - async def connect(self, websocket: WebSocket) -> None: - """Accept a new WebSocket connection.""" - await websocket.accept() - async with self._lock: - self._active_connections.add(websocket) - logger.info( - "WebSocket client connected. Total: %d", len(self._active_connections) - ) - - # Send current status immediately upon connection - if self._last_status: - try: - await websocket.send_json({"type": "status", "data": self._last_status}) - except Exception as e: - logger.debug("Failed to send initial status: %s", e) - - async def disconnect(self, websocket: WebSocket) -> None: - """Remove a WebSocket connection.""" - async with self._lock: - self._active_connections.discard(websocket) - logger.info( - "WebSocket client disconnected. Total: %d", len(self._active_connections) - ) - - async def broadcast(self, message: dict[str, Any]) -> None: - """Broadcast a message to all connected clients.""" - async with self._lock: - connections = list(self._active_connections) - - if not connections: - return - - disconnected = [] - for websocket in connections: - try: - await websocket.send_json(message) - except Exception as e: - logger.debug("Failed to send to client: %s", e) - disconnected.append(websocket) - - # Clean up disconnected clients - for ws in disconnected: - await self.disconnect(ws) - - def status_changed( - self, old: dict[str, Any] | None, new: dict[str, Any] - ) -> bool: - """Detect if media status has meaningfully changed. - - Position is NOT included for normal playback (let HA interpolate). - But seeks (large unexpected jumps) are detected. - """ - if old is None: - return True - - # Fields to compare for changes (NO position - let HA interpolate) - significant_fields = [ - "state", - "title", - "artist", - "album", - "volume", - "muted", - "duration", - "source", - "album_art_url", - ] - - for field in significant_fields: - if old.get(field) != new.get(field): - return True - - # Detect seeks - large position jumps that aren't normal playback - old_pos = old.get("position") or 0 - new_pos = new.get("position") or 0 - pos_diff = new_pos - old_pos - - # During playback, position should increase by ~0.5s (our poll interval) - # A seek is when position jumps backwards OR forward by more than expected - if new.get("state") == "playing": - # Backward seek or forward jump > 3s indicates seek - if pos_diff < -1.0 or pos_diff > 3.0: - return True - else: - # When paused, any significant position change is a seek - if abs(pos_diff) > 1.0: - return True - - return False - - async def start_status_monitor( - self, - get_status_func: Callable[[], Coroutine[Any, Any, Any]], - ) -> None: - """Start the background status monitoring loop.""" - if self._running: - return - - self._running = True - self._broadcast_task = asyncio.create_task( - self._status_monitor_loop(get_status_func) - ) - logger.info("WebSocket status monitor started") - - async def stop_status_monitor(self) -> None: - """Stop the background status monitoring loop.""" - self._running = False - if self._broadcast_task: - self._broadcast_task.cancel() - try: - await self._broadcast_task - except asyncio.CancelledError: - pass - logger.info("WebSocket status monitor stopped") - - async def _status_monitor_loop( - self, - get_status_func: Callable[[], Coroutine[Any, Any, Any]], - ) -> None: - """Background loop that polls for status changes and broadcasts.""" - while self._running: - try: - # Only poll if we have connected clients - async with self._lock: - has_clients = len(self._active_connections) > 0 - - if has_clients: - status = await get_status_func() - status_dict = status.model_dump() - - # Only broadcast on actual state changes - # Let HA handle position interpolation during playback - if self.status_changed(self._last_status, status_dict): - self._last_status = status_dict - self._last_broadcast_time = time.time() - await self.broadcast( - {"type": "status_update", "data": status_dict} - ) - logger.debug("Broadcast sent: status change") - else: - # Update cached status even without broadcast - self._last_status = status_dict - else: - # Still update cache for when clients connect - status = await get_status_func() - self._last_status = status.model_dump() - - await asyncio.sleep(self._poll_interval) - - except asyncio.CancelledError: - break - except Exception as e: - logger.error("Error in status monitor: %s", e) - await asyncio.sleep(self._poll_interval) - - @property - def client_count(self) -> int: - """Return the number of connected clients.""" - return len(self._active_connections) - - -# Global instance -ws_manager = ConnectionManager() diff --git a/media-server/media_server/services/windows_media.py b/media-server/media_server/services/windows_media.py deleted file mode 100644 index ed9186e..0000000 --- a/media-server/media_server/services/windows_media.py +++ /dev/null @@ -1,596 +0,0 @@ -"""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 diff --git a/media-server/requirements.txt b/media-server/requirements.txt deleted file mode 100644 index 806405c..0000000 --- a/media-server/requirements.txt +++ /dev/null @@ -1,28 +0,0 @@ -# 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