From 46cb2fbac2f1dfa690a8156036b79f86d2e1c43f Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 5 Feb 2026 00:15:04 +0300 Subject: [PATCH] Initial commit for `Emby Media Player` HAOS HACS integration --- .github/ISSUE_TEMPLATE/bug_report.md | 46 ++ .github/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/feature_request.md | 27 ++ .github/PULL_REQUEST_TEMPLATE.md | 20 + .github/workflows/validate.yaml | 17 + .gitignore | 90 ++++ CLAUDE.md | 111 +++++ README.md | 155 +++++++ custom_components/emby_player/__init__.py | 135 ++++++ custom_components/emby_player/api.py | 392 ++++++++++++++++ custom_components/emby_player/browse_media.py | 231 ++++++++++ custom_components/emby_player/config_flow.py | 230 ++++++++++ custom_components/emby_player/const.py | 89 ++++ custom_components/emby_player/coordinator.py | 283 ++++++++++++ custom_components/emby_player/manifest.json | 12 + custom_components/emby_player/media_player.py | 421 ++++++++++++++++++ custom_components/emby_player/strings.json | 43 ++ .../emby_player/translations/en.json | 43 ++ custom_components/emby_player/websocket.py | 244 ++++++++++ hacs.json | 6 + 20 files changed, 2603 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/validate.yaml create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 custom_components/emby_player/__init__.py create mode 100644 custom_components/emby_player/api.py create mode 100644 custom_components/emby_player/browse_media.py create mode 100644 custom_components/emby_player/config_flow.py create mode 100644 custom_components/emby_player/const.py create mode 100644 custom_components/emby_player/coordinator.py create mode 100644 custom_components/emby_player/manifest.json create mode 100644 custom_components/emby_player/media_player.py create mode 100644 custom_components/emby_player/strings.json create mode 100644 custom_components/emby_player/translations/en.json create mode 100644 custom_components/emby_player/websocket.py create mode 100644 hacs.json diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..c44e870 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,46 @@ +--- +name: Bug Report +about: Report a bug or unexpected behavior +title: '' +labels: bug +assignees: '' +--- + +## Describe the Bug + +A clear description of what the bug is. + +## Environment + +- **Home Assistant version:** +- **Integration version:** +- **Emby Server version:** + +## Steps to Reproduce + +1. +2. +3. + +## Expected Behavior + +What you expected to happen. + +## Actual Behavior + +What actually happened. + +## Logs + +
+Relevant log entries + +``` +Paste logs here +``` + +
+ +## Additional Context + +Any other context about the problem. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..2023e90 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Home Assistant Community + url: https://community.home-assistant.io/ + about: Ask questions about Home Assistant + - name: Emby Documentation + url: https://emby.media/support + about: Emby official documentation and support diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..b0cbb31 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,27 @@ +--- +name: Feature Request +about: Suggest a new feature or enhancement +title: '' +labels: enhancement +assignees: '' +--- + +## Feature Description + +A clear description of what you would like to see added. + +## Use Case + +Describe the problem this feature would solve or the use case it enables. + +## Proposed Solution + +If you have ideas on how to implement this, describe them here. + +## Alternatives Considered + +Any alternative solutions or features you've considered. + +## Additional Context + +Any other context, screenshots, or examples. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..3dc4efa --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,20 @@ +## Description + +Brief description of the changes. + +## Type of Change + +- [ ] Bug fix +- [ ] New feature +- [ ] Documentation update +- [ ] Other (describe): + +## Testing + +Describe how you tested these changes. + +## Checklist + +- [ ] Code follows project style guidelines +- [ ] Changes have been tested locally +- [ ] Documentation updated (if applicable) diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml new file mode 100644 index 0000000..501b4eb --- /dev/null +++ b/.github/workflows/validate.yaml @@ -0,0 +1,17 @@ +name: Validate + +on: + push: + pull_request: + schedule: + - cron: "0 0 * * *" + workflow_dispatch: + +jobs: + hassfest: + name: Hassfest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: home-assistant/actions/hassfest@master + if: github.server_url == 'https://github.com' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9daff8d --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDEs +.idea/ +.vscode/ +*.swp +*.swo +*~ +.project +.pydevproject +.settings/ + +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# Windows +Thumbs.db +ehthumbs.db +Desktop.ini + +# Claude Code +.claude/ + +# Home Assistant +.HA_VERSION +home-assistant.log +home-assistant_v2.db +home-assistant_v2.db-shm +home-assistant_v2.db-wal diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0348acf --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,111 @@ +# Claude Code Session Notes + +This file documents mistakes and lessons learned during the development of this integration. + +## Mistakes Made + +### 1. Missing `/emby` Prefix on API Endpoints + +**Problem:** Initially created all API endpoints without the `/emby` prefix. + +```python +# Wrong +ENDPOINT_SYSTEM_INFO = "/System/Info" +ENDPOINT_USERS = "/Users" + +# Correct +ENDPOINT_SYSTEM_INFO = "/emby/System/Info" +ENDPOINT_USERS = "/emby/Users" +``` + +**Impact:** Connection to Emby server failed with "cannot connect" errors. + +**Lesson:** Always verify API endpoint formats against official documentation. Emby Server requires the `/emby` prefix for all API calls. + +--- + +### 2. Incorrect Volume Control API Format + +**Problem:** Tried multiple incorrect formats for the SetVolume command before finding the correct one. + +```python +# Attempt 1 - Wrong endpoint with body +endpoint = f"/Sessions/{session_id}/Command/SetVolume" +data = {"Arguments": {"Volume": 50}} + +# Attempt 2 - Wrong: query parameter +endpoint = f"/Sessions/{session_id}/Command/SetVolume?Volume=50" + +# Correct format +endpoint = f"/Sessions/{session_id}/Command" +data = {"Name": "SetVolume", "Arguments": {"Volume": "50"}} # Arguments as STRINGS +``` + +**Impact:** Volume control didn't work even though mute/unmute worked fine. + +**Lesson:** Emby general commands must be sent to `/Command` endpoint (not `/Command/{CommandName}`) with: +- `Name` field containing the command name +- `Arguments` as a dict with STRING values, not integers + +--- + +### 3. NumberSelector Returns Float, Not Int + +**Problem:** Home Assistant's `NumberSelector` widget returns float values, but port numbers must be integers. + +```python +# Wrong - port could be 8096.0 +self._port = user_input.get(CONF_PORT, DEFAULT_PORT) + +# Correct +self._port = int(user_input.get(CONF_PORT, DEFAULT_PORT)) +``` + +**Impact:** Potential type errors or malformed URLs with decimal port numbers. + +**Lesson:** Always explicitly convert NumberSelector values to the expected type. + +--- + +### 4. Inconsistent Use of Constants + +**Problem:** Hardcoded endpoint paths in some methods instead of using defined constants. + +```python +# Wrong - hardcoded +endpoint = f"/Sessions/{session_id}/Playing" + +# Correct - using constant +endpoint = f"{ENDPOINT_SESSIONS}/{session_id}/Playing" +``` + +**Impact:** When the `/emby` prefix was added to constants, hardcoded paths were missed, causing inconsistent behavior. + +**Lesson:** Always use constants consistently. When fixing issues, search for all occurrences of the affected strings. + +--- + +### 5. Import Confusion Between Local and HA Constants + +**Problem:** Initially imported `CONF_HOST` and `CONF_PORT` from `homeassistant.const` in some files, while also defining them in local `const.py`. + +```python +# Inconsistent imports +from homeassistant.const import CONF_HOST, CONF_PORT # in __init__.py +from .const import CONF_HOST, CONF_PORT # in config_flow.py +``` + +**Impact:** Potential confusion and maintenance issues, though values were identical. + +**Lesson:** Choose one source for constants and use it consistently across all files. For custom integrations, prefer local constants for full control. + +--- + +## Best Practices Established + +1. **Test API endpoints with curl first** - Verify the exact request format before implementing in code +2. **Add debug logging** - Include request URLs and response status codes for troubleshooting +3. **Handle multiple API formats** - Some servers may respond differently; implement fallbacks when sensible +4. **Type conversion** - Always explicitly convert UI input values to expected types +5. **Consistent constant usage** - Define constants once and use them everywhere +6. **Wait for user approval before committing** - Always let the user test changes before creating git commits diff --git a/README.md b/README.md new file mode 100644 index 0000000..a6ab260 --- /dev/null +++ b/README.md @@ -0,0 +1,155 @@ +# Emby Media Player + +[![hacs_badge](https://img.shields.io/badge/HACS-Custom-orange.svg)](https://github.com/hacs/integration) + +A Home Assistant custom integration that exposes Emby media server clients as media players with full playback control, media metadata, and library browsing capabilities. + +## Features + +- **Media Player Control**: Play, pause, stop, seek, volume control, mute, next/previous track +- **Real-time Updates**: WebSocket connection for instant state synchronization with polling fallback +- **Media Metadata**: Display currently playing media information including: + - Title, artist, album (for music) + - Series name, season, episode (for TV shows) + - Thumbnail/artwork + - Duration and playback position +- **Media Browser**: Browse your Emby library directly from Home Assistant + - Navigate through Movies, TV Shows, Music libraries + - Play any media directly from the browser +- **Dynamic Session Discovery**: Automatically discovers and creates media player entities for active Emby clients + +## Installation + +### HACS (Recommended) + +1. Open HACS in Home Assistant +2. Click on "Integrations" +3. Click the three dots menu and select "Custom repositories" +4. Add this repository URL and select "Integration" as the category +5. Click "Install" +6. Restart Home Assistant + +### Manual Installation + +1. Download the `custom_components/emby_player` folder +2. Copy it to your Home Assistant `custom_components` directory +3. Restart Home Assistant + +## Configuration + +1. Go to **Settings** > **Devices & Services** +2. Click **Add Integration** +3. Search for "Emby Media Player" +4. Enter your Emby server details: + - **Host**: Your Emby server hostname or IP address + - **Port**: Emby server port (default: 8096) + - **API Key**: Your Emby API key (found in Dashboard > Extended > API Keys) + - **Use SSL**: Enable if your server uses HTTPS +5. Select the Emby user account to use +6. Click **Submit** + +### Getting an API Key + +1. Open your Emby Server Dashboard +2. Navigate to **Extended** > **API Keys** +3. Click **New API Key** (+ button) +4. Give it a name (e.g., "Home Assistant") +5. Copy the generated key + +## Options + +After configuration, you can adjust the following options: + +- **Scan Interval**: Polling interval in seconds (5-60, default: 10). Used as a fallback when WebSocket connection is unavailable. + +## Supported Features + +| Feature | Support | +|---------|---------| +| Play/Pause | Yes | +| Stop | Yes | +| Volume Control | Yes | +| Volume Mute | Yes | +| Seek | Yes | +| Next Track | Yes | +| Previous Track | Yes | +| Media Browser | Yes | +| Play Media | Yes | + +## Entity Attributes + +Each media player entity exposes the following attributes: + +- `session_id`: Emby session identifier +- `device_id`: Device identifier +- `device_name`: Name of the playback device +- `client_name`: Emby client application name +- `user_name`: Emby user name +- `item_id`: Currently playing item ID +- `item_type`: Type of media (Movie, Episode, Audio, etc.) + +## Automation Examples + +### Dim lights when playing a movie + +```yaml +automation: + - alias: "Dim lights for movie" + trigger: + - platform: state + entity_id: media_player.emby_living_room_tv + to: "playing" + condition: + - condition: template + value_template: "{{ state_attr('media_player.emby_living_room_tv', 'item_type') == 'Movie' }}" + action: + - service: light.turn_on + target: + entity_id: light.living_room + data: + brightness_pct: 20 +``` + +### Pause playback when doorbell rings + +```yaml +automation: + - alias: "Pause Emby on doorbell" + trigger: + - platform: state + entity_id: binary_sensor.doorbell + to: "on" + action: + - service: media_player.media_pause + target: + entity_id: media_player.emby_living_room_tv +``` + +## Troubleshooting + +### Connection Issues + +- Verify the Emby server is accessible from Home Assistant +- Check that the API key is valid and has appropriate permissions +- Ensure the port is correct (default is 8096) + +### No Media Players Appearing + +- Media player entities are only created for **active sessions** that support remote control +- Start playback on an Emby client and wait for the entity to appear +- Check the Home Assistant logs for any error messages + +### WebSocket Connection Failed + +If WebSocket connection fails, the integration will fall back to polling. Check: +- Firewall rules allow WebSocket connections +- Reverse proxy is configured to support WebSocket +- Server logs in Home Assistant for specific errors + +## Contributing + +Contributions are welcome! Please open an issue or submit a pull request. + +## License + +This project is licensed under the MIT License. diff --git a/custom_components/emby_player/__init__.py b/custom_components/emby_player/__init__.py new file mode 100644 index 0000000..4ce106e --- /dev/null +++ b/custom_components/emby_player/__init__.py @@ -0,0 +1,135 @@ +"""Emby Media Player integration for Home Assistant.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .api import EmbyApiClient, EmbyConnectionError +from .const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PORT, + CONF_SCAN_INTERVAL, + CONF_SSL, + CONF_USER_ID, + DEFAULT_SCAN_INTERVAL, + DEFAULT_SSL, + DOMAIN, +) +from .coordinator import EmbyCoordinator +from .websocket import EmbyWebSocket + +if TYPE_CHECKING: + from homeassistant.helpers.aiohttp_client import async_get_clientsession + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.MEDIA_PLAYER] + + +@dataclass +class EmbyRuntimeData: + """Runtime data for Emby integration.""" + + coordinator: EmbyCoordinator + api: EmbyApiClient + websocket: EmbyWebSocket + user_id: str + + +type EmbyConfigEntry = ConfigEntry[EmbyRuntimeData] + + +async def async_setup_entry(hass: HomeAssistant, entry: EmbyConfigEntry) -> bool: + """Set up Emby Media Player from a config entry.""" + host = entry.data[CONF_HOST] + port = int(entry.data[CONF_PORT]) + api_key = entry.data[CONF_API_KEY] + ssl = entry.data.get(CONF_SSL, DEFAULT_SSL) + user_id = entry.data[CONF_USER_ID] + scan_interval = int(entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)) + + # Create shared aiohttp session + from homeassistant.helpers.aiohttp_client import async_get_clientsession + + session = async_get_clientsession(hass) + + # Create API client + api = EmbyApiClient( + host=host, + port=port, + api_key=api_key, + ssl=ssl, + session=session, + ) + + # Test connection + try: + await api.test_connection() + except EmbyConnectionError as err: + raise ConfigEntryNotReady(f"Cannot connect to Emby server: {err}") from err + + # Create WebSocket client + websocket = EmbyWebSocket( + host=host, + port=port, + api_key=api_key, + ssl=ssl, + session=session, + ) + + # Create coordinator + coordinator = EmbyCoordinator( + hass=hass, + api=api, + websocket=websocket, + scan_interval=scan_interval, + ) + + # Set up WebSocket connection + await coordinator.async_setup() + + # Fetch initial data + await coordinator.async_config_entry_first_refresh() + + # Store runtime data + entry.runtime_data = EmbyRuntimeData( + coordinator=coordinator, + api=api, + websocket=websocket, + user_id=user_id, + ) + + # Set up 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_listener)) + + return True + + +async def _async_update_listener(hass: HomeAssistant, entry: EmbyConfigEntry) -> None: + """Handle options update.""" + scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + entry.runtime_data.coordinator.update_scan_interval(scan_interval) + _LOGGER.debug("Updated Emby scan interval to %d seconds", scan_interval) + + +async def async_unload_entry(hass: HomeAssistant, entry: EmbyConfigEntry) -> bool: + """Unload a config entry.""" + # Unload platforms + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + # Shut down coordinator (closes WebSocket) + await entry.runtime_data.coordinator.async_shutdown() + + return unload_ok diff --git a/custom_components/emby_player/api.py b/custom_components/emby_player/api.py new file mode 100644 index 0000000..bb0adc2 --- /dev/null +++ b/custom_components/emby_player/api.py @@ -0,0 +1,392 @@ +"""Emby REST API client.""" + +from __future__ import annotations + +import logging +from typing import Any + +import aiohttp + +from .const import ( + COMMAND_MUTE, + COMMAND_SET_VOLUME, + COMMAND_UNMUTE, + DEFAULT_PORT, + DEVICE_ID, + DEVICE_NAME, + DEVICE_VERSION, + ENDPOINT_ITEMS, + ENDPOINT_SESSIONS, + ENDPOINT_SYSTEM_INFO, + ENDPOINT_USERS, + PLAY_COMMAND_PLAY_NOW, + PLAYBACK_COMMAND_NEXT_TRACK, + PLAYBACK_COMMAND_PAUSE, + PLAYBACK_COMMAND_PREVIOUS_TRACK, + PLAYBACK_COMMAND_SEEK, + PLAYBACK_COMMAND_STOP, + PLAYBACK_COMMAND_UNPAUSE, +) + +_LOGGER = logging.getLogger(__name__) + + +class EmbyApiError(Exception): + """Base exception for Emby API errors.""" + + +class EmbyConnectionError(EmbyApiError): + """Exception for connection errors.""" + + +class EmbyAuthenticationError(EmbyApiError): + """Exception for authentication errors.""" + + +class EmbyApiClient: + """Emby REST API client.""" + + def __init__( + self, + host: str, + api_key: str, + port: int = DEFAULT_PORT, + ssl: bool = False, + session: aiohttp.ClientSession | None = None, + ) -> None: + """Initialize the Emby API client.""" + self._host = host + self._port = port + self._api_key = api_key + self._ssl = ssl + self._session = session + self._owns_session = session is None + + protocol = "https" if ssl else "http" + self._base_url = f"{protocol}://{host}:{port}" + + @property + def base_url(self) -> str: + """Return the base URL.""" + return self._base_url + + async def _ensure_session(self) -> aiohttp.ClientSession: + """Ensure an aiohttp session exists.""" + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession() + self._owns_session = True + return self._session + + async def close(self) -> None: + """Close the aiohttp session if we own it.""" + if self._owns_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 { + "X-Emby-Token": self._api_key, + "X-Emby-Client": DEVICE_NAME, + "X-Emby-Device-Name": DEVICE_NAME, + "X-Emby-Device-Id": DEVICE_ID, + "X-Emby-Client-Version": DEVICE_VERSION, + "Content-Type": "application/json", + "Accept": "application/json", + } + + async def _request( + self, + method: str, + endpoint: str, + params: dict[str, Any] | None = None, + data: dict[str, Any] | None = None, + ) -> Any: + """Make an API request.""" + session = await self._ensure_session() + url = f"{self._base_url}{endpoint}" + + _LOGGER.debug("Making %s request to %s", method, url) + + try: + async with session.request( + method, + url, + headers=self._get_headers(), + params=params, + json=data, + timeout=aiohttp.ClientTimeout(total=15), + ssl=False if not self._ssl else None, # Disable SSL verification if not using SSL + ) as response: + _LOGGER.debug("Response status: %s", response.status) + + if response.status == 401: + raise EmbyAuthenticationError("Invalid API key") + if response.status == 403: + raise EmbyAuthenticationError("Access forbidden") + if response.status >= 400: + text = await response.text() + _LOGGER.error("API error %s: %s", response.status, text) + raise EmbyApiError(f"API error {response.status}: {text}") + + content_type = response.headers.get("Content-Type", "") + if "application/json" in content_type: + return await response.json() + return await response.text() + + except aiohttp.ClientError as err: + _LOGGER.error("Connection error to %s: %s", url, err) + raise EmbyConnectionError(f"Connection error: {err}") from err + except TimeoutError as err: + _LOGGER.error("Timeout connecting to %s", url) + raise EmbyConnectionError(f"Connection timeout: {err}") from err + + async def _get( + self, endpoint: str, params: dict[str, Any] | None = None + ) -> Any: + """Make a GET request.""" + return await self._request("GET", endpoint, params=params) + + async def _post( + self, + endpoint: str, + params: dict[str, Any] | None = None, + data: dict[str, Any] | None = None, + ) -> Any: + """Make a POST request.""" + return await self._request("POST", endpoint, params=params, data=data) + + # ------------------------------------------------------------------------- + # Authentication & System + # ------------------------------------------------------------------------- + + async def test_connection(self) -> dict[str, Any]: + """Test the connection to the Emby server. + + Tries both /emby/System/Info and /System/Info endpoints. + Returns server info if successful. + """ + # Try with /emby prefix first (standard Emby) + try: + _LOGGER.debug("Trying connection with /emby prefix") + return await self._get(ENDPOINT_SYSTEM_INFO) + except (EmbyConnectionError, EmbyApiError) as err: + _LOGGER.debug("Connection with /emby prefix failed: %s", err) + + # Try without /emby prefix (some Emby configurations) + try: + _LOGGER.debug("Trying connection without /emby prefix") + return await self._get("/System/Info") + except (EmbyConnectionError, EmbyApiError) as err: + _LOGGER.debug("Connection without /emby prefix failed: %s", err) + raise EmbyConnectionError( + f"Cannot connect to Emby server at {self._base_url}. " + "Please verify the host, port, and that the server is running." + ) from err + + async def get_server_info(self) -> dict[str, Any]: + """Get server information.""" + return await self._get(ENDPOINT_SYSTEM_INFO) + + async def get_users(self) -> list[dict[str, Any]]: + """Get list of users.""" + return await self._get(ENDPOINT_USERS) + + # ------------------------------------------------------------------------- + # Sessions + # ------------------------------------------------------------------------- + + async def get_sessions(self) -> list[dict[str, Any]]: + """Get all active sessions.""" + return await self._get(ENDPOINT_SESSIONS) + + async def get_controllable_sessions( + self, user_id: str | None = None + ) -> list[dict[str, Any]]: + """Get sessions that can be remotely controlled.""" + params = {} + if user_id: + params["ControllableByUserId"] = user_id + + sessions = await self._get(ENDPOINT_SESSIONS, params=params) + return [s for s in sessions if s.get("SupportsRemoteControl")] + + # ------------------------------------------------------------------------- + # Playback Control + # ------------------------------------------------------------------------- + + async def play_media( + self, + session_id: str, + item_ids: list[str], + play_command: str = PLAY_COMMAND_PLAY_NOW, + start_position_ticks: int = 0, + ) -> None: + """Send play command to a session.""" + endpoint = f"{ENDPOINT_SESSIONS}/{session_id}/Playing" + params = { + "ItemIds": ",".join(item_ids), + "PlayCommand": play_command, + } + if start_position_ticks > 0: + params["StartPositionTicks"] = start_position_ticks + + _LOGGER.debug( + "Sending play_media: endpoint=%s, session_id=%s, item_ids=%s, command=%s", + endpoint, + session_id, + item_ids, + play_command, + ) + await self._post(endpoint, params=params) + + async def _playback_command(self, session_id: str, command: str) -> None: + """Send a playback command to a session.""" + endpoint = f"{ENDPOINT_SESSIONS}/{session_id}/Playing/{command}" + await self._post(endpoint) + + async def play(self, session_id: str) -> None: + """Resume playback.""" + await self._playback_command(session_id, PLAYBACK_COMMAND_UNPAUSE) + + async def pause(self, session_id: str) -> None: + """Pause playback.""" + await self._playback_command(session_id, PLAYBACK_COMMAND_PAUSE) + + async def stop(self, session_id: str) -> None: + """Stop playback.""" + await self._playback_command(session_id, PLAYBACK_COMMAND_STOP) + + async def next_track(self, session_id: str) -> None: + """Skip to next track.""" + await self._playback_command(session_id, PLAYBACK_COMMAND_NEXT_TRACK) + + async def previous_track(self, session_id: str) -> None: + """Skip to previous track.""" + await self._playback_command(session_id, PLAYBACK_COMMAND_PREVIOUS_TRACK) + + async def seek(self, session_id: str, position_ticks: int) -> None: + """Seek to a position.""" + endpoint = f"{ENDPOINT_SESSIONS}/{session_id}/Playing/{PLAYBACK_COMMAND_SEEK}" + await self._post(endpoint, params={"SeekPositionTicks": position_ticks}) + + # ------------------------------------------------------------------------- + # Volume Control + # ------------------------------------------------------------------------- + + async def _send_command( + self, session_id: str, command: str, arguments: dict[str, Any] | None = None + ) -> None: + """Send a general command to a session.""" + endpoint = f"{ENDPOINT_SESSIONS}/{session_id}/Command" + data: dict[str, Any] = {"Name": command} + if arguments: + # Emby expects arguments as strings + data["Arguments"] = {k: str(v) for k, v in arguments.items()} + await self._post(endpoint, data=data) + + async def set_volume(self, session_id: str, volume: int) -> None: + """Set volume level (0-100).""" + volume = max(0, min(100, volume)) + await self._send_command(session_id, COMMAND_SET_VOLUME, {"Volume": volume}) + + async def mute(self, session_id: str) -> None: + """Mute the session.""" + await self._send_command(session_id, COMMAND_MUTE) + + async def unmute(self, session_id: str) -> None: + """Unmute the session.""" + await self._send_command(session_id, COMMAND_UNMUTE) + + # ------------------------------------------------------------------------- + # Library Browsing + # ------------------------------------------------------------------------- + + async def get_views(self, user_id: str) -> list[dict[str, Any]]: + """Get user's library views (top-level folders).""" + endpoint = f"{ENDPOINT_USERS}/{user_id}/Views" + result = await self._get(endpoint) + return result.get("Items", []) + + async def get_items( + self, + user_id: str, + parent_id: str | None = None, + include_item_types: list[str] | None = None, + recursive: bool = False, + sort_by: str = "SortName", + sort_order: str = "Ascending", + start_index: int = 0, + limit: int = 100, + search_term: str | None = None, + fields: list[str] | None = None, + ) -> dict[str, Any]: + """Get items from the library.""" + endpoint = f"{ENDPOINT_USERS}/{user_id}/Items" + + params: dict[str, Any] = { + "SortBy": sort_by, + "SortOrder": sort_order, + "StartIndex": start_index, + "Limit": limit, + "Recursive": str(recursive).lower(), + } + + if parent_id: + params["ParentId"] = parent_id + if include_item_types: + params["IncludeItemTypes"] = ",".join(include_item_types) + if search_term: + params["SearchTerm"] = search_term + if fields: + params["Fields"] = ",".join(fields) + else: + params["Fields"] = "PrimaryImageAspectRatio,BasicSyncInfo" + + return await self._get(endpoint, params=params) + + async def get_item(self, user_id: str, item_id: str) -> dict[str, Any]: + """Get a single item by ID.""" + endpoint = f"{ENDPOINT_USERS}/{user_id}/Items/{item_id}" + return await self._get(endpoint) + + async def get_artists( + self, + user_id: str, + parent_id: str | None = None, + start_index: int = 0, + limit: int = 100, + ) -> dict[str, Any]: + """Get artists.""" + endpoint = "/emby/Artists" + params: dict[str, Any] = { + "UserId": user_id, + "StartIndex": start_index, + "Limit": limit, + "SortBy": "SortName", + "SortOrder": "Ascending", + } + if parent_id: + params["ParentId"] = parent_id + + return await self._get(endpoint, params=params) + + def get_image_url( + self, + item_id: str, + image_type: str = "Primary", + max_width: int | None = None, + max_height: int | None = None, + ) -> str: + """Get the URL for an item's image.""" + url = f"{self._base_url}{ENDPOINT_ITEMS}/{item_id}/Images/{image_type}" + params = [] + if max_width: + params.append(f"maxWidth={max_width}") + if max_height: + params.append(f"maxHeight={max_height}") + params.append(f"api_key={self._api_key}") + + if params: + url += "?" + "&".join(params) + + return url diff --git a/custom_components/emby_player/browse_media.py b/custom_components/emby_player/browse_media.py new file mode 100644 index 0000000..4d81094 --- /dev/null +++ b/custom_components/emby_player/browse_media.py @@ -0,0 +1,231 @@ +"""Media browser for Emby Media Player integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.media_player import BrowseMedia, MediaClass, MediaType +from homeassistant.core import HomeAssistant + +from .api import EmbyApiClient +from .const import ( + ITEM_TYPE_AUDIO, + ITEM_TYPE_COLLECTION_FOLDER, + ITEM_TYPE_EPISODE, + ITEM_TYPE_FOLDER, + ITEM_TYPE_MOVIE, + ITEM_TYPE_MUSIC_ALBUM, + ITEM_TYPE_MUSIC_ARTIST, + ITEM_TYPE_PLAYLIST, + ITEM_TYPE_SEASON, + ITEM_TYPE_SERIES, + ITEM_TYPE_USER_VIEW, +) + +_LOGGER = logging.getLogger(__name__) + +# Map Emby item types to Home Assistant media classes +ITEM_TYPE_TO_MEDIA_CLASS: dict[str, MediaClass] = { + ITEM_TYPE_MOVIE: MediaClass.MOVIE, + ITEM_TYPE_SERIES: MediaClass.TV_SHOW, + ITEM_TYPE_SEASON: MediaClass.SEASON, + ITEM_TYPE_EPISODE: MediaClass.EPISODE, + ITEM_TYPE_AUDIO: MediaClass.TRACK, + ITEM_TYPE_MUSIC_ALBUM: MediaClass.ALBUM, + ITEM_TYPE_MUSIC_ARTIST: MediaClass.ARTIST, + ITEM_TYPE_PLAYLIST: MediaClass.PLAYLIST, + ITEM_TYPE_FOLDER: MediaClass.DIRECTORY, + ITEM_TYPE_COLLECTION_FOLDER: MediaClass.DIRECTORY, + ITEM_TYPE_USER_VIEW: MediaClass.DIRECTORY, +} + +# Map Emby item types to Home Assistant media types +ITEM_TYPE_TO_MEDIA_TYPE: dict[str, MediaType | str] = { + ITEM_TYPE_MOVIE: MediaType.MOVIE, + ITEM_TYPE_SERIES: MediaType.TVSHOW, + ITEM_TYPE_SEASON: MediaType.SEASON, + ITEM_TYPE_EPISODE: MediaType.EPISODE, + ITEM_TYPE_AUDIO: MediaType.TRACK, + ITEM_TYPE_MUSIC_ALBUM: MediaType.ALBUM, + ITEM_TYPE_MUSIC_ARTIST: MediaType.ARTIST, + ITEM_TYPE_PLAYLIST: MediaType.PLAYLIST, +} + +# Item types that can be played directly +PLAYABLE_ITEM_TYPES = { + ITEM_TYPE_MOVIE, + ITEM_TYPE_EPISODE, + ITEM_TYPE_AUDIO, +} + +# Item types that can be expanded (have children) +EXPANDABLE_ITEM_TYPES = { + ITEM_TYPE_SERIES, + ITEM_TYPE_SEASON, + ITEM_TYPE_MUSIC_ALBUM, + ITEM_TYPE_MUSIC_ARTIST, + ITEM_TYPE_PLAYLIST, + ITEM_TYPE_FOLDER, + ITEM_TYPE_COLLECTION_FOLDER, + ITEM_TYPE_USER_VIEW, +} + + +async def async_browse_media( + hass: HomeAssistant, + api: EmbyApiClient, + user_id: str, + media_content_type: MediaType | str | None, + media_content_id: str | None, +) -> BrowseMedia: + """Browse Emby media library.""" + if media_content_id is None or media_content_id == "": + # Return root - library views + return await _build_root_browse(api, user_id) + + # Browse specific item/folder + return await _build_item_browse(api, user_id, media_content_id) + + +async def _build_root_browse(api: EmbyApiClient, user_id: str) -> BrowseMedia: + """Build root browse media structure (library views).""" + views = await api.get_views(user_id) + + children = [] + for view in views: + item_id = view.get("Id") + name = view.get("Name", "Unknown") + item_type = view.get("Type", ITEM_TYPE_USER_VIEW) + collection_type = view.get("CollectionType", "") + + # Determine media class based on collection type + if collection_type == "movies": + media_class = MediaClass.MOVIE + elif collection_type == "tvshows": + media_class = MediaClass.TV_SHOW + elif collection_type == "music": + media_class = MediaClass.MUSIC + else: + media_class = MediaClass.DIRECTORY + + thumbnail = api.get_image_url(item_id, max_width=300) if item_id else None + + children.append( + BrowseMedia( + media_class=media_class, + media_content_id=item_id, + media_content_type=MediaType.CHANNELS, # Library view + title=name, + can_play=False, + can_expand=True, + thumbnail=thumbnail, + ) + ) + + return BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id="", + media_content_type=MediaType.CHANNELS, + title="Emby Library", + can_play=False, + can_expand=True, + children=children, + ) + + +async def _build_item_browse( + api: EmbyApiClient, user_id: str, item_id: str +) -> BrowseMedia: + """Build browse media structure for a specific item.""" + # Get the item details + item = await api.get_item(user_id, item_id) + item_type = item.get("Type", "") + item_name = item.get("Name", "Unknown") + + # Get children items + children_data = await api.get_items( + user_id=user_id, + parent_id=item_id, + limit=200, + fields=["PrimaryImageAspectRatio", "BasicSyncInfo", "Overview"], + ) + + children = [] + for child in children_data.get("Items", []): + child_media = _build_browse_media_item(api, child) + if child_media: + children.append(child_media) + + # Determine media class and type for parent + media_class = ITEM_TYPE_TO_MEDIA_CLASS.get(item_type, MediaClass.DIRECTORY) + media_type = ITEM_TYPE_TO_MEDIA_TYPE.get(item_type, MediaType.CHANNELS) + + thumbnail = api.get_image_url(item_id, max_width=300) + + return BrowseMedia( + media_class=media_class, + media_content_id=item_id, + media_content_type=media_type, + title=item_name, + can_play=item_type in PLAYABLE_ITEM_TYPES, + can_expand=True, + children=children, + thumbnail=thumbnail, + ) + + +def _build_browse_media_item(api: EmbyApiClient, item: dict[str, Any]) -> BrowseMedia | None: + """Build a BrowseMedia item from Emby item data.""" + item_id = item.get("Id") + if not item_id: + return None + + item_type = item.get("Type", "") + name = item.get("Name", "Unknown") + + # Build title for episodes with season/episode numbers + if item_type == ITEM_TYPE_EPISODE: + season_num = item.get("ParentIndexNumber") + episode_num = item.get("IndexNumber") + if season_num is not None and episode_num is not None: + name = f"S{season_num:02d}E{episode_num:02d} - {name}" + elif episode_num is not None: + name = f"E{episode_num:02d} - {name}" + + # Build title for tracks with track number + if item_type == ITEM_TYPE_AUDIO: + track_num = item.get("IndexNumber") + artists = item.get("Artists", []) + if track_num is not None: + name = f"{track_num}. {name}" + if artists: + name = f"{name} - {', '.join(artists)}" + + # Get media class and type + media_class = ITEM_TYPE_TO_MEDIA_CLASS.get(item_type, MediaClass.VIDEO) + media_type = ITEM_TYPE_TO_MEDIA_TYPE.get(item_type, MediaType.VIDEO) + + # Determine if playable/expandable + can_play = item_type in PLAYABLE_ITEM_TYPES + can_expand = item_type in EXPANDABLE_ITEM_TYPES + + # Get thumbnail URL + # For episodes, prefer series or season image + image_item_id = item_id + if item_type == ITEM_TYPE_EPISODE: + image_item_id = item.get("SeriesId") or item.get("SeasonId") or item_id + elif item_type == ITEM_TYPE_AUDIO: + image_item_id = item.get("AlbumId") or item_id + + thumbnail = api.get_image_url(image_item_id, max_width=300) + + return BrowseMedia( + media_class=media_class, + media_content_id=item_id, + media_content_type=media_type, + title=name, + can_play=can_play, + can_expand=can_expand, + thumbnail=thumbnail, + ) diff --git a/custom_components/emby_player/config_flow.py b/custom_components/emby_player/config_flow.py new file mode 100644 index 0000000..72cb6f5 --- /dev/null +++ b/custom_components/emby_player/config_flow.py @@ -0,0 +1,230 @@ +"""Config flow for Emby Media Player integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) +from homeassistant.core import callback +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .api import EmbyApiClient, EmbyAuthenticationError, EmbyConnectionError +from .const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PORT, + CONF_SCAN_INTERVAL, + CONF_SSL, + CONF_USER_ID, + DEFAULT_PORT, + DEFAULT_SCAN_INTERVAL, + DEFAULT_SSL, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +class EmbyConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Emby Media Player.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._host: str | None = None + self._port: int = DEFAULT_PORT + self._api_key: str | None = None + self._ssl: bool = DEFAULT_SSL + self._users: list[dict[str, Any]] = [] + self._server_info: dict[str, Any] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step - server connection.""" + errors: dict[str, str] = {} + + if user_input is not None: + self._host = user_input[CONF_HOST].strip() + self._port = int(user_input.get(CONF_PORT, DEFAULT_PORT)) + self._api_key = user_input[CONF_API_KEY].strip() + self._ssl = user_input.get(CONF_SSL, DEFAULT_SSL) + + _LOGGER.debug( + "Testing connection to %s:%s (SSL: %s)", + self._host, + self._port, + self._ssl, + ) + + # Test connection + api = EmbyApiClient( + host=self._host, + port=self._port, + api_key=self._api_key, + ssl=self._ssl, + ) + + try: + self._server_info = await api.test_connection() + self._users = await api.get_users() + await api.close() + + if not self._users: + errors["base"] = "no_users" + else: + return await self.async_step_user_select() + + except EmbyAuthenticationError: + errors["base"] = "invalid_auth" + except EmbyConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + finally: + await api.close() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT) + ), + vol.Optional(CONF_PORT, default=DEFAULT_PORT): NumberSelector( + NumberSelectorConfig( + min=1, + max=65535, + mode=NumberSelectorMode.BOX, + ) + ), + vol.Required(CONF_API_KEY): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool, + } + ), + errors=errors, + ) + + async def async_step_user_select( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle user selection step.""" + errors: dict[str, str] = {} + + if user_input is not None: + user_id = user_input[CONF_USER_ID] + + # Find user name + user_name = next( + (u["Name"] for u in self._users if u["Id"] == user_id), + "Unknown", + ) + + # Create unique ID based on server ID and user + server_id = self._server_info.get("Id", self._host) + await self.async_set_unique_id(f"{server_id}_{user_id}") + self._abort_if_unique_id_configured() + + server_name = self._server_info.get("ServerName", self._host) + + return self.async_create_entry( + title=f"{server_name} ({user_name})", + data={ + CONF_HOST: self._host, + CONF_PORT: self._port, + CONF_API_KEY: self._api_key, + CONF_SSL: self._ssl, + CONF_USER_ID: user_id, + }, + options={ + CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, + }, + ) + + # Build user selection options + user_options = [ + {"value": user["Id"], "label": user["Name"]} for user in self._users + ] + + return self.async_show_form( + step_id="user_select", + data_schema=vol.Schema( + { + vol.Required(CONF_USER_ID): SelectSelector( + SelectSelectorConfig( + options=user_options, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ), + errors=errors, + ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> EmbyOptionsFlow: + """Get the options flow for this handler.""" + return EmbyOptionsFlow(config_entry) + + +class EmbyOptionsFlow(OptionsFlow): + """Handle options flow for Emby Media Player.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self._config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + current_interval = self._config_entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_SCAN_INTERVAL, default=current_interval + ): NumberSelector( + NumberSelectorConfig( + min=5, + max=60, + step=1, + mode=NumberSelectorMode.SLIDER, + unit_of_measurement="seconds", + ) + ), + } + ), + ) diff --git a/custom_components/emby_player/const.py b/custom_components/emby_player/const.py new file mode 100644 index 0000000..4f04812 --- /dev/null +++ b/custom_components/emby_player/const.py @@ -0,0 +1,89 @@ +"""Constants for the Emby Media Player integration.""" + +from typing import Final + +DOMAIN: Final = "emby_player" + +# Configuration keys +CONF_HOST: Final = "host" +CONF_PORT: Final = "port" +CONF_API_KEY: Final = "api_key" +CONF_SSL: Final = "ssl" +CONF_USER_ID: Final = "user_id" +CONF_SCAN_INTERVAL: Final = "scan_interval" + +# Defaults +DEFAULT_PORT: Final = 8096 +DEFAULT_SSL: Final = False +DEFAULT_SCAN_INTERVAL: Final = 10 # seconds + +# Emby ticks conversion (1 tick = 100 nanoseconds = 0.0000001 seconds) +TICKS_PER_SECOND: Final = 10_000_000 + +# API endpoints (with /emby prefix for Emby Server) +ENDPOINT_SYSTEM_INFO: Final = "/emby/System/Info" +ENDPOINT_SYSTEM_PING: Final = "/emby/System/Ping" +ENDPOINT_USERS: Final = "/emby/Users" +ENDPOINT_SESSIONS: Final = "/emby/Sessions" +ENDPOINT_ITEMS: Final = "/emby/Items" + +# WebSocket +WEBSOCKET_PATH: Final = "/embywebsocket" + +# Device identification for Home Assistant +DEVICE_ID: Final = "homeassistant_emby_player" +DEVICE_NAME: Final = "Home Assistant" +DEVICE_VERSION: Final = "1.0.0" + +# Media types +MEDIA_TYPE_VIDEO: Final = "Video" +MEDIA_TYPE_AUDIO: Final = "Audio" + +# Item types +ITEM_TYPE_MOVIE: Final = "Movie" +ITEM_TYPE_EPISODE: Final = "Episode" +ITEM_TYPE_SERIES: Final = "Series" +ITEM_TYPE_SEASON: Final = "Season" +ITEM_TYPE_AUDIO: Final = "Audio" +ITEM_TYPE_MUSIC_ALBUM: Final = "MusicAlbum" +ITEM_TYPE_MUSIC_ARTIST: Final = "MusicArtist" +ITEM_TYPE_PLAYLIST: Final = "Playlist" +ITEM_TYPE_FOLDER: Final = "Folder" +ITEM_TYPE_COLLECTION_FOLDER: Final = "CollectionFolder" +ITEM_TYPE_USER_VIEW: Final = "UserView" + +# Play commands +PLAY_COMMAND_PLAY_NOW: Final = "PlayNow" +PLAY_COMMAND_PLAY_NEXT: Final = "PlayNext" +PLAY_COMMAND_PLAY_LAST: Final = "PlayLast" + +# Playback state commands +PLAYBACK_COMMAND_STOP: Final = "Stop" +PLAYBACK_COMMAND_PAUSE: Final = "Pause" +PLAYBACK_COMMAND_UNPAUSE: Final = "Unpause" +PLAYBACK_COMMAND_NEXT_TRACK: Final = "NextTrack" +PLAYBACK_COMMAND_PREVIOUS_TRACK: Final = "PreviousTrack" +PLAYBACK_COMMAND_SEEK: Final = "Seek" + +# General commands +COMMAND_SET_VOLUME: Final = "SetVolume" +COMMAND_MUTE: Final = "Mute" +COMMAND_UNMUTE: Final = "Unmute" +COMMAND_TOGGLE_MUTE: Final = "ToggleMute" + +# WebSocket message types +WS_MESSAGE_SESSIONS_START: Final = "SessionsStart" +WS_MESSAGE_SESSIONS_STOP: Final = "SessionsStop" +WS_MESSAGE_SESSIONS: Final = "Sessions" +WS_MESSAGE_PLAYBACK_START: Final = "PlaybackStart" +WS_MESSAGE_PLAYBACK_STOP: Final = "PlaybackStopped" +WS_MESSAGE_PLAYBACK_PROGRESS: Final = "PlaybackProgress" + +# Attributes for extra state +ATTR_ITEM_ID: Final = "item_id" +ATTR_ITEM_TYPE: Final = "item_type" +ATTR_SESSION_ID: Final = "session_id" +ATTR_DEVICE_ID: Final = "device_id" +ATTR_DEVICE_NAME: Final = "device_name" +ATTR_CLIENT_NAME: Final = "client_name" +ATTR_USER_NAME: Final = "user_name" diff --git a/custom_components/emby_player/coordinator.py b/custom_components/emby_player/coordinator.py new file mode 100644 index 0000000..6b190ad --- /dev/null +++ b/custom_components/emby_player/coordinator.py @@ -0,0 +1,283 @@ +"""Data coordinator for Emby Media Player integration.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from datetime import timedelta +from typing import Any + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .api import EmbyApiClient, EmbyApiError +from .const import ( + DOMAIN, + TICKS_PER_SECOND, + WS_MESSAGE_PLAYBACK_PROGRESS, + WS_MESSAGE_PLAYBACK_START, + WS_MESSAGE_PLAYBACK_STOP, + WS_MESSAGE_SESSIONS, +) +from .websocket import EmbyWebSocket + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class EmbyNowPlaying: + """Currently playing media information.""" + + item_id: str + name: str + media_type: str # Audio, Video + item_type: str # Movie, Episode, Audio, etc. + artist: str | None = None + album: str | None = None + album_artist: str | None = None + series_name: str | None = None + season_name: str | None = None + index_number: int | None = None # Episode number + parent_index_number: int | None = None # Season number + duration_ticks: int = 0 + primary_image_tag: str | None = None + primary_image_item_id: str | None = None + backdrop_image_tags: list[str] = field(default_factory=list) + genres: list[str] = field(default_factory=list) + production_year: int | None = None + overview: str | None = None + + @property + def duration_seconds(self) -> float: + """Get duration in seconds.""" + return self.duration_ticks / TICKS_PER_SECOND if self.duration_ticks else 0 + + +@dataclass +class EmbyPlayState: + """Playback state information.""" + + is_paused: bool = False + is_muted: bool = False + volume_level: int = 100 # 0-100 + position_ticks: int = 0 + can_seek: bool = True + repeat_mode: str = "RepeatNone" + shuffle_mode: str = "Sorted" + play_method: str | None = None # DirectPlay, DirectStream, Transcode + + @property + def position_seconds(self) -> float: + """Get position in seconds.""" + return self.position_ticks / TICKS_PER_SECOND if self.position_ticks else 0 + + +@dataclass +class EmbySession: + """Represents an Emby client session.""" + + session_id: str + device_id: str + device_name: str + client_name: str + app_version: str | None = None + user_id: str | None = None + user_name: str | None = None + supports_remote_control: bool = True + now_playing: EmbyNowPlaying | None = None + play_state: EmbyPlayState | None = None + playable_media_types: list[str] = field(default_factory=list) + supported_commands: list[str] = field(default_factory=list) + + @property + def is_playing(self) -> bool: + """Return True if media is currently playing (not paused).""" + return ( + self.now_playing is not None + and self.play_state is not None + and not self.play_state.is_paused + ) + + @property + def is_paused(self) -> bool: + """Return True if media is paused.""" + return ( + self.now_playing is not None + and self.play_state is not None + and self.play_state.is_paused + ) + + @property + def is_idle(self) -> bool: + """Return True if session is idle (no media playing).""" + return self.now_playing is None + + +class EmbyCoordinator(DataUpdateCoordinator[dict[str, EmbySession]]): + """Coordinator for Emby data with WebSocket + polling fallback.""" + + def __init__( + self, + hass: HomeAssistant, + api: EmbyApiClient, + websocket: EmbyWebSocket, + scan_interval: int, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=scan_interval), + ) + self.api = api + self._websocket = websocket + self._ws_connected = False + self._remove_ws_callback: callable | None = None + + async def async_setup(self) -> None: + """Set up the coordinator with WebSocket connection.""" + # Try to establish WebSocket connection + if await self._websocket.connect(): + await self._websocket.subscribe_to_sessions() + self._remove_ws_callback = self._websocket.add_callback( + self._handle_ws_message + ) + self._ws_connected = True + _LOGGER.info("Emby WebSocket connected, using real-time updates") + else: + _LOGGER.warning( + "Emby WebSocket connection failed, using polling fallback" + ) + + @callback + def _handle_ws_message(self, message_type: str, data: Any) -> None: + """Handle incoming WebSocket message.""" + _LOGGER.debug("Handling WebSocket message: %s", message_type) + + if message_type == WS_MESSAGE_SESSIONS: + # Full session list received + if isinstance(data, list): + sessions = self._parse_sessions(data) + self.async_set_updated_data(sessions) + + elif message_type in ( + WS_MESSAGE_PLAYBACK_START, + WS_MESSAGE_PLAYBACK_STOP, + WS_MESSAGE_PLAYBACK_PROGRESS, + ): + # Individual session update - trigger a refresh to get full state + # We could optimize this by updating only the affected session, + # but a full refresh ensures consistency + self.hass.async_create_task(self.async_request_refresh()) + + async def _async_update_data(self) -> dict[str, EmbySession]: + """Fetch sessions from Emby API (polling fallback).""" + try: + sessions_data = await self.api.get_sessions() + return self._parse_sessions(sessions_data) + except EmbyApiError as err: + raise UpdateFailed(f"Error fetching Emby sessions: {err}") from err + + def _parse_sessions(self, sessions_data: list[dict[str, Any]]) -> dict[str, EmbySession]: + """Parse session data into EmbySession objects.""" + sessions: dict[str, EmbySession] = {} + + for session_data in sessions_data: + # Only include sessions that support remote control + if not session_data.get("SupportsRemoteControl", False): + continue + + session_id = session_data.get("Id") + if not session_id: + continue + + # Parse now playing item + now_playing = None + now_playing_data = session_data.get("NowPlayingItem") + if now_playing_data: + now_playing = self._parse_now_playing(now_playing_data) + + # Parse play state + play_state = None + play_state_data = session_data.get("PlayState") + if play_state_data: + play_state = self._parse_play_state(play_state_data) + + session = EmbySession( + session_id=session_id, + device_id=session_data.get("DeviceId", ""), + device_name=session_data.get("DeviceName", "Unknown Device"), + client_name=session_data.get("Client", "Unknown Client"), + app_version=session_data.get("ApplicationVersion"), + user_id=session_data.get("UserId"), + user_name=session_data.get("UserName"), + supports_remote_control=session_data.get("SupportsRemoteControl", True), + now_playing=now_playing, + play_state=play_state, + playable_media_types=session_data.get("PlayableMediaTypes", []), + supported_commands=session_data.get("SupportedCommands", []), + ) + + sessions[session_id] = session + + return sessions + + def _parse_now_playing(self, data: dict[str, Any]) -> EmbyNowPlaying: + """Parse now playing item data.""" + # Get artists as string + artists = data.get("Artists", []) + artist = ", ".join(artists) if artists else data.get("AlbumArtist") + + # Get the image item ID (for series/seasons, might be different from item ID) + image_item_id = data.get("Id") + if data.get("SeriesId"): + image_item_id = data.get("SeriesId") + elif data.get("ParentId") and data.get("Type") == "Audio": + image_item_id = data.get("ParentId") # Use album ID for music + + return EmbyNowPlaying( + item_id=data.get("Id", ""), + name=data.get("Name", ""), + media_type=data.get("MediaType", ""), + item_type=data.get("Type", ""), + artist=artist, + album=data.get("Album"), + album_artist=data.get("AlbumArtist"), + series_name=data.get("SeriesName"), + season_name=data.get("SeasonName"), + index_number=data.get("IndexNumber"), + parent_index_number=data.get("ParentIndexNumber"), + duration_ticks=data.get("RunTimeTicks", 0), + primary_image_tag=data.get("PrimaryImageTag"), + primary_image_item_id=image_item_id, + backdrop_image_tags=data.get("BackdropImageTags", []), + genres=data.get("Genres", []), + production_year=data.get("ProductionYear"), + overview=data.get("Overview"), + ) + + def _parse_play_state(self, data: dict[str, Any]) -> EmbyPlayState: + """Parse play state data.""" + return EmbyPlayState( + is_paused=data.get("IsPaused", False), + is_muted=data.get("IsMuted", False), + volume_level=data.get("VolumeLevel", 100), + position_ticks=data.get("PositionTicks", 0), + can_seek=data.get("CanSeek", True), + repeat_mode=data.get("RepeatMode", "RepeatNone"), + shuffle_mode=data.get("ShuffleMode", "Sorted"), + play_method=data.get("PlayMethod"), + ) + + def update_scan_interval(self, interval: int) -> None: + """Update the polling scan interval.""" + self.update_interval = timedelta(seconds=interval) + _LOGGER.debug("Updated scan interval to %d seconds", interval) + + async def async_shutdown(self) -> None: + """Shut down the coordinator.""" + if self._remove_ws_callback: + self._remove_ws_callback() + + await self._websocket.close() diff --git a/custom_components/emby_player/manifest.json b/custom_components/emby_player/manifest.json new file mode 100644 index 0000000..6c12807 --- /dev/null +++ b/custom_components/emby_player/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "emby_player", + "name": "Emby Media Player", + "codeowners": [], + "config_flow": true, + "dependencies": [], + "documentation": "https://github.com/your-repo/haos-integration-emby", + "iot_class": "local_push", + "issue_tracker": "https://github.com/your-repo/haos-integration-emby/issues", + "requirements": [], + "version": "1.0.0" +} diff --git a/custom_components/emby_player/media_player.py b/custom_components/emby_player/media_player.py new file mode 100644 index 0000000..8907e5b --- /dev/null +++ b/custom_components/emby_player/media_player.py @@ -0,0 +1,421 @@ +"""Media player platform for Emby Media Player integration.""" + +from __future__ import annotations + +import logging +from datetime import datetime +from typing import Any + +from homeassistant.components.media_player import ( + BrowseMedia, + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, + MediaType, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import slugify +from homeassistant.util.dt import utcnow + +from . import EmbyConfigEntry, EmbyRuntimeData +from .browse_media import async_browse_media +from .const import ( + ATTR_CLIENT_NAME, + ATTR_DEVICE_ID, + ATTR_DEVICE_NAME, + ATTR_ITEM_ID, + ATTR_ITEM_TYPE, + ATTR_SESSION_ID, + ATTR_USER_NAME, + DOMAIN, + ITEM_TYPE_AUDIO, + ITEM_TYPE_EPISODE, + ITEM_TYPE_MOVIE, + MEDIA_TYPE_AUDIO, + MEDIA_TYPE_VIDEO, + TICKS_PER_SECOND, +) +from .coordinator import EmbyCoordinator, EmbySession + +_LOGGER = logging.getLogger(__name__) + +# Supported features for Emby media player +SUPPORTED_FEATURES = ( + MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.STOP + | MediaPlayerEntityFeature.SEEK + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.BROWSE_MEDIA +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EmbyConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Emby media player entities.""" + runtime_data: EmbyRuntimeData = entry.runtime_data + coordinator = runtime_data.coordinator + + # Track which sessions we've already created entities for + tracked_sessions: set[str] = set() + + @callback + def async_update_entities() -> None: + """Add new entities for new sessions.""" + if coordinator.data is None: + return + + current_sessions = set(coordinator.data.keys()) + new_sessions = current_sessions - tracked_sessions + + if new_sessions: + new_entities = [ + EmbyMediaPlayer(coordinator, entry, session_id) + for session_id in new_sessions + ] + async_add_entities(new_entities) + tracked_sessions.update(new_sessions) + _LOGGER.debug("Added %d new Emby media player entities", len(new_entities)) + + # Register listener for coordinator updates + entry.async_on_unload(coordinator.async_add_listener(async_update_entities)) + + # Add entities for existing sessions + async_update_entities() + + +class EmbyMediaPlayer(CoordinatorEntity[EmbyCoordinator], MediaPlayerEntity): + """Representation of an Emby media player.""" + + _attr_has_entity_name = True + _attr_device_class = MediaPlayerDeviceClass.TV + _attr_supported_features = SUPPORTED_FEATURES + + def __init__( + self, + coordinator: EmbyCoordinator, + entry: ConfigEntry, + session_id: str, + ) -> None: + """Initialize the Emby media player.""" + super().__init__(coordinator) + self._entry = entry + self._session_id = session_id + self._last_position_update: datetime | None = None + + # Get initial session info for naming + session = self._session + device_name = session.device_name if session else "Unknown" + client_name = session.client_name if session else "Unknown" + + # Set unique ID and entity ID + self._attr_unique_id = f"{entry.entry_id}_{session_id}" + self._attr_name = f"{device_name} ({client_name})" + + @property + def _session(self) -> EmbySession | None: + """Get current session data.""" + if self.coordinator.data is None: + return None + return self.coordinator.data.get(self._session_id) + + @property + def _runtime_data(self) -> EmbyRuntimeData: + """Get runtime data.""" + return self._entry.runtime_data + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.coordinator.last_update_success and self._session is not None + + @property + def device_info(self) -> DeviceInfo: + """Return device info.""" + session = self._session + device_name = session.device_name if session else "Unknown Device" + client_name = session.client_name if session else "Unknown" + + return DeviceInfo( + identifiers={(DOMAIN, self._session_id)}, + name=f"{device_name}", + manufacturer="Emby", + model=client_name, + sw_version=session.app_version if session else None, + entry_type=DeviceEntryType.SERVICE, + ) + + @property + def state(self) -> MediaPlayerState: + """Return the state of the player.""" + session = self._session + if session is None: + return MediaPlayerState.OFF + + if session.is_playing: + return MediaPlayerState.PLAYING + if session.is_paused: + return MediaPlayerState.PAUSED + return MediaPlayerState.IDLE + + @property + def volume_level(self) -> float | None: + """Return volume level (0.0-1.0).""" + session = self._session + if session and session.play_state: + return session.play_state.volume_level / 100 + return None + + @property + def is_volume_muted(self) -> bool | None: + """Return True if volume is muted.""" + session = self._session + if session and session.play_state: + return session.play_state.is_muted + return None + + @property + def media_content_id(self) -> str | None: + """Return the content ID of current playing media.""" + session = self._session + if session and session.now_playing: + return session.now_playing.item_id + return None + + @property + def media_content_type(self) -> MediaType | str | None: + """Return the content type of current playing media.""" + session = self._session + if session and session.now_playing: + media_type = session.now_playing.media_type + if media_type == MEDIA_TYPE_AUDIO: + return MediaType.MUSIC + if media_type == MEDIA_TYPE_VIDEO: + item_type = session.now_playing.item_type + if item_type == ITEM_TYPE_MOVIE: + return MediaType.MOVIE + if item_type == ITEM_TYPE_EPISODE: + return MediaType.TVSHOW + return MediaType.VIDEO + return None + + @property + def media_title(self) -> str | None: + """Return the title of current playing media.""" + session = self._session + if session and session.now_playing: + np = session.now_playing + # For TV episodes, include series and episode info + if np.item_type == ITEM_TYPE_EPISODE and np.series_name: + season = f"S{np.parent_index_number:02d}" if np.parent_index_number else "" + episode = f"E{np.index_number:02d}" if np.index_number else "" + return f"{np.series_name} {season}{episode} - {np.name}" + return np.name + return None + + @property + def media_artist(self) -> str | None: + """Return the artist of current playing media.""" + session = self._session + if session and session.now_playing: + return session.now_playing.artist + return None + + @property + def media_album_name(self) -> str | None: + """Return the album name of current playing media.""" + session = self._session + if session and session.now_playing: + return session.now_playing.album + return None + + @property + def media_album_artist(self) -> str | None: + """Return the album artist of current playing media.""" + session = self._session + if session and session.now_playing: + return session.now_playing.album_artist + return None + + @property + def media_series_title(self) -> str | None: + """Return the series title for TV shows.""" + session = self._session + if session and session.now_playing: + return session.now_playing.series_name + return None + + @property + def media_season(self) -> str | None: + """Return the season for TV shows.""" + session = self._session + if session and session.now_playing and session.now_playing.parent_index_number: + return str(session.now_playing.parent_index_number) + return None + + @property + def media_episode(self) -> str | None: + """Return the episode for TV shows.""" + session = self._session + if session and session.now_playing and session.now_playing.index_number: + return str(session.now_playing.index_number) + return None + + @property + def media_duration(self) -> int | None: + """Return the duration of current playing media in seconds.""" + session = self._session + if session and session.now_playing: + return int(session.now_playing.duration_seconds) + return None + + @property + def media_position(self) -> int | None: + """Return the position of current playing media in seconds.""" + session = self._session + if session and session.play_state: + return int(session.play_state.position_seconds) + return None + + @property + def media_position_updated_at(self) -> datetime | None: + """Return when position was last updated.""" + session = self._session + if session and session.play_state and session.now_playing: + return utcnow() + return None + + @property + def media_image_url(self) -> str | None: + """Return the image URL of current playing media.""" + session = self._session + if session and session.now_playing: + np = session.now_playing + item_id = np.primary_image_item_id or np.item_id + if item_id: + return self._runtime_data.api.get_image_url( + item_id, + image_type="Primary", + max_width=500, + max_height=500, + ) + return None + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return extra state attributes.""" + session = self._session + if session is None: + return {} + + attrs = { + ATTR_SESSION_ID: session.session_id, + ATTR_DEVICE_ID: session.device_id, + ATTR_DEVICE_NAME: session.device_name, + ATTR_CLIENT_NAME: session.client_name, + ATTR_USER_NAME: session.user_name, + } + + if session.now_playing: + attrs[ATTR_ITEM_ID] = session.now_playing.item_id + attrs[ATTR_ITEM_TYPE] = session.now_playing.item_type + + return attrs + + # ------------------------------------------------------------------------- + # Playback Control Methods + # ------------------------------------------------------------------------- + + async def async_media_play(self) -> None: + """Resume playback.""" + await self._runtime_data.api.play(self._session_id) + await self.coordinator.async_request_refresh() + + async def async_media_pause(self) -> None: + """Pause playback.""" + await self._runtime_data.api.pause(self._session_id) + await self.coordinator.async_request_refresh() + + async def async_media_stop(self) -> None: + """Stop playback.""" + await self._runtime_data.api.stop(self._session_id) + await self.coordinator.async_request_refresh() + + async def async_media_next_track(self) -> None: + """Skip to next track.""" + await self._runtime_data.api.next_track(self._session_id) + await self.coordinator.async_request_refresh() + + async def async_media_previous_track(self) -> None: + """Skip to previous track.""" + await self._runtime_data.api.previous_track(self._session_id) + await self.coordinator.async_request_refresh() + + async def async_media_seek(self, position: float) -> None: + """Seek to position.""" + position_ticks = int(position * TICKS_PER_SECOND) + await self._runtime_data.api.seek(self._session_id, position_ticks) + await self.coordinator.async_request_refresh() + + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level (0.0-1.0).""" + volume_percent = int(volume * 100) + await self._runtime_data.api.set_volume(self._session_id, volume_percent) + await self.coordinator.async_request_refresh() + + async def async_mute_volume(self, mute: bool) -> None: + """Mute or unmute.""" + if mute: + await self._runtime_data.api.mute(self._session_id) + else: + await self._runtime_data.api.unmute(self._session_id) + await self.coordinator.async_request_refresh() + + # ------------------------------------------------------------------------- + # Media Browsing & Playing + # ------------------------------------------------------------------------- + + async def async_play_media( + self, + media_type: MediaType | str, + media_id: str, + **kwargs: Any, + ) -> None: + """Play a piece of media.""" + _LOGGER.debug( + "async_play_media called: session_id=%s, media_type=%s, media_id=%s", + self._session_id, + media_type, + media_id, + ) + await self._runtime_data.api.play_media( + self._session_id, + item_ids=[media_id], + ) + await self.coordinator.async_request_refresh() + + async def async_browse_media( + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, + ) -> BrowseMedia: + """Browse media.""" + return await async_browse_media( + self.hass, + self._runtime_data.api, + self._runtime_data.user_id, + media_content_type, + media_content_id, + ) diff --git a/custom_components/emby_player/strings.json b/custom_components/emby_player/strings.json new file mode 100644 index 0000000..8959d67 --- /dev/null +++ b/custom_components/emby_player/strings.json @@ -0,0 +1,43 @@ +{ + "config": { + "step": { + "user": { + "title": "Connect to Emby Server", + "description": "Enter your Emby server connection details. You can find your API key in Emby Server Dashboard > Extended > API Keys.", + "data": { + "host": "Host", + "port": "Port", + "api_key": "API Key", + "ssl": "Use SSL" + } + }, + "user_select": { + "title": "Select User", + "description": "Select the Emby user account to use for browsing and playback.", + "data": { + "user_id": "User" + } + } + }, + "error": { + "cannot_connect": "Cannot connect to Emby server. Please check the host and port.", + "invalid_auth": "Invalid API key. Please check your credentials.", + "no_users": "No users found on the Emby server.", + "unknown": "An unexpected error occurred." + }, + "abort": { + "already_configured": "This Emby server and user combination is already configured." + } + }, + "options": { + "step": { + "init": { + "title": "Emby Media Player Options", + "description": "Configure polling interval for session updates (used as fallback when WebSocket is unavailable).", + "data": { + "scan_interval": "Scan Interval" + } + } + } + } +} diff --git a/custom_components/emby_player/translations/en.json b/custom_components/emby_player/translations/en.json new file mode 100644 index 0000000..8959d67 --- /dev/null +++ b/custom_components/emby_player/translations/en.json @@ -0,0 +1,43 @@ +{ + "config": { + "step": { + "user": { + "title": "Connect to Emby Server", + "description": "Enter your Emby server connection details. You can find your API key in Emby Server Dashboard > Extended > API Keys.", + "data": { + "host": "Host", + "port": "Port", + "api_key": "API Key", + "ssl": "Use SSL" + } + }, + "user_select": { + "title": "Select User", + "description": "Select the Emby user account to use for browsing and playback.", + "data": { + "user_id": "User" + } + } + }, + "error": { + "cannot_connect": "Cannot connect to Emby server. Please check the host and port.", + "invalid_auth": "Invalid API key. Please check your credentials.", + "no_users": "No users found on the Emby server.", + "unknown": "An unexpected error occurred." + }, + "abort": { + "already_configured": "This Emby server and user combination is already configured." + } + }, + "options": { + "step": { + "init": { + "title": "Emby Media Player Options", + "description": "Configure polling interval for session updates (used as fallback when WebSocket is unavailable).", + "data": { + "scan_interval": "Scan Interval" + } + } + } + } +} diff --git a/custom_components/emby_player/websocket.py b/custom_components/emby_player/websocket.py new file mode 100644 index 0000000..bc098f3 --- /dev/null +++ b/custom_components/emby_player/websocket.py @@ -0,0 +1,244 @@ +"""Emby WebSocket client for real-time updates.""" + +from __future__ import annotations + +import asyncio +import json +import logging +from collections.abc import Callable +from typing import Any + +import aiohttp + +from .const import ( + DEVICE_ID, + DEVICE_NAME, + DEVICE_VERSION, + WEBSOCKET_PATH, + WS_MESSAGE_PLAYBACK_PROGRESS, + WS_MESSAGE_PLAYBACK_START, + WS_MESSAGE_PLAYBACK_STOP, + WS_MESSAGE_SESSIONS, + WS_MESSAGE_SESSIONS_START, + WS_MESSAGE_SESSIONS_STOP, +) + +_LOGGER = logging.getLogger(__name__) + +# Message types we're interested in +TRACKED_MESSAGE_TYPES = { + WS_MESSAGE_SESSIONS, + WS_MESSAGE_PLAYBACK_START, + WS_MESSAGE_PLAYBACK_STOP, + WS_MESSAGE_PLAYBACK_PROGRESS, +} + + +class EmbyWebSocket: + """WebSocket client for real-time Emby updates.""" + + def __init__( + self, + host: str, + port: int, + api_key: str, + ssl: bool = False, + session: aiohttp.ClientSession | None = None, + ) -> None: + """Initialize the WebSocket client.""" + self._host = host + self._port = port + self._api_key = api_key + self._ssl = ssl + self._session = session + self._owns_session = session is None + + protocol = "wss" if ssl else "ws" + self._url = f"{protocol}://{host}:{port}{WEBSOCKET_PATH}" + + self._ws: aiohttp.ClientWebSocketResponse | None = None + self._callbacks: list[Callable[[str, Any], None]] = [] + self._listen_task: asyncio.Task | None = None + self._running = False + self._reconnect_interval = 30 # seconds + + @property + def connected(self) -> bool: + """Return True if connected to WebSocket.""" + return self._ws is not None and not self._ws.closed + + async def _ensure_session(self) -> aiohttp.ClientSession: + """Ensure an aiohttp session exists.""" + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession() + self._owns_session = True + return self._session + + async def connect(self) -> bool: + """Connect to Emby WebSocket.""" + if self.connected: + return True + + session = await self._ensure_session() + + # Build WebSocket URL with authentication params + params = { + "api_key": self._api_key, + "deviceId": DEVICE_ID, + } + + try: + self._ws = await session.ws_connect( + self._url, + params=params, + heartbeat=30, + timeout=aiohttp.ClientTimeout(total=10), + ) + self._running = True + _LOGGER.debug("Connected to Emby WebSocket at %s", self._url) + + # Start listening for messages + self._listen_task = asyncio.create_task(self._listen()) + + return True + + except aiohttp.ClientError as err: + _LOGGER.warning("Failed to connect to Emby WebSocket: %s", err) + return False + except Exception as err: + _LOGGER.exception("Unexpected error connecting to WebSocket: %s", err) + return False + + async def _listen(self) -> None: + """Listen for WebSocket messages.""" + if not self._ws: + return + + try: + async for msg in self._ws: + if msg.type == aiohttp.WSMsgType.TEXT: + try: + data = json.loads(msg.data) + await self._handle_message(data) + except json.JSONDecodeError: + _LOGGER.warning("Invalid JSON received: %s", msg.data) + + elif msg.type == aiohttp.WSMsgType.ERROR: + _LOGGER.error( + "WebSocket error: %s", self._ws.exception() if self._ws else "Unknown" + ) + break + + elif msg.type in ( + aiohttp.WSMsgType.CLOSE, + aiohttp.WSMsgType.CLOSED, + aiohttp.WSMsgType.CLOSING, + ): + _LOGGER.debug("WebSocket connection closed") + break + + except asyncio.CancelledError: + _LOGGER.debug("WebSocket listener cancelled") + except Exception as err: + _LOGGER.exception("Error in WebSocket listener: %s", err) + finally: + self._ws = None + + # Attempt reconnection if still running + if self._running: + _LOGGER.info( + "WebSocket disconnected, will reconnect in %d seconds", + self._reconnect_interval, + ) + asyncio.create_task(self._reconnect()) + + async def _reconnect(self) -> None: + """Attempt to reconnect to WebSocket.""" + await asyncio.sleep(self._reconnect_interval) + + if self._running and not self.connected: + _LOGGER.debug("Attempting WebSocket reconnection...") + if await self.connect(): + await self.subscribe_to_sessions() + + async def _handle_message(self, message: dict[str, Any]) -> None: + """Handle an incoming WebSocket message.""" + msg_type = message.get("MessageType", "") + data = message.get("Data") + + _LOGGER.debug("Received WebSocket message: %s", msg_type) + + if msg_type in TRACKED_MESSAGE_TYPES: + # Notify all callbacks + for callback in self._callbacks: + try: + callback(msg_type, data) + except Exception: + _LOGGER.exception("Error in WebSocket callback") + + async def subscribe_to_sessions(self) -> None: + """Subscribe to session updates.""" + if not self.connected: + _LOGGER.warning("Cannot subscribe: WebSocket not connected") + return + + # Request session updates every 1500ms + await self._send_message(WS_MESSAGE_SESSIONS_START, "0,1500") + _LOGGER.debug("Subscribed to session updates") + + async def unsubscribe_from_sessions(self) -> None: + """Unsubscribe from session updates.""" + if self.connected: + await self._send_message(WS_MESSAGE_SESSIONS_STOP, "") + + async def _send_message(self, message_type: str, data: Any) -> None: + """Send a message through the WebSocket.""" + if not self._ws or self._ws.closed: + return + + message = { + "MessageType": message_type, + "Data": data, + } + + try: + await self._ws.send_json(message) + except Exception as err: + _LOGGER.warning("Failed to send WebSocket message: %s", err) + + def add_callback(self, callback: Callable[[str, Any], None]) -> Callable[[], None]: + """Add a callback for WebSocket messages. + + Returns a function to remove the callback. + """ + self._callbacks.append(callback) + + def remove_callback() -> None: + if callback in self._callbacks: + self._callbacks.remove(callback) + + return remove_callback + + async def disconnect(self) -> None: + """Disconnect from WebSocket.""" + self._running = False + + if self._listen_task and not self._listen_task.done(): + self._listen_task.cancel() + try: + await self._listen_task + except asyncio.CancelledError: + pass + + if self._ws and not self._ws.closed: + await self._ws.close() + + self._ws = None + _LOGGER.debug("Disconnected from Emby WebSocket") + + async def close(self) -> None: + """Close the WebSocket and session.""" + await self.disconnect() + + if self._owns_session and self._session and not self._session.closed: + await self._session.close() diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..e78bf41 --- /dev/null +++ b/hacs.json @@ -0,0 +1,6 @@ +{ + "name": "Emby Media Player", + "homeassistant": "2024.1.0", + "render_readme": true, + "content_in_root": false +}