Initial commit for Emby Media Player HAOS HACS integration
All checks were successful
Validate / Hassfest (push) Successful in 9s
All checks were successful
Validate / Hassfest (push) Successful in 9s
This commit is contained in:
46
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
46
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal 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
|
||||||
27
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
27
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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
20
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal 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
17
.github/workflows/validate.yaml
vendored
Normal 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
90
.gitignore
vendored
Normal 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
111
CLAUDE.md
Normal 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
155
README.md
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
# Emby Media Player
|
||||||
|
|
||||||
|
[](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.
|
||||||
135
custom_components/emby_player/__init__.py
Normal file
135
custom_components/emby_player/__init__.py
Normal 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
|
||||||
392
custom_components/emby_player/api.py
Normal file
392
custom_components/emby_player/api.py
Normal 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
|
||||||
231
custom_components/emby_player/browse_media.py
Normal file
231
custom_components/emby_player/browse_media.py
Normal 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,
|
||||||
|
)
|
||||||
230
custom_components/emby_player/config_flow.py
Normal file
230
custom_components/emby_player/config_flow.py
Normal 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",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
89
custom_components/emby_player/const.py
Normal file
89
custom_components/emby_player/const.py
Normal 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"
|
||||||
283
custom_components/emby_player/coordinator.py
Normal file
283
custom_components/emby_player/coordinator.py
Normal 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()
|
||||||
12
custom_components/emby_player/manifest.json
Normal file
12
custom_components/emby_player/manifest.json
Normal 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"
|
||||||
|
}
|
||||||
421
custom_components/emby_player/media_player.py
Normal file
421
custom_components/emby_player/media_player.py
Normal 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,
|
||||||
|
)
|
||||||
43
custom_components/emby_player/strings.json
Normal file
43
custom_components/emby_player/strings.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
custom_components/emby_player/translations/en.json
Normal file
43
custom_components/emby_player/translations/en.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
244
custom_components/emby_player/websocket.py
Normal file
244
custom_components/emby_player/websocket.py
Normal 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()
|
||||||
Reference in New Issue
Block a user