Initial commit for Emby Media Player HAOS HACS integration
All checks were successful
Validate / Hassfest (push) Successful in 9s

This commit is contained in:
2026-02-05 00:15:04 +03:00
commit 46cb2fbac2
20 changed files with 2603 additions and 0 deletions

46
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -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
<details>
<summary>Relevant log entries</summary>
```
Paste logs here
```
</details>
## Additional Context
Any other context about the problem.

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -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

View File

@@ -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.

20
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -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)

17
.github/workflows/validate.yaml vendored Normal file
View File

@@ -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'

90
.gitignore vendored Normal file
View File

@@ -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

111
CLAUDE.md Normal file
View File

@@ -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

155
README.md Normal file
View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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",
)
),
}
),
)

View File

@@ -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"

View File

@@ -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()

View File

@@ -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"
}

View File

@@ -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,
)

View File

@@ -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"
}
}
}
}
}

View File

@@ -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"
}
}
}
}
}

View File

@@ -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()

6
hacs.json Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "Emby Media Player",
"homeassistant": "2024.1.0",
"render_readme": true,
"content_in_root": false
}