Production-readiness pass: security hardening, performance improvements, new services (send_message, set_repeat, refresh_library), diagnostics, reauth flow, image proxy, per-instance device IDs, exponential WS reconnect backoff, ID validation, stale device cleanup, and supporting integration plumbing. Three rounds of independent code review applied. See RELEASE_NOTES.md for the full changelog.
This commit is contained in:
@@ -1,111 +1,154 @@
|
||||
# Claude Code Session Notes
|
||||
# Claude Code – Project Notes
|
||||
|
||||
This file documents mistakes and lessons learned during the development of this integration.
|
||||
Operator notes for working on this repo with Claude Code (or any other LLM
|
||||
coding agent). Keep this file accurate; future agents read it as ground truth.
|
||||
|
||||
## Mistakes Made
|
||||
## Project at a glance
|
||||
|
||||
### 1. Missing `/emby` Prefix on API Endpoints
|
||||
- **What this is.** A Home Assistant custom integration that exposes Emby
|
||||
Server clients as `media_player` entities, with WebSocket real-time updates
|
||||
and a REST polling safety net.
|
||||
- **Layout.** All code lives under
|
||||
`custom_components/emby_player/`. HACS metadata at the repo root
|
||||
(`hacs.json`, `RELEASE_NOTES.md`, `README.md`).
|
||||
- **Distribution.** HACS custom repo, hosted on a private Gitea instance
|
||||
(`git.dolgolyov-family.by`). No GitHub mirror — `manifest.json` keeps
|
||||
`codeowners: []` because hassfest validates handles against GitHub.
|
||||
- **Min HA version.** `2024.10.0` (declared in `hacs.json`); the Python is
|
||||
expected to be 3.12+ as shipped by HAOS.
|
||||
- **Quality scale.** Targets `silver`.
|
||||
|
||||
**Problem:** Initially created all API endpoints without the `/emby` prefix.
|
||||
## Module map
|
||||
|
||||
```python
|
||||
# Wrong
|
||||
ENDPOINT_SYSTEM_INFO = "/System/Info"
|
||||
ENDPOINT_USERS = "/Users"
|
||||
| File | Role |
|
||||
| ---- | ---- |
|
||||
| `__init__.py` | Entry setup/unload, runtime data dataclass, hub device, manifest-version lookup, `async_remove_config_entry_device` |
|
||||
| `api.py` | REST client. Owns no aiohttp session. Validates IDs and image types before URL build. Pins working `/emby` vs `""` prefix on connect. |
|
||||
| `websocket.py` | WS client. API key in headers (never URL). Exponential backoff + jitter. Detaches async callbacks via task. |
|
||||
| `coordinator.py` | `DataUpdateCoordinator` subclass. Frozen dataclasses. WS events update sessions in place. REST poll merges via `last_seen`. `_safe_int` for malformed payloads. |
|
||||
| `media_player.py` | The entity class. Image proxy via `async_get_media_image`. Stale device prune. Enqueue → Emby command map. |
|
||||
| `config_flow.py` | User + reauth flows. Uses `getattr` fallbacks for HA-version-shifting helpers. |
|
||||
| `browse_media.py` | Media browser; wraps all errors as `BrowseError`. |
|
||||
| `services.py` / `services.yaml` | `send_message`, `set_repeat`, `refresh_library`. |
|
||||
| `diagnostics.py` | Redacted entry + sessions dump; session IDs hashed. |
|
||||
| `const.py` | All constants, including `EMBY_ID_PATTERN`, `ALLOWED_IMAGE_TYPES`, timeouts, backoff bounds. |
|
||||
| `manifest.json` | HA integration metadata + ssdp / zeroconf hints. |
|
||||
| `strings.json` / `translations/en.json` | UI strings (config flow, services, reauth). |
|
||||
|
||||
# Correct
|
||||
ENDPOINT_SYSTEM_INFO = "/emby/System/Info"
|
||||
ENDPOINT_USERS = "/emby/Users"
|
||||
```
|
||||
## Workflow expectations
|
||||
|
||||
**Impact:** Connection to Emby server failed with "cannot connect" errors.
|
||||
1. **Plan first.** For anything bigger than a typo, use `planner` /
|
||||
`architect` agents (or sketch a TodoWrite plan) before editing files.
|
||||
2. **Edit minimally.** Match the project's style — explicit type hints,
|
||||
`from __future__ import annotations`, frozen dataclasses for state.
|
||||
3. **Verify locally.**
|
||||
- Parse check: `python -c "import ast, pathlib; [ast.parse(p.read_text(encoding='utf-8'), filename=str(p)) for p in pathlib.Path('custom_components/emby_player').glob('*.py')]"`
|
||||
- JSON validation for `manifest.json`, `strings.json`, `translations/*.json`, `hacs.json`.
|
||||
4. **Review before commit.** Spawn `python-reviewer` (or `code-reviewer`)
|
||||
after any non-trivial change. Two-round reviews are the norm here — the
|
||||
first pass usually surfaces hidden issues.
|
||||
5. **Never commit without explicit user approval.** This rule has been
|
||||
broken before — don't.
|
||||
|
||||
**Lesson:** Always verify API endpoint formats against official documentation. Emby Server requires the `/emby` prefix for all API calls.
|
||||
## Conventions / invariants
|
||||
|
||||
---
|
||||
- **Aiohttp session is owned by HA.** API and WebSocket clients accept an
|
||||
injected `aiohttp.ClientSession` and never close it. Do not add
|
||||
`_ensure_session` or `_owns_session` flags back.
|
||||
- **IDs are validated.** Anything that lands in a URL path (`session_id`,
|
||||
`item_id`, `user_id`, `parent_id`) must go through `_validate_emby_id`
|
||||
(regex `^[A-Za-z0-9_-]{1,128}$`). Don't bypass.
|
||||
- **Image type whitelist.** `image_type` must be in `ALLOWED_IMAGE_TYPES`
|
||||
(see `const.py`). Adding a new image type means updating the constant.
|
||||
- **API key never goes into URLs.** Image fetches use the `X-Emby-Token`
|
||||
header via `EmbyApiClient.fetch_image`. The HA media-player image proxy
|
||||
(`async_get_media_image` / `async_get_browse_image`) is the public path.
|
||||
- **WebSocket auth via headers.** Same reason: no query-string leakage to
|
||||
proxy logs.
|
||||
- **Device identity is per-config-entry.** `device_id` is derived from
|
||||
`instance_id.async_get(hass)` + `entry.entry_id`. Multiple HA installs on
|
||||
one Emby server must not collide.
|
||||
- **Frozen dataclasses for coordinator state.** Use
|
||||
`dataclasses.replace(...)` to produce a new session; never mutate.
|
||||
- **Auth failure routing.** Authentication errors during `_async_update_data`
|
||||
must raise `ConfigEntryAuthFailed` so HA triggers the reauth flow. Do not
|
||||
re-introduce a custom subclass of `UpdateFailed` for auth.
|
||||
- **No raw `print` / no emoji** in code or comments. Use `_LOGGER`.
|
||||
- **Comments explain WHY only.** Identifier names should carry the WHAT.
|
||||
|
||||
### 2. Incorrect Volume Control API Format
|
||||
## Emby API gotchas (historical, still worth remembering)
|
||||
|
||||
**Problem:** Tried multiple incorrect formats for the SetVolume command before finding the correct one.
|
||||
- **`/emby` prefix.** Most Emby Server deployments require `/emby/` in front
|
||||
of API paths. The client probes both `/emby/System/Info` and
|
||||
`/System/Info` on `test_connection()` and pins the working prefix in
|
||||
`self._prefix`. Don't hardcode the prefix into endpoint constants.
|
||||
- **General commands.** Emby's `/Sessions/{id}/Command` endpoint expects
|
||||
`{"Name": "<command>", "Arguments": {...}}`, NOT
|
||||
`/Command/{commandName}?...`. Arguments must be string-typed values, so
|
||||
`_send_command` stringifies everything.
|
||||
- **WebSocket `ForceKeepAlive`.** Emby will close the WS connection if you
|
||||
don't echo back `KeepAlive` to `ForceKeepAlive`. Already handled in
|
||||
`_handle_message`; don't strip that path.
|
||||
- **Non-admin API keys.** `GET /Users` returns 401/403 for non-admin tokens.
|
||||
`EmbyApiClient.get_users` falls back to `/Users/Public`.
|
||||
- **Ticks.** Emby uses 100ns ticks (`TICKS_PER_SECOND = 10_000_000`). Use
|
||||
`round()` not `int()` when converting seconds → ticks to avoid off-by-one
|
||||
on resume positions.
|
||||
- **`NumberSelector` returns float.** Always `int(...)` before passing to
|
||||
the API layer (port, scan_interval, etc.).
|
||||
- **`PlaySessionId` vs `SessionId`.** WS playback events carry both;
|
||||
`SessionId` is the per-client session, `PlaySessionId` is per-stream and
|
||||
can collide across devices. The coordinator only keys off `SessionId`.
|
||||
|
||||
```python
|
||||
# Attempt 1 - Wrong endpoint with body
|
||||
endpoint = f"/Sessions/{session_id}/Command/SetVolume"
|
||||
data = {"Arguments": {"Volume": 50}}
|
||||
## Performance budget
|
||||
|
||||
# Attempt 2 - Wrong: query parameter
|
||||
endpoint = f"/Sessions/{session_id}/Command/SetVolume?Volume=50"
|
||||
- **WS connected.** Trust WS for live updates. REST poll runs every 5 min
|
||||
(`DEFAULT_SCAN_INTERVAL_WS = 300`) as a safety net; the merge logic in
|
||||
`_async_update_data` keeps WS-current sessions intact.
|
||||
- **WS down.** Polling falls back to the user-configured `scan_interval`
|
||||
(default 10 s; range 5–60 s).
|
||||
- **Image fetches.** Run on a separate 30 s timeout
|
||||
(`IMAGE_FETCH_TIMEOUT_SECONDS`), independent of the 15 s REST timeout.
|
||||
|
||||
# Correct format
|
||||
endpoint = f"/Sessions/{session_id}/Command"
|
||||
data = {"Name": "SetVolume", "Arguments": {"Volume": "50"}} # Arguments as STRINGS
|
||||
```
|
||||
## Security checklist (anything that touches the API key or user URLs)
|
||||
|
||||
**Impact:** Volume control didn't work even though mute/unmute worked fine.
|
||||
- [ ] API key only travels in headers (`X-Emby-Token`) or HA's own redacted
|
||||
config entry storage — never URLs.
|
||||
- [ ] All IDs flowing into REST paths validated.
|
||||
- [ ] `image_type` validated against `ALLOWED_IMAGE_TYPES`.
|
||||
- [ ] Diagnostics redact API key and hash session/device/user IDs.
|
||||
- [ ] `verify_ssl` honoured for HTTPS; defaults to verifying.
|
||||
|
||||
**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
|
||||
## Files NOT to edit casually
|
||||
|
||||
---
|
||||
- `hacs.json` — bumping the `homeassistant` floor is a breaking change for
|
||||
installed users; coordinate with a release.
|
||||
- `manifest.json` `version` — must be bumped together with
|
||||
`RELEASE_NOTES.md` and a Git tag.
|
||||
- `RELEASE_NOTES.md` v0.x.0 sections — append new sections; don't rewrite
|
||||
history.
|
||||
|
||||
### 3. NumberSelector Returns Float, Not Int
|
||||
## Release flow
|
||||
|
||||
**Problem:** Home Assistant's `NumberSelector` widget returns float values, but port numbers must be integers.
|
||||
1. Land all changes on a feature branch.
|
||||
2. Bump `manifest.json` version + add a section to `RELEASE_NOTES.md` (newest
|
||||
first).
|
||||
3. Open a PR on Gitea; wait for review.
|
||||
4. After merge, tag `vX.Y.Z` on the default branch — the Gitea release
|
||||
workflow under `.gitea/` cuts the release.
|
||||
|
||||
```python
|
||||
# Wrong - port could be 8096.0
|
||||
self._port = user_input.get(CONF_PORT, DEFAULT_PORT)
|
||||
## Anti-patterns we've already paid for
|
||||
|
||||
# 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
|
||||
- Bypassing constants and hardcoding endpoint paths — see history of the
|
||||
missing `/emby` prefix.
|
||||
- Reading API responses without `isinstance` checks — Emby sometimes
|
||||
returns `null` or unexpected shapes for fields like `RunTimeTicks`.
|
||||
Use the `_safe_int` helper in `coordinator.py` for any numeric field
|
||||
coming off the wire.
|
||||
- Reusing a fixed `DEVICE_ID` across HA instances — Emby will evict
|
||||
whichever instance last registered, silently breaking the other.
|
||||
- Including the API key in URLs returned to the browser via
|
||||
`media_image_url`. The fix is the image proxy in `media_player.py`; don't
|
||||
reintroduce the URL-with-key shortcut "for performance".
|
||||
- `except Exception: pass` — always at least `_LOGGER.debug(...)` so
|
||||
failures are diagnosable.
|
||||
|
||||
@@ -1,96 +1,190 @@
|
||||
# Emby Media Player
|
||||
|
||||
[](https://github.com/hacs/integration)
|
||||
[](RELEASE_NOTES.md)
|
||||
[](https://www.home-assistant.io/)
|
||||
|
||||
A Home Assistant custom integration that exposes Emby media server clients as media players with full playback control, media metadata, and library browsing capabilities.
|
||||
A Home Assistant custom integration that exposes Emby Server clients as media players with full playback control, real-time WebSocket updates, library browsing, repeat-mode control, and on-screen messaging.
|
||||
|
||||
## 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
|
||||
### Playback & metadata
|
||||
|
||||
- **Full media-player control**: play, pause, stop, seek, volume, mute, next/previous track, repeat mode.
|
||||
- **Real-time updates over WebSocket** with a slow REST poll as a safety net — `PlaybackStart`, `PlaybackProgress`, and `PlaybackStopped` events update the entity in place.
|
||||
- **Now-playing metadata**: title, artist, album, series / season / episode, artwork, duration, position, play method (DirectPlay / DirectStream / Transcode).
|
||||
- **Per-client device class** inferred from the Emby client (AndroidTV / Kodi / Roku → TV, music clients → speaker, others → generic).
|
||||
- **Enqueue semantics** mapped to Emby commands: HA's `play` / `replace` → `PlayNow`, `next` → `PlayNext`, `add` → `PlayLast`.
|
||||
|
||||
### Library
|
||||
|
||||
- **Browse the Emby library** from Home Assistant's media browser — Movies, TV Shows, Music, Playlists, user views.
|
||||
- **Server-side image proxy** — artwork is fetched with the API key in an HTTP header and never leaks into URLs in the browser.
|
||||
|
||||
### Security & reliability
|
||||
|
||||
- **API key never appears in URLs** (no query-string leakage to proxy logs or browser history).
|
||||
- **Per-instance device IDs** derived from the Home Assistant UUID — multiple HA installs no longer collide on the same Emby server.
|
||||
- **Self-signed HTTPS** support via a `verify_ssl` toggle.
|
||||
- **Reauth flow** prompts for a fresh API key when the server rejects the current one.
|
||||
- **Exponential backoff with jitter** for WebSocket reconnects; auth failures stop the retry loop.
|
||||
- **ID validation** on all session / item / user identifiers (defense in depth against path traversal in REST paths).
|
||||
- **Stale device cleanup** removes media-player devices for sessions that disappear for over 30 minutes (with a 10-minute grace period after startup).
|
||||
|
||||
### Integration plumbing
|
||||
|
||||
- **Diagnostics** — Settings → Integrations → "..." → Download diagnostics for a redacted JSON dump (API key and session IDs are hashed/redacted).
|
||||
- **Hub device** linked to per-session devices via `via_device` — easier to clean up.
|
||||
- **Zeroconf + SSDP discovery hints** so Emby servers can be found by HA.
|
||||
- **Three services**: `send_message`, `set_repeat`, `refresh_library`.
|
||||
|
||||
## 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
|
||||
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
|
||||
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**
|
||||
1. Go to **Settings → Devices & Services**.
|
||||
2. Click **Add Integration** and search for **Emby Media Player**.
|
||||
3. Enter your Emby server details:
|
||||
- **Host** — Emby server hostname or IP address.
|
||||
- **Port** — default `8096` (HTTP) or `8920` (HTTPS).
|
||||
- **API Key** — created in Emby Dashboard → Extended → API Keys.
|
||||
- **Use SSL** — enable if your server uses HTTPS.
|
||||
- **Verify SSL certificate** — disable only when using a self-signed certificate that HA's CA bundle doesn't trust.
|
||||
4. Select the Emby user account to use for browsing & playback.
|
||||
5. Click **Submit**.
|
||||
|
||||
### Getting an API Key
|
||||
### 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
|
||||
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.
|
||||
|
||||
> Admin keys list all users automatically. Non-admin keys fall back to the
|
||||
> public users endpoint — the integration handles both.
|
||||
|
||||
### Reauthentication
|
||||
|
||||
If you regenerate the API key on the Emby server, Home Assistant will flag
|
||||
the integration as needing attention and walk you through entering the new
|
||||
key — no need to remove and re-add the integration.
|
||||
|
||||
## Options
|
||||
|
||||
After configuration, you can adjust the following options:
|
||||
After configuration, open **Configure** on the integration to adjust:
|
||||
|
||||
- **Scan Interval**: Polling interval in seconds (5-60, default: 10). Used as a fallback when WebSocket connection is unavailable.
|
||||
- **Scan Interval** (5–60 s, default `10`). Used as the polling cadence while
|
||||
the WebSocket is unavailable. When the WebSocket is connected, the
|
||||
integration drops to a 5-minute REST safety-net poll automatically.
|
||||
|
||||
## 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 |
|
||||
| Feature | Support |
|
||||
| ------------------- | ------- |
|
||||
| Play / Pause | Yes |
|
||||
| Stop | Yes |
|
||||
| Volume Control | Yes |
|
||||
| Volume Mute | Yes |
|
||||
| Seek | Yes |
|
||||
| Next Track | Yes |
|
||||
| Previous Track | Yes |
|
||||
| Repeat Mode | Yes |
|
||||
| Media Browser | Yes |
|
||||
| Play Media | Yes |
|
||||
| Enqueue / Play Next | 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.)
|
||||
| Attribute | Description |
|
||||
| -------------- | -------------------------------------------------------------------------- |
|
||||
| `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 currently signed in on the client (when available) |
|
||||
| `item_id` | Currently playing item ID (only while playing) |
|
||||
| `item_type` | Type of media (`Movie`, `Episode`, `Audio`, …) |
|
||||
| `play_method` | `DirectPlay`, `DirectStream`, or `Transcode` (only while playing) |
|
||||
|
||||
## Services
|
||||
|
||||
### `emby_player.send_message`
|
||||
|
||||
Display a banner notification on an Emby client. Great for doorbells, alarms,
|
||||
laundry timers, etc.
|
||||
|
||||
| Field | Required | Description |
|
||||
| ------------ | -------- | -------------------------------------------- |
|
||||
| `entity_id` | Yes | One or more Emby media-player entities. |
|
||||
| `message` | Yes | Text to display. |
|
||||
| `header` | No | Optional header / title. |
|
||||
| `timeout_ms` | No | How long to show the message (100–60000 ms). |
|
||||
|
||||
```yaml
|
||||
service: emby_player.send_message
|
||||
target:
|
||||
entity_id: media_player.emby_living_room_tv
|
||||
data:
|
||||
header: "Doorbell"
|
||||
message: "Someone's at the front door"
|
||||
timeout_ms: 5000
|
||||
```
|
||||
|
||||
### `emby_player.set_repeat`
|
||||
|
||||
Set the repeat mode on the current playback.
|
||||
|
||||
| Field | Required | Description |
|
||||
| ------------- | -------- | -------------------------------------------------------- |
|
||||
| `entity_id` | Yes | One or more Emby media-player entities. |
|
||||
| `repeat_mode` | Yes | One of `RepeatNone`, `RepeatOne`, or `RepeatAll`. |
|
||||
|
||||
```yaml
|
||||
service: emby_player.set_repeat
|
||||
target:
|
||||
entity_id: media_player.emby_living_room_tv
|
||||
data:
|
||||
repeat_mode: RepeatAll
|
||||
```
|
||||
|
||||
> The standard HA media-player repeat control is also wired up — `media_player.repeat_set` will work without using this service.
|
||||
|
||||
### `emby_player.refresh_library`
|
||||
|
||||
Trigger a server-side library scan. If `entity_id` is omitted, all configured
|
||||
Emby servers are refreshed.
|
||||
|
||||
```yaml
|
||||
service: emby_player.refresh_library
|
||||
```
|
||||
|
||||
## Diagnostics
|
||||
|
||||
Open **Settings → Integrations → Emby Media Player → "..." → Download diagnostics** to get a redacted JSON dump containing the entry, the current sessions, server id, and WebSocket connection status. Useful when filing bug reports.
|
||||
|
||||
The dump redacts the API key and replaces real session / device / user IDs with stable hashes so it's safe to share.
|
||||
|
||||
## Automation Examples
|
||||
|
||||
### Dim lights when playing a movie
|
||||
### Dim lights when a movie starts
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
@@ -110,7 +204,7 @@ automation:
|
||||
brightness_pct: 20
|
||||
```
|
||||
|
||||
### Pause playback when doorbell rings
|
||||
### Pause playback when the doorbell rings, then notify the screen
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
@@ -123,32 +217,62 @@ automation:
|
||||
- service: media_player.media_pause
|
||||
target:
|
||||
entity_id: media_player.emby_living_room_tv
|
||||
- service: emby_player.send_message
|
||||
target:
|
||||
entity_id: media_player.emby_living_room_tv
|
||||
data:
|
||||
header: "Doorbell"
|
||||
message: "Someone is at the door"
|
||||
timeout_ms: 8000
|
||||
```
|
||||
|
||||
### Toggle shuffle/repeat from a script
|
||||
|
||||
```yaml
|
||||
script:
|
||||
emby_repeat_all:
|
||||
sequence:
|
||||
- service: media_player.repeat_set
|
||||
target:
|
||||
entity_id: media_player.emby_living_room_tv
|
||||
data:
|
||||
repeat: all
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Issues
|
||||
### 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)
|
||||
- Verify the Emby server is reachable from the Home Assistant host (try `ping` / `curl http://<host>:8096/emby/System/Info` from a terminal on the HA box).
|
||||
- Check that the API key is valid and not revoked in Emby Dashboard → Extended → API Keys.
|
||||
- For HTTPS with a self-signed certificate, toggle **Verify SSL certificate** off in the integration config.
|
||||
- If reauth keeps failing, regenerate the API key on the server and try again.
|
||||
|
||||
### No Media Players Appearing
|
||||
### 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
|
||||
- Media-player entities are only created for **active sessions that support remote control**. Start playback on an Emby client first.
|
||||
- Browser-based Emby Web sessions usually do not support remote control and won't appear as entities.
|
||||
- Check **Settings → System → Logs** in Home Assistant for messages from `custom_components.emby_player`.
|
||||
|
||||
### WebSocket Connection Failed
|
||||
### 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
|
||||
If the WebSocket can't connect, the integration falls back to REST polling at the configured **Scan Interval**. To restore real-time updates:
|
||||
|
||||
- Allow WebSocket traffic to TCP `8096` / `8920` from the HA host.
|
||||
- If you run Emby behind a reverse proxy, make sure `Upgrade` / `Connection` headers are forwarded for `/embywebsocket`.
|
||||
- For HTTPS proxies (Caddy / Traefik / nginx), confirm the WebSocket path is included in the proxy config.
|
||||
|
||||
### Stale devices in the registry
|
||||
|
||||
Devices for sessions that haven't been seen for 30 minutes are removed automatically (after a 10-minute startup grace). You can also manually delete a device from **Settings → Devices & Services → Emby Media Player → Device → Delete**.
|
||||
|
||||
## Versioning
|
||||
|
||||
See [RELEASE_NOTES.md](RELEASE_NOTES.md) for the full changelog.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please open an issue or submit a pull request.
|
||||
Contributions are welcome. Please open an issue or submit a pull request.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,8 +1,157 @@
|
||||
## v0.2.0 (2026-05-26)
|
||||
|
||||
Production-readiness pass: security hardening, performance improvements,
|
||||
new services, and supporting integration plumbing. Three rounds of
|
||||
independent code review applied.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- `EmbyApiClient` and `EmbyWebSocket` constructors now require an injected
|
||||
`aiohttp.ClientSession` and a `device_id`. Both clients never own or close
|
||||
the session — Home Assistant owns it. Custom integrations or scripts
|
||||
importing these classes directly must update their call sites.
|
||||
|
||||
### Features
|
||||
|
||||
- **WebSocket real-time updates** now drive entity state without firing a
|
||||
REST refresh on every `PlaybackProgress` event. Sessions update in place
|
||||
via `dataclasses.replace`; REST falls back to a 5-minute safety net while
|
||||
the WS is up.
|
||||
- **Image proxy**: artwork is fetched server-side with the API key in the
|
||||
`X-Emby-Token` header; the API key never appears in URLs returned to the
|
||||
browser.
|
||||
- **Per-instance device ID** derived from `instance_id.async_get(hass)` and
|
||||
the config-entry id — multiple Home Assistant installs no longer collide
|
||||
on the same Emby server.
|
||||
- **Self-signed HTTPS support** via a new `verify_ssl` toggle in the config
|
||||
flow (defaults to verifying).
|
||||
- **Reauth flow**: when the server rejects the API key, Home Assistant
|
||||
prompts for a new one inline instead of leaving the integration broken.
|
||||
- **Repeat mode control** wired through the standard
|
||||
`media_player.repeat_set` UI; HA `MediaPlayerEnqueue` mapped explicitly to
|
||||
Emby `PlayNow` / `PlayNext` / `PlayLast`.
|
||||
- **Per-client device class** inferred from the Emby client name (AndroidTV /
|
||||
Kodi / Roku → TV, music clients → Speaker, others → generic).
|
||||
- **Three new services**:
|
||||
- `emby_player.send_message` — display a banner on an Emby client
|
||||
(doorbells, alarms, laundry timers).
|
||||
- `emby_player.set_repeat` — set RepeatNone / RepeatOne / RepeatAll.
|
||||
- `emby_player.refresh_library` — trigger a server-side library scan.
|
||||
- **Diagnostics**: redacted entry + sessions JSON dump from
|
||||
Settings → Integrations → "..." → Download diagnostics. API key is
|
||||
redacted; session / device / user IDs are replaced with stable hashes.
|
||||
- **Hub device** registered for `via_device` linkage; per-session devices
|
||||
now appear under it.
|
||||
- **Zeroconf + SSDP discovery hints** so Home Assistant can find Emby
|
||||
servers.
|
||||
- **Stale device cleanup** removes devices for sessions absent over 30
|
||||
minutes (with a 10-minute setup grace period); the coordinator's
|
||||
`forget_session` helper prevents the last-seen map from growing
|
||||
unbounded.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- WebSocket reconnect uses **exponential backoff with jitter** (capped at
|
||||
5 min) instead of a fixed 30 s; auth failures no longer trigger infinite
|
||||
retry; the reconnect task is tracked and cancelled cleanly on unload.
|
||||
- `media_position_updated_at` now reflects the real coordinator update
|
||||
time, eliminating spurious state writes from returning `utcnow()` on
|
||||
every property read.
|
||||
- Authentication failures during a coordinator update now raise
|
||||
`ConfigEntryAuthFailed` so Home Assistant actually triggers the reauth
|
||||
flow.
|
||||
- WebSocket authentication moved into HTTP headers — the API key no longer
|
||||
appears in proxy access logs.
|
||||
- `MediaPlayerDeviceClass.TV` is no longer hardcoded for non-TV clients.
|
||||
- `_attr_has_entity_name = True` combined with `_attr_name` no longer
|
||||
double-prints the device name in the UI.
|
||||
- Browse media now raises `BrowseError` with context (and wraps unexpected
|
||||
exceptions instead of surfacing 500s).
|
||||
- Non-admin API keys fall back to `/Users/Public` instead of failing setup.
|
||||
- All session / item / user IDs are validated against
|
||||
`^[A-Za-z0-9_-]{1,128}$` before interpolation into REST paths
|
||||
(defense-in-depth against path traversal / SSRF).
|
||||
- `image_type` validated against a whitelist (`Primary`, `Backdrop`,
|
||||
`Thumb`, `Logo`, `Banner`, `Art`, `Disc`, `Box`).
|
||||
- `play_media` raises `ServiceValidationError` on bad input (not
|
||||
`ValueError`); validates `media_id` format and `position` type.
|
||||
- `async_remove_config_entry_device` refuses to remove the hub device and
|
||||
any session still present in the coordinator.
|
||||
- WebSocket `ForceKeepAlive` is now echoed as `KeepAlive` so the server
|
||||
doesn't drop idle connections.
|
||||
- `PlaySessionId` fallback removed from playback-event parsing — only
|
||||
`SessionId` is matched, eliminating cross-device false positives.
|
||||
- `_safe_int` helper hardens numeric field parsing against `null` /
|
||||
string / malformed payloads (`RunTimeTicks`, `PositionTicks`,
|
||||
`VolumeLevel`, etc.).
|
||||
- Image fetches get a dedicated 30 s timeout, separate from the 15 s REST
|
||||
default.
|
||||
- `manifest.json` `codeowners` corrected to an empty list (hassfest
|
||||
validates entries against GitHub handles).
|
||||
|
||||
### Performance
|
||||
|
||||
- WebSocket / REST race resolved: `_async_update_data` records
|
||||
`request_started`, then merges REST results with any session whose
|
||||
`last_seen` is newer (WS state wins for in-flight progress).
|
||||
- REST poll interval automatically slows to 5 min while the WebSocket is
|
||||
connected, restoring the user-configured interval if it disconnects.
|
||||
- WebSocket callbacks may be sync or async; async ones are detached via
|
||||
`asyncio.create_task` so a slow consumer can't stall the reader.
|
||||
|
||||
---
|
||||
|
||||
### Development / Internal
|
||||
|
||||
- Frozen dataclasses (`EmbyNowPlaying`, `EmbyPlayState`, `EmbySession`)
|
||||
across the coordinator state — safer for concurrency and immutability.
|
||||
- `manifest.json` declares `integration_type: hub`, `quality_scale: silver`,
|
||||
`loggers`, and discovery hints.
|
||||
- HACS minimum Home Assistant bumped to `2024.10.0`.
|
||||
- Client version sourced from `manifest.json` at startup via
|
||||
`loader.async_get_integration` (no more `DEVICE_VERSION` drift).
|
||||
- New `diagnostics.py`, `services.py`, `services.yaml`.
|
||||
- Strings + `translations/en.json` extended for `verify_ssl`,
|
||||
`reauth_confirm`, and the three new services.
|
||||
- `README.md` and `CLAUDE.md` rewritten to match the v0.2.0 state of the
|
||||
integration.
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>Files Changed</summary>
|
||||
|
||||
| File | Status |
|
||||
|------|--------|
|
||||
| `CLAUDE.md` | Modified |
|
||||
| `README.md` | Modified |
|
||||
| `RELEASE_NOTES.md` | Modified |
|
||||
| `hacs.json` | Modified |
|
||||
| `custom_components/emby_player/__init__.py` | Modified |
|
||||
| `custom_components/emby_player/api.py` | Modified |
|
||||
| `custom_components/emby_player/browse_media.py` | Modified |
|
||||
| `custom_components/emby_player/config_flow.py` | Modified |
|
||||
| `custom_components/emby_player/const.py` | Modified |
|
||||
| `custom_components/emby_player/coordinator.py` | Modified |
|
||||
| `custom_components/emby_player/manifest.json` | Modified |
|
||||
| `custom_components/emby_player/media_player.py` | Modified |
|
||||
| `custom_components/emby_player/strings.json` | Modified |
|
||||
| `custom_components/emby_player/translations/en.json` | Modified |
|
||||
| `custom_components/emby_player/websocket.py` | Modified |
|
||||
| `custom_components/emby_player/diagnostics.py` | Added |
|
||||
| `custom_components/emby_player/services.py` | Added |
|
||||
| `custom_components/emby_player/services.yaml` | Added |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## v0.1.0 (2026-03-26)
|
||||
|
||||
Initial release of the **Emby Media Player** custom integration for Home Assistant (HACS).
|
||||
|
||||
### Features
|
||||
|
||||
- Full Emby Server media player integration for Home Assistant ([46cb2fb](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-hacs-emby-media-player/commit/46cb2fb))
|
||||
|
||||
---
|
||||
|
||||
@@ -4,14 +4,18 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TypeAlias
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import instance_id
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.loader import async_get_integration
|
||||
|
||||
from .api import EmbyApiClient, EmbyConnectionError
|
||||
from .api import EmbyApiClient, EmbyAuthenticationError, EmbyConnectionError
|
||||
from .const import (
|
||||
CONF_API_KEY,
|
||||
CONF_HOST,
|
||||
@@ -19,16 +23,17 @@ from .const import (
|
||||
CONF_SCAN_INTERVAL,
|
||||
CONF_SSL,
|
||||
CONF_USER_ID,
|
||||
CONF_VERIFY_SSL,
|
||||
DEFAULT_DEVICE_VERSION,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DEFAULT_SSL,
|
||||
DEFAULT_VERIFY_SSL,
|
||||
DOMAIN,
|
||||
)
|
||||
from .coordinator import EmbyCoordinator
|
||||
from .services import async_setup_services, async_unload_services
|
||||
from .websocket import EmbyWebSocket
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
@@ -42,9 +47,25 @@ class EmbyRuntimeData:
|
||||
api: EmbyApiClient
|
||||
websocket: EmbyWebSocket
|
||||
user_id: str
|
||||
server_id: str | None = None
|
||||
|
||||
|
||||
type EmbyConfigEntry = ConfigEntry[EmbyRuntimeData]
|
||||
EmbyConfigEntry: TypeAlias = ConfigEntry[EmbyRuntimeData]
|
||||
|
||||
|
||||
def _build_device_id(hass_uuid: str, entry_id: str) -> str:
|
||||
"""Build a stable per-config-entry device id for Emby."""
|
||||
return f"hass-{hass_uuid[:8]}-{entry_id[:8]}"
|
||||
|
||||
|
||||
async def _get_manifest_version(hass: HomeAssistant) -> str:
|
||||
"""Read the integration version from its manifest, with a safe fallback."""
|
||||
try:
|
||||
integration = await async_get_integration(hass, DOMAIN)
|
||||
except Exception as err: # noqa: BLE001 - manifest loading must not block setup
|
||||
_LOGGER.debug("Falling back to default device version: %s", err)
|
||||
return DEFAULT_DEVICE_VERSION
|
||||
return str(integration.version or DEFAULT_DEVICE_VERSION)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: EmbyConfigEntry) -> bool:
|
||||
@@ -53,39 +74,50 @@ async def async_setup_entry(hass: HomeAssistant, entry: EmbyConfigEntry) -> bool
|
||||
port = int(entry.data[CONF_PORT])
|
||||
api_key = entry.data[CONF_API_KEY]
|
||||
ssl = entry.data.get(CONF_SSL, DEFAULT_SSL)
|
||||
verify_ssl = entry.data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_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
|
||||
scan_interval = int(
|
||||
entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
|
||||
)
|
||||
|
||||
hass_uuid = await instance_id.async_get(hass)
|
||||
device_id = _build_device_id(hass_uuid, entry.entry_id)
|
||||
session = async_get_clientsession(hass)
|
||||
client_version = await _get_manifest_version(hass)
|
||||
|
||||
# Create API client
|
||||
api = EmbyApiClient(
|
||||
host=host,
|
||||
port=port,
|
||||
api_key=api_key,
|
||||
ssl=ssl,
|
||||
verify_ssl=verify_ssl,
|
||||
session=session,
|
||||
device_id=device_id,
|
||||
client_version=client_version,
|
||||
)
|
||||
|
||||
# Test connection
|
||||
try:
|
||||
await api.test_connection()
|
||||
server_info = await api.test_connection()
|
||||
except EmbyAuthenticationError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Authentication failed for Emby server: {err}"
|
||||
) from err
|
||||
except EmbyConnectionError as err:
|
||||
raise ConfigEntryNotReady(f"Cannot connect to Emby server: {err}") from 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,
|
||||
verify_ssl=verify_ssl,
|
||||
session=session,
|
||||
device_id=device_id,
|
||||
client_version=client_version,
|
||||
)
|
||||
|
||||
# Create coordinator
|
||||
coordinator = EmbyCoordinator(
|
||||
hass=hass,
|
||||
api=api,
|
||||
@@ -93,43 +125,99 @@ async def async_setup_entry(hass: HomeAssistant, entry: EmbyConfigEntry) -> bool
|
||||
scan_interval=scan_interval,
|
||||
)
|
||||
|
||||
# Set up WebSocket connection
|
||||
await coordinator.async_setup()
|
||||
try:
|
||||
await coordinator.async_setup()
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
except Exception:
|
||||
# If first refresh fails, make sure the WebSocket task is cleaned up.
|
||||
await websocket.close()
|
||||
raise
|
||||
|
||||
# Fetch initial data
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
server_id = (
|
||||
server_info.get("Id") if isinstance(server_info, dict) else None
|
||||
)
|
||||
server_name = (
|
||||
server_info.get("ServerName") if isinstance(server_info, dict) else None
|
||||
)
|
||||
|
||||
# Store runtime data
|
||||
entry.runtime_data = EmbyRuntimeData(
|
||||
coordinator=coordinator,
|
||||
api=api,
|
||||
websocket=websocket,
|
||||
user_id=user_id,
|
||||
server_id=server_id,
|
||||
)
|
||||
|
||||
# Set up platforms
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
# Register a hub device for via_device linking.
|
||||
device_registry = dr.async_get(hass)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
manufacturer="Emby",
|
||||
name=server_name or entry.title,
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
model="Emby Server",
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
await async_setup_services(hass)
|
||||
|
||||
# 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:
|
||||
async def _async_update_listener(
|
||||
hass: HomeAssistant, entry: EmbyConfigEntry
|
||||
) -> None:
|
||||
"""Handle options update."""
|
||||
scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
|
||||
scan_interval = int(
|
||||
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:
|
||||
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()
|
||||
|
||||
# Tear down services when the last entry is unloaded.
|
||||
others_loaded = any(
|
||||
e.entry_id != entry.entry_id
|
||||
for e in hass.config_entries.async_entries(DOMAIN)
|
||||
)
|
||||
if not others_loaded:
|
||||
async_unload_services(hass)
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def async_remove_config_entry_device(
|
||||
hass: HomeAssistant,
|
||||
entry: EmbyConfigEntry,
|
||||
device_entry: dr.DeviceEntry,
|
||||
) -> bool:
|
||||
"""Allow the user to remove device entries that are no longer present.
|
||||
|
||||
Refuse to remove:
|
||||
- The implicit "hub" device (identifier == entry.entry_id), since
|
||||
removing it would orphan all session entities.
|
||||
- Any device whose session is still present in the coordinator.
|
||||
"""
|
||||
# ``entry.runtime_data`` may be unset if the entry is mid-(re)load.
|
||||
runtime_data = getattr(entry, "runtime_data", None)
|
||||
for domain, identifier in device_entry.identifiers:
|
||||
if domain != DOMAIN:
|
||||
continue
|
||||
if identifier == entry.entry_id:
|
||||
return False # hub device
|
||||
if runtime_data is not None and runtime_data.coordinator.data:
|
||||
if identifier in runtime_data.coordinator.data:
|
||||
return False # session still present
|
||||
return True
|
||||
|
||||
@@ -3,22 +3,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .const import (
|
||||
ALLOWED_IMAGE_TYPES,
|
||||
COMMAND_DISPLAY_MESSAGE,
|
||||
COMMAND_MUTE,
|
||||
COMMAND_SET_REPEAT_MODE,
|
||||
COMMAND_SET_VOLUME,
|
||||
COMMAND_UNMUTE,
|
||||
DEFAULT_DEVICE_VERSION,
|
||||
DEFAULT_PORT,
|
||||
DEVICE_ID,
|
||||
DEVICE_NAME,
|
||||
DEVICE_VERSION,
|
||||
EMBY_ID_PATTERN,
|
||||
ENDPOINT_ARTISTS,
|
||||
ENDPOINT_ITEMS,
|
||||
ENDPOINT_LIBRARY_REFRESH,
|
||||
ENDPOINT_PREFIX_EMBY,
|
||||
ENDPOINT_PREFIX_NONE,
|
||||
ENDPOINT_SESSIONS,
|
||||
ENDPOINT_SYSTEM_INFO,
|
||||
ENDPOINT_USERS,
|
||||
ENDPOINT_USERS_PUBLIC,
|
||||
IMAGE_FETCH_TIMEOUT_SECONDS,
|
||||
PLAY_COMMAND_PLAY_NOW,
|
||||
PLAYBACK_COMMAND_NEXT_TRACK,
|
||||
PLAYBACK_COMMAND_PAUSE,
|
||||
@@ -30,6 +40,10 @@ from .const import (
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=15)
|
||||
_IMAGE_TIMEOUT = aiohttp.ClientTimeout(total=IMAGE_FETCH_TIMEOUT_SECONDS)
|
||||
_EMBY_ID_RE = re.compile(EMBY_ID_PATTERN)
|
||||
|
||||
|
||||
class EmbyApiError(Exception):
|
||||
"""Base exception for Emby API errors."""
|
||||
@@ -43,56 +57,88 @@ class EmbyAuthenticationError(EmbyApiError):
|
||||
"""Exception for authentication errors."""
|
||||
|
||||
|
||||
def _validate_emby_id(value: str, field_name: str = "id") -> str:
|
||||
"""Reject IDs that don't look like Emby identifiers."""
|
||||
if not isinstance(value, str) or not _EMBY_ID_RE.fullmatch(value):
|
||||
raise EmbyApiError(f"Invalid Emby {field_name}: {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
class EmbyApiClient:
|
||||
"""Emby REST API client."""
|
||||
"""Emby REST API client.
|
||||
|
||||
The aiohttp session is owned by the caller (Home Assistant); this class
|
||||
never closes it.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
api_key: str,
|
||||
session: aiohttp.ClientSession,
|
||||
device_id: str,
|
||||
port: int = DEFAULT_PORT,
|
||||
ssl: bool = False,
|
||||
session: aiohttp.ClientSession | None = None,
|
||||
verify_ssl: bool = True,
|
||||
client_version: str = DEFAULT_DEVICE_VERSION,
|
||||
) -> None:
|
||||
"""Initialize the Emby API client."""
|
||||
self._host = host
|
||||
if not host or not host.strip():
|
||||
raise ValueError("host must not be empty")
|
||||
if not api_key:
|
||||
raise ValueError("api_key must not be empty")
|
||||
if not device_id:
|
||||
raise ValueError("device_id must not be empty")
|
||||
|
||||
self._host = host.strip().rstrip("/")
|
||||
self._port = port
|
||||
self._api_key = api_key
|
||||
self._ssl = ssl
|
||||
self._verify_ssl = verify_ssl
|
||||
self._session = session
|
||||
self._owns_session = session is None
|
||||
self._device_id = device_id
|
||||
self._client_version = client_version
|
||||
|
||||
protocol = "https" if ssl else "http"
|
||||
self._base_url = f"{protocol}://{host}:{port}"
|
||||
self._base_url = f"{protocol}://{self._host}:{port}"
|
||||
# Discovered at test_connection(); set to "" if server is configured
|
||||
# without the /emby prefix.
|
||||
self._prefix: str = ENDPOINT_PREFIX_EMBY
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
"""Return the base URL."""
|
||||
"""Return the base URL (no API prefix)."""
|
||||
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
|
||||
@property
|
||||
def prefix(self) -> str:
|
||||
"""Return the working API path prefix (e.g. "/emby" or "")."""
|
||||
return self._prefix
|
||||
|
||||
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()
|
||||
@property
|
||||
def device_id(self) -> str:
|
||||
"""Return the device id used to identify this client to Emby."""
|
||||
return self._device_id
|
||||
|
||||
def _get_headers(self) -> dict[str, str]:
|
||||
def _get_headers(self, *, content_json: bool = True) -> dict[str, str]:
|
||||
"""Get headers for API requests."""
|
||||
return {
|
||||
headers = {
|
||||
"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",
|
||||
"X-Emby-Device-Id": self._device_id,
|
||||
"X-Emby-Client-Version": self._client_version,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if content_json:
|
||||
headers["Content-Type"] = "application/json"
|
||||
return headers
|
||||
|
||||
def _ssl_kwarg(self) -> dict[str, Any]:
|
||||
"""Return the ssl kwarg for aiohttp depending on config."""
|
||||
if not self._ssl:
|
||||
return {}
|
||||
return {"ssl": self._verify_ssl}
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
@@ -100,22 +146,29 @@ class EmbyApiClient:
|
||||
endpoint: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
data: dict[str, Any] | None = None,
|
||||
*,
|
||||
absolute: bool = False,
|
||||
) -> Any:
|
||||
"""Make an API request."""
|
||||
session = await self._ensure_session()
|
||||
url = f"{self._base_url}{endpoint}"
|
||||
"""Make an API request.
|
||||
|
||||
``endpoint`` is expected to begin with "/" (e.g. "/System/Info").
|
||||
When ``absolute`` is False the discovered prefix ("/emby" by default)
|
||||
is prepended.
|
||||
"""
|
||||
path = endpoint if absolute else f"{self._prefix}{endpoint}"
|
||||
url = f"{self._base_url}{path}"
|
||||
|
||||
_LOGGER.debug("Making %s request to %s", method, url)
|
||||
|
||||
try:
|
||||
async with session.request(
|
||||
async with self._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
|
||||
timeout=_REQUEST_TIMEOUT,
|
||||
**self._ssl_kwarg(),
|
||||
) as response:
|
||||
_LOGGER.debug("Response status: %s", response.status)
|
||||
|
||||
@@ -125,19 +178,22 @@ class EmbyApiClient:
|
||||
raise EmbyAuthenticationError("Access forbidden")
|
||||
if response.status >= 400:
|
||||
text = await response.text()
|
||||
_LOGGER.error("API error %s: %s", response.status, text)
|
||||
_LOGGER.debug("API error %s: %s", response.status, text)
|
||||
raise EmbyApiError(f"API error {response.status}: {text}")
|
||||
|
||||
if response.status == 204 or response.content_length == 0:
|
||||
return None
|
||||
|
||||
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)
|
||||
_LOGGER.debug("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)
|
||||
_LOGGER.debug("Timeout connecting to %s", url)
|
||||
raise EmbyConnectionError(f"Connection timeout: {err}") from err
|
||||
|
||||
async def _get(
|
||||
@@ -162,34 +218,46 @@ class EmbyApiClient:
|
||||
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.
|
||||
Tries both /emby/System/Info and /System/Info and pins the working
|
||||
prefix for subsequent calls.
|
||||
"""
|
||||
# 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)
|
||||
last_error: Exception | None = None
|
||||
for prefix in (ENDPOINT_PREFIX_EMBY, ENDPOINT_PREFIX_NONE):
|
||||
url_path = f"{prefix}{ENDPOINT_SYSTEM_INFO}"
|
||||
try:
|
||||
_LOGGER.debug("Probing %s for connectivity", url_path)
|
||||
result = await self._request("GET", url_path, absolute=True)
|
||||
self._prefix = prefix
|
||||
_LOGGER.debug("Using API prefix %r", prefix or "<none>")
|
||||
return result
|
||||
except EmbyAuthenticationError:
|
||||
raise
|
||||
except (EmbyConnectionError, EmbyApiError) as err:
|
||||
last_error = err
|
||||
continue
|
||||
|
||||
# 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
|
||||
raise EmbyConnectionError(
|
||||
f"Cannot connect to Emby server at {self._base_url}. "
|
||||
f"Last error: {last_error}"
|
||||
)
|
||||
|
||||
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)
|
||||
"""Get list of users.
|
||||
|
||||
Falls back to the public users endpoint if the API key is not an admin
|
||||
token (HTTP 401/403 on the authenticated endpoint).
|
||||
"""
|
||||
try:
|
||||
return await self._get(ENDPOINT_USERS)
|
||||
except EmbyAuthenticationError:
|
||||
_LOGGER.debug(
|
||||
"API key is not admin, falling back to /Users/Public"
|
||||
)
|
||||
return await self._get(ENDPOINT_USERS_PUBLIC)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Sessions
|
||||
@@ -203,7 +271,7 @@ class EmbyApiClient:
|
||||
self, user_id: str | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get sessions that can be remotely controlled."""
|
||||
params = {}
|
||||
params: dict[str, Any] = {}
|
||||
if user_id:
|
||||
params["ControllableByUserId"] = user_id
|
||||
|
||||
@@ -222,25 +290,25 @@ class EmbyApiClient:
|
||||
start_position_ticks: int = 0,
|
||||
) -> None:
|
||||
"""Send play command to a session."""
|
||||
if not item_ids:
|
||||
raise EmbyApiError("item_ids are required")
|
||||
_validate_emby_id(session_id, "session_id")
|
||||
for item_id in item_ids:
|
||||
_validate_emby_id(item_id, "item_id")
|
||||
|
||||
endpoint = f"{ENDPOINT_SESSIONS}/{session_id}/Playing"
|
||||
params = {
|
||||
params: dict[str, Any] = {
|
||||
"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."""
|
||||
_validate_emby_id(session_id, "session_id")
|
||||
endpoint = f"{ENDPOINT_SESSIONS}/{session_id}/Playing/{command}"
|
||||
await self._post(endpoint)
|
||||
|
||||
@@ -266,28 +334,38 @@ class EmbyApiClient:
|
||||
|
||||
async def seek(self, session_id: str, position_ticks: int) -> None:
|
||||
"""Seek to a position."""
|
||||
_validate_emby_id(session_id, "session_id")
|
||||
endpoint = f"{ENDPOINT_SESSIONS}/{session_id}/Playing/{PLAYBACK_COMMAND_SEEK}"
|
||||
await self._post(endpoint, params={"SeekPositionTicks": position_ticks})
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Volume Control
|
||||
# General Commands
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def _send_command(
|
||||
self, session_id: str, command: str, arguments: dict[str, Any] | None = None
|
||||
self,
|
||||
session_id: str,
|
||||
command: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Send a general command to a session."""
|
||||
"""Send a general command to a session.
|
||||
|
||||
Emby's /Command endpoint accepts arguments as strings; numeric values
|
||||
are stringified here.
|
||||
"""
|
||||
_validate_emby_id(session_id, "session_id")
|
||||
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})
|
||||
volume = max(0, min(100, int(volume)))
|
||||
await self._send_command(
|
||||
session_id, COMMAND_SET_VOLUME, {"Volume": volume}
|
||||
)
|
||||
|
||||
async def mute(self, session_id: str) -> None:
|
||||
"""Mute the session."""
|
||||
@@ -297,15 +375,40 @@ class EmbyApiClient:
|
||||
"""Unmute the session."""
|
||||
await self._send_command(session_id, COMMAND_UNMUTE)
|
||||
|
||||
async def set_repeat_mode(self, session_id: str, mode: str) -> None:
|
||||
"""Set the session's repeat mode (RepeatNone/RepeatOne/RepeatAll)."""
|
||||
await self._send_command(
|
||||
session_id, COMMAND_SET_REPEAT_MODE, {"RepeatMode": mode}
|
||||
)
|
||||
|
||||
async def display_message(
|
||||
self,
|
||||
session_id: str,
|
||||
text: str,
|
||||
header: str | None = None,
|
||||
timeout_ms: int | None = None,
|
||||
) -> None:
|
||||
"""Display a message on the client device."""
|
||||
args: dict[str, Any] = {"Text": text}
|
||||
if header is not None:
|
||||
args["Header"] = header
|
||||
if timeout_ms is not None:
|
||||
args["TimeoutMs"] = int(timeout_ms)
|
||||
await self._send_command(session_id, COMMAND_DISPLAY_MESSAGE, args)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Library Browsing
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def get_views(self, user_id: str) -> list[dict[str, Any]]:
|
||||
"""Get user's library views (top-level folders)."""
|
||||
_validate_emby_id(user_id, "user_id")
|
||||
endpoint = f"{ENDPOINT_USERS}/{user_id}/Views"
|
||||
result = await self._get(endpoint)
|
||||
return result.get("Items", [])
|
||||
if not isinstance(result, dict):
|
||||
return []
|
||||
items = result.get("Items", [])
|
||||
return items if isinstance(items, list) else []
|
||||
|
||||
async def get_items(
|
||||
self,
|
||||
@@ -321,6 +424,9 @@ class EmbyApiClient:
|
||||
fields: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Get items from the library."""
|
||||
_validate_emby_id(user_id, "user_id")
|
||||
if parent_id is not None:
|
||||
_validate_emby_id(parent_id, "parent_id")
|
||||
endpoint = f"{ENDPOINT_USERS}/{user_id}/Items"
|
||||
|
||||
params: dict[str, Any] = {
|
||||
@@ -339,13 +445,13 @@ class EmbyApiClient:
|
||||
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."""
|
||||
_validate_emby_id(user_id, "user_id")
|
||||
_validate_emby_id(item_id, "item_id")
|
||||
endpoint = f"{ENDPOINT_USERS}/{user_id}/Items/{item_id}"
|
||||
return await self._get(endpoint)
|
||||
|
||||
@@ -357,7 +463,9 @@ class EmbyApiClient:
|
||||
limit: int = 100,
|
||||
) -> dict[str, Any]:
|
||||
"""Get artists."""
|
||||
endpoint = "/emby/Artists"
|
||||
_validate_emby_id(user_id, "user_id")
|
||||
if parent_id is not None:
|
||||
_validate_emby_id(parent_id, "parent_id")
|
||||
params: dict[str, Any] = {
|
||||
"UserId": user_id,
|
||||
"StartIndex": start_index,
|
||||
@@ -368,25 +476,72 @@ class EmbyApiClient:
|
||||
if parent_id:
|
||||
params["ParentId"] = parent_id
|
||||
|
||||
return await self._get(endpoint, params=params)
|
||||
return await self._get(ENDPOINT_ARTISTS, params=params)
|
||||
|
||||
def get_image_url(
|
||||
async def refresh_library(self) -> None:
|
||||
"""Trigger a server-side library scan."""
|
||||
await self._post(ENDPOINT_LIBRARY_REFRESH)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Image fetching
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def get_image_path(
|
||||
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 = []
|
||||
) -> tuple[str, dict[str, str]]:
|
||||
"""Build the URL and query params for an item image.
|
||||
|
||||
Returns ``(url, params)``. The API key is intentionally NOT included;
|
||||
the caller is responsible for sending the X-Emby-Token header. The
|
||||
item_id is validated to prevent path traversal.
|
||||
"""
|
||||
_validate_emby_id(item_id, "item_id")
|
||||
if image_type not in ALLOWED_IMAGE_TYPES:
|
||||
raise EmbyApiError(f"Invalid image_type: {image_type!r}")
|
||||
url = (
|
||||
f"{self._base_url}{self._prefix}{ENDPOINT_ITEMS}"
|
||||
f"/{item_id}/Images/{image_type}"
|
||||
)
|
||||
params: dict[str, str] = {}
|
||||
if max_width:
|
||||
params.append(f"maxWidth={max_width}")
|
||||
params["maxWidth"] = str(int(max_width))
|
||||
if max_height:
|
||||
params.append(f"maxHeight={max_height}")
|
||||
params.append(f"api_key={self._api_key}")
|
||||
params["maxHeight"] = str(int(max_height))
|
||||
return url, params
|
||||
|
||||
if params:
|
||||
url += "?" + "&".join(params)
|
||||
async def fetch_image(
|
||||
self,
|
||||
item_id: str,
|
||||
image_type: str = "Primary",
|
||||
max_width: int | None = None,
|
||||
max_height: int | None = None,
|
||||
) -> tuple[bytes, str | None]:
|
||||
"""Fetch an image. Returns ``(content, content_type)``.
|
||||
|
||||
return url
|
||||
Uses the X-Emby-Token header so the API key is not exposed in URLs.
|
||||
"""
|
||||
url, params = self.get_image_path(
|
||||
item_id, image_type, max_width, max_height
|
||||
)
|
||||
try:
|
||||
async with self._session.get(
|
||||
url,
|
||||
params=params,
|
||||
headers=self._get_headers(content_json=False),
|
||||
timeout=_IMAGE_TIMEOUT,
|
||||
**self._ssl_kwarg(),
|
||||
) as response:
|
||||
if response.status >= 400:
|
||||
raise EmbyApiError(
|
||||
f"Image fetch failed with status {response.status}"
|
||||
)
|
||||
content = await response.read()
|
||||
return content, response.headers.get("Content-Type")
|
||||
except aiohttp.ClientError as err:
|
||||
raise EmbyConnectionError(f"Image fetch error: {err}") from err
|
||||
except TimeoutError as err:
|
||||
raise EmbyConnectionError(f"Image fetch timeout: {err}") from err
|
||||
|
||||
@@ -5,10 +5,15 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.media_player import BrowseMedia, MediaClass, MediaType
|
||||
from homeassistant.components.media_player import (
|
||||
BrowseError,
|
||||
BrowseMedia,
|
||||
MediaClass,
|
||||
MediaType,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .api import EmbyApiClient
|
||||
from .api import EmbyApiClient, EmbyApiError
|
||||
from .const import (
|
||||
ITEM_TYPE_AUDIO,
|
||||
ITEM_TYPE_COLLECTION_FOLDER,
|
||||
@@ -25,7 +30,11 @@ from .const import (
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Map Emby item types to Home Assistant media classes
|
||||
# Default page size when no pagination params provided
|
||||
DEFAULT_PAGE_SIZE = 100
|
||||
# Maximum items per browse call (HA UI also has limits)
|
||||
MAX_PAGE_SIZE = 200
|
||||
|
||||
ITEM_TYPE_TO_MEDIA_CLASS: dict[str, MediaClass] = {
|
||||
ITEM_TYPE_MOVIE: MediaClass.MOVIE,
|
||||
ITEM_TYPE_SERIES: MediaClass.TV_SHOW,
|
||||
@@ -40,7 +49,6 @@ ITEM_TYPE_TO_MEDIA_CLASS: dict[str, MediaClass] = {
|
||||
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,
|
||||
@@ -52,24 +60,22 @@ ITEM_TYPE_TO_MEDIA_TYPE: dict[str, MediaType | str] = {
|
||||
ITEM_TYPE_PLAYLIST: MediaType.PLAYLIST,
|
||||
}
|
||||
|
||||
# Item types that can be played directly
|
||||
PLAYABLE_ITEM_TYPES = {
|
||||
ITEM_TYPE_MOVIE,
|
||||
ITEM_TYPE_EPISODE,
|
||||
ITEM_TYPE_AUDIO,
|
||||
}
|
||||
PLAYABLE_ITEM_TYPES = frozenset(
|
||||
{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,
|
||||
}
|
||||
EXPANDABLE_ITEM_TYPES = frozenset(
|
||||
{
|
||||
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(
|
||||
@@ -80,26 +86,36 @@ async def async_browse_media(
|
||||
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)
|
||||
try:
|
||||
if not media_content_id:
|
||||
return await _build_root_browse(api, user_id)
|
||||
return await _build_item_browse(api, user_id, media_content_id)
|
||||
except BrowseError:
|
||||
raise
|
||||
except EmbyApiError as err:
|
||||
_LOGGER.warning("Failed to browse Emby library: %s", err)
|
||||
raise BrowseError(f"Failed to browse Emby library: {err}") from err
|
||||
except Exception as err: # noqa: BLE001 - convert any leak into BrowseError
|
||||
_LOGGER.exception("Unexpected error while browsing Emby library")
|
||||
raise BrowseError(f"Unexpected error: {err}") from err
|
||||
|
||||
|
||||
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)
|
||||
if not isinstance(views, list):
|
||||
views = []
|
||||
|
||||
children = []
|
||||
children: list[BrowseMedia] = []
|
||||
for view in views:
|
||||
if not isinstance(view, dict):
|
||||
continue
|
||||
item_id = view.get("Id")
|
||||
if not item_id:
|
||||
continue
|
||||
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":
|
||||
@@ -109,17 +125,14 @@ async def _build_root_browse(api: EmbyApiClient, user_id: str) -> BrowseMedia:
|
||||
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
|
||||
media_content_type=MediaType.CHANNELS,
|
||||
title=name,
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
thumbnail=thumbnail,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -138,31 +151,33 @@ 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"],
|
||||
limit=MAX_PAGE_SIZE,
|
||||
fields=["PrimaryImageAspectRatio"],
|
||||
)
|
||||
|
||||
children = []
|
||||
for child in children_data.get("Items", []):
|
||||
child_media = _build_browse_media_item(api, child)
|
||||
raw_children = (
|
||||
children_data.get("Items", [])
|
||||
if isinstance(children_data, dict)
|
||||
else []
|
||||
)
|
||||
children: list[BrowseMedia] = []
|
||||
for child in raw_children:
|
||||
if not isinstance(child, dict):
|
||||
continue
|
||||
child_media = _build_browse_media_item(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,
|
||||
@@ -171,11 +186,10 @@ async def _build_item_browse(
|
||||
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:
|
||||
def _build_browse_media_item(item: dict[str, Any]) -> BrowseMedia | None:
|
||||
"""Build a BrowseMedia item from Emby item data."""
|
||||
item_id = item.get("Id")
|
||||
if not item_id:
|
||||
@@ -184,7 +198,6 @@ def _build_browse_media_item(api: EmbyApiClient, item: dict[str, Any]) -> Browse
|
||||
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")
|
||||
@@ -193,7 +206,6 @@ def _build_browse_media_item(api: EmbyApiClient, item: dict[str, Any]) -> Browse
|
||||
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", [])
|
||||
@@ -202,30 +214,14 @@ def _build_browse_media_item(api: EmbyApiClient, item: dict[str, Any]) -> Browse
|
||||
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,
|
||||
can_play=item_type in PLAYABLE_ITEM_TYPES,
|
||||
can_expand=item_type in EXPANDABLE_ITEM_TYPES,
|
||||
)
|
||||
|
||||
@@ -14,7 +14,10 @@ from homeassistant.config_entries import (
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import instance_id
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
BooleanSelector,
|
||||
NumberSelector,
|
||||
NumberSelectorConfig,
|
||||
NumberSelectorMode,
|
||||
@@ -34,15 +37,22 @@ from .const import (
|
||||
CONF_SCAN_INTERVAL,
|
||||
CONF_SSL,
|
||||
CONF_USER_ID,
|
||||
CONF_VERIFY_SSL,
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DEFAULT_SSL,
|
||||
DEFAULT_VERIFY_SSL,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _make_temp_device_id(prefix: str) -> str:
|
||||
"""Build a temporary device id used during config flow."""
|
||||
return f"hass-config-{prefix[:12]}"
|
||||
|
||||
|
||||
class EmbyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Emby Media Player."""
|
||||
|
||||
@@ -54,8 +64,10 @@ class EmbyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._port: int = DEFAULT_PORT
|
||||
self._api_key: str | None = None
|
||||
self._ssl: bool = DEFAULT_SSL
|
||||
self._verify_ssl: bool = DEFAULT_VERIFY_SSL
|
||||
self._users: list[dict[str, Any]] = []
|
||||
self._server_info: dict[str, Any] = {}
|
||||
self._reauth_entry: ConfigEntry | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -68,65 +80,72 @@ class EmbyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
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)
|
||||
self._verify_ssl = user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Testing connection to %s:%s (SSL: %s)",
|
||||
self._host,
|
||||
self._port,
|
||||
self._ssl,
|
||||
)
|
||||
errors = await self._probe()
|
||||
|
||||
# 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()
|
||||
if not errors and self._users:
|
||||
return await self.async_step_user_select()
|
||||
if not errors:
|
||||
errors["base"] = "no_users"
|
||||
|
||||
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(
|
||||
vol.Required(
|
||||
CONF_HOST, default=self._host or vol.UNDEFINED
|
||||
): TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)),
|
||||
vol.Optional(
|
||||
CONF_PORT, default=self._port
|
||||
): NumberSelector(
|
||||
NumberSelectorConfig(
|
||||
min=1,
|
||||
max=65535,
|
||||
mode=NumberSelectorMode.BOX,
|
||||
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,
|
||||
vol.Optional(CONF_SSL, default=self._ssl): BooleanSelector(),
|
||||
vol.Optional(
|
||||
CONF_VERIFY_SSL, default=self._verify_ssl
|
||||
): BooleanSelector(),
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def _probe(self) -> dict[str, str]:
|
||||
"""Try to connect & list users with the current self.* settings."""
|
||||
assert self._host is not None
|
||||
assert self._api_key is not None
|
||||
errors: dict[str, str] = {}
|
||||
session = async_get_clientsession(self.hass)
|
||||
hass_uuid = await instance_id.async_get(self.hass)
|
||||
|
||||
api = EmbyApiClient(
|
||||
host=self._host,
|
||||
port=self._port,
|
||||
api_key=self._api_key,
|
||||
ssl=self._ssl,
|
||||
verify_ssl=self._verify_ssl,
|
||||
session=session,
|
||||
device_id=_make_temp_device_id(hass_uuid),
|
||||
)
|
||||
|
||||
try:
|
||||
self._server_info = await api.test_connection()
|
||||
self._users = await api.get_users()
|
||||
except EmbyAuthenticationError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except EmbyConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("Unexpected error during Emby config probe")
|
||||
errors["base"] = "unknown"
|
||||
|
||||
return errors
|
||||
|
||||
async def async_step_user_select(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -135,14 +154,11 @@ class EmbyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
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()
|
||||
@@ -156,6 +172,7 @@ class EmbyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
CONF_PORT: self._port,
|
||||
CONF_API_KEY: self._api_key,
|
||||
CONF_SSL: self._ssl,
|
||||
CONF_VERIFY_SSL: self._verify_ssl,
|
||||
CONF_USER_ID: user_id,
|
||||
},
|
||||
options={
|
||||
@@ -163,9 +180,9 @@ class EmbyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
},
|
||||
)
|
||||
|
||||
# Build user selection options
|
||||
user_options = [
|
||||
{"value": user["Id"], "label": user["Name"]} for user in self._users
|
||||
{"value": user["Id"], "label": user.get("Name", "Unknown")}
|
||||
for user in self._users
|
||||
]
|
||||
|
||||
return self.async_show_form(
|
||||
@@ -183,6 +200,94 @@ class EmbyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Reauthentication
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _resolve_reauth_entry(self) -> ConfigEntry | None:
|
||||
"""Resolve the reauth target entry from context.
|
||||
|
||||
Uses ``_get_reauth_entry`` when available (HA 2024.11+), falling back
|
||||
to the documented context dict.
|
||||
"""
|
||||
getter = getattr(self, "_get_reauth_entry", None)
|
||||
if callable(getter):
|
||||
try:
|
||||
return getter()
|
||||
except Exception as err: # noqa: BLE001 - version-specific
|
||||
_LOGGER.debug(
|
||||
"_get_reauth_entry helper unavailable, falling back: %s",
|
||||
err,
|
||||
)
|
||||
entry_id = self.context.get("entry_id")
|
||||
if not entry_id:
|
||||
return None
|
||||
return self.hass.config_entries.async_get_entry(entry_id)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the reauth flow."""
|
||||
self._reauth_entry = self._resolve_reauth_entry()
|
||||
if self._reauth_entry is not None:
|
||||
self._host = self._reauth_entry.data.get(CONF_HOST)
|
||||
self._port = int(
|
||||
self._reauth_entry.data.get(CONF_PORT, DEFAULT_PORT)
|
||||
)
|
||||
self._ssl = self._reauth_entry.data.get(CONF_SSL, DEFAULT_SSL)
|
||||
self._verify_ssl = self._reauth_entry.data.get(
|
||||
CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL
|
||||
)
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm reauthentication with a new API key."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None and self._reauth_entry is not None:
|
||||
self._api_key = user_input[CONF_API_KEY].strip()
|
||||
errors = await self._probe()
|
||||
|
||||
if not errors:
|
||||
new_data = {
|
||||
**self._reauth_entry.data,
|
||||
CONF_API_KEY: self._api_key,
|
||||
}
|
||||
# Prefer the unified helper when available; fall back manually.
|
||||
helper = getattr(
|
||||
self, "async_update_reload_and_abort", None
|
||||
)
|
||||
if callable(helper):
|
||||
return helper(
|
||||
self._reauth_entry,
|
||||
data=new_data,
|
||||
reason="reauth_successful",
|
||||
)
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self._reauth_entry, data=new_data
|
||||
)
|
||||
await self.hass.config_entries.async_reload(
|
||||
self._reauth_entry.entry_id
|
||||
)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_API_KEY): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.PASSWORD)
|
||||
),
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"host": self._host or "",
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
|
||||
@@ -9,31 +9,55 @@ CONF_HOST: Final = "host"
|
||||
CONF_PORT: Final = "port"
|
||||
CONF_API_KEY: Final = "api_key"
|
||||
CONF_SSL: Final = "ssl"
|
||||
CONF_VERIFY_SSL: Final = "verify_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
|
||||
DEFAULT_VERIFY_SSL: Final = True
|
||||
DEFAULT_SCAN_INTERVAL: Final = 10 # seconds (polling fallback)
|
||||
DEFAULT_SCAN_INTERVAL_WS: Final = 300 # seconds, when WebSocket is connected
|
||||
|
||||
# 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"
|
||||
ENDPOINT_PREFIX_EMBY: Final = "/emby"
|
||||
ENDPOINT_PREFIX_NONE: Final = ""
|
||||
ENDPOINT_SYSTEM_INFO: Final = "/System/Info"
|
||||
ENDPOINT_SYSTEM_PING: Final = "/System/Ping"
|
||||
ENDPOINT_USERS: Final = "/Users"
|
||||
ENDPOINT_USERS_PUBLIC: Final = "/Users/Public"
|
||||
ENDPOINT_SESSIONS: Final = "/Sessions"
|
||||
ENDPOINT_ITEMS: Final = "/Items"
|
||||
ENDPOINT_ARTISTS: Final = "/Artists"
|
||||
ENDPOINT_LIBRARY_REFRESH: Final = "/Library/Refresh"
|
||||
|
||||
# WebSocket
|
||||
WEBSOCKET_PATH: Final = "/embywebsocket"
|
||||
WS_RECONNECT_MIN_DELAY: Final = 5 # seconds
|
||||
WS_RECONNECT_MAX_DELAY: Final = 300 # seconds (5 min cap)
|
||||
WS_HEARTBEAT: Final = 30 # seconds
|
||||
|
||||
# Device identification for Home Assistant
|
||||
DEVICE_ID: Final = "homeassistant_emby_player"
|
||||
DEVICE_NAME: Final = "Home Assistant"
|
||||
DEVICE_VERSION: Final = "1.0.0"
|
||||
# Fallback version string when the manifest version can't be read.
|
||||
DEFAULT_DEVICE_VERSION: Final = "0.0.0"
|
||||
|
||||
# Emby IDs are typically 32-char hex (with optional dashes / underscores);
|
||||
# bound length to reject pathological inputs while still allowing the slight
|
||||
# variations seen across Emby Server versions.
|
||||
EMBY_ID_PATTERN: Final = r"^[A-Za-z0-9_-]{1,128}$"
|
||||
|
||||
# Whitelist of Emby image types we may request.
|
||||
ALLOWED_IMAGE_TYPES: Final = frozenset(
|
||||
{"Primary", "Backdrop", "Thumb", "Logo", "Banner", "Art", "Disc", "Box"}
|
||||
)
|
||||
|
||||
# Image fetches can be larger than regular API calls.
|
||||
IMAGE_FETCH_TIMEOUT_SECONDS: Final = 30
|
||||
|
||||
# Media types
|
||||
MEDIA_TYPE_VIDEO: Final = "Video"
|
||||
@@ -70,6 +94,18 @@ COMMAND_SET_VOLUME: Final = "SetVolume"
|
||||
COMMAND_MUTE: Final = "Mute"
|
||||
COMMAND_UNMUTE: Final = "Unmute"
|
||||
COMMAND_TOGGLE_MUTE: Final = "ToggleMute"
|
||||
COMMAND_SET_REPEAT_MODE: Final = "SetRepeatMode"
|
||||
COMMAND_DISPLAY_MESSAGE: Final = "DisplayMessage"
|
||||
COMMAND_SEND_STRING: Final = "SendString"
|
||||
|
||||
# Repeat modes (Emby)
|
||||
REPEAT_MODE_NONE: Final = "RepeatNone"
|
||||
REPEAT_MODE_ONE: Final = "RepeatOne"
|
||||
REPEAT_MODE_ALL: Final = "RepeatAll"
|
||||
|
||||
# Shuffle modes (Emby)
|
||||
SHUFFLE_MODE_SORTED: Final = "Sorted"
|
||||
SHUFFLE_MODE_SHUFFLE: Final = "Shuffle"
|
||||
|
||||
# WebSocket message types
|
||||
WS_MESSAGE_SESSIONS_START: Final = "SessionsStart"
|
||||
@@ -78,6 +114,8 @@ WS_MESSAGE_SESSIONS: Final = "Sessions"
|
||||
WS_MESSAGE_PLAYBACK_START: Final = "PlaybackStart"
|
||||
WS_MESSAGE_PLAYBACK_STOP: Final = "PlaybackStopped"
|
||||
WS_MESSAGE_PLAYBACK_PROGRESS: Final = "PlaybackProgress"
|
||||
WS_MESSAGE_KEEP_ALIVE: Final = "KeepAlive"
|
||||
WS_MESSAGE_FORCE_KEEP_ALIVE: Final = "ForceKeepAlive"
|
||||
|
||||
# Attributes for extra state
|
||||
ATTR_ITEM_ID: Final = "item_id"
|
||||
@@ -87,3 +125,16 @@ ATTR_DEVICE_ID: Final = "device_id"
|
||||
ATTR_DEVICE_NAME: Final = "device_name"
|
||||
ATTR_CLIENT_NAME: Final = "client_name"
|
||||
ATTR_USER_NAME: Final = "user_name"
|
||||
ATTR_PLAY_METHOD: Final = "play_method"
|
||||
|
||||
# Service attributes
|
||||
ATTR_MESSAGE: Final = "message"
|
||||
ATTR_HEADER: Final = "header"
|
||||
ATTR_TIMEOUT_MS: Final = "timeout_ms"
|
||||
ATTR_REPEAT_MODE: Final = "repeat_mode"
|
||||
|
||||
# Stale session cleanup
|
||||
STALE_SESSION_TIMEOUT: Final = 1800 # 30 minutes
|
||||
# Don't prune devices until the integration has been running this long, to
|
||||
# avoid wiping freshly restarted entities before they reappear.
|
||||
STALE_PRUNE_GRACE_SECONDS: Final = 600 # 10 minutes
|
||||
|
||||
@@ -3,15 +3,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
DataUpdateCoordinator,
|
||||
UpdateFailed,
|
||||
)
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .api import EmbyApiClient, EmbyApiError
|
||||
from .api import EmbyApiClient, EmbyApiError, EmbyAuthenticationError
|
||||
from .const import (
|
||||
DEFAULT_SCAN_INTERVAL_WS,
|
||||
DOMAIN,
|
||||
TICKS_PER_SECOND,
|
||||
WS_MESSAGE_PLAYBACK_PROGRESS,
|
||||
@@ -24,7 +31,17 @@ from .websocket import EmbyWebSocket
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
def _safe_int(value: Any, default: int | None = None) -> int | None:
|
||||
"""Best-effort int coercion that tolerates strings and bad data."""
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmbyNowPlaying:
|
||||
"""Currently playing media information."""
|
||||
|
||||
@@ -42,8 +59,8 @@ class EmbyNowPlaying:
|
||||
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)
|
||||
backdrop_image_tags: tuple[str, ...] = ()
|
||||
genres: tuple[str, ...] = ()
|
||||
production_year: int | None = None
|
||||
overview: str | None = None
|
||||
|
||||
@@ -53,10 +70,11 @@ class EmbyNowPlaying:
|
||||
return self.duration_ticks / TICKS_PER_SECOND if self.duration_ticks else 0
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(frozen=True)
|
||||
class EmbyPlayState:
|
||||
"""Playback state information."""
|
||||
|
||||
updated_at: datetime
|
||||
is_paused: bool = False
|
||||
is_muted: bool = False
|
||||
volume_level: int = 100 # 0-100
|
||||
@@ -72,7 +90,7 @@ class EmbyPlayState:
|
||||
return self.position_ticks / TICKS_PER_SECOND if self.position_ticks else 0
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(frozen=True)
|
||||
class EmbySession:
|
||||
"""Represents an Emby client session."""
|
||||
|
||||
@@ -86,8 +104,9 @@ class EmbySession:
|
||||
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)
|
||||
playable_media_types: tuple[str, ...] = ()
|
||||
supported_commands: tuple[str, ...] = ()
|
||||
last_seen: datetime = field(default_factory=utcnow)
|
||||
|
||||
@property
|
||||
def is_playing(self) -> bool:
|
||||
@@ -114,7 +133,14 @@ class EmbySession:
|
||||
|
||||
|
||||
class EmbyCoordinator(DataUpdateCoordinator[dict[str, EmbySession]]):
|
||||
"""Coordinator for Emby data with WebSocket + polling fallback."""
|
||||
"""Coordinator for Emby data with WebSocket + polling fallback.
|
||||
|
||||
When the WebSocket is connected we trust it as the source of truth for
|
||||
each session. A slow REST poll still runs as a safety net and merges
|
||||
its result with the WS-derived state: any session whose ``last_seen``
|
||||
is newer than the moment we started the REST request keeps the WS
|
||||
version intact.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -132,18 +158,34 @@ class EmbyCoordinator(DataUpdateCoordinator[dict[str, EmbySession]]):
|
||||
)
|
||||
self.api = api
|
||||
self._websocket = websocket
|
||||
self._ws_connected = False
|
||||
self._remove_ws_callback: callable | None = None
|
||||
self._poll_interval = scan_interval
|
||||
self._remove_ws_callback: Callable[[], None] | None = None
|
||||
# Per-session last-seen timestamps (kept after a session leaves
|
||||
# ``data`` so we can age it out cleanly).
|
||||
self._session_last_seen: dict[str, datetime] = {}
|
||||
|
||||
@property
|
||||
def websocket_connected(self) -> bool:
|
||||
"""Return True if WebSocket is currently connected."""
|
||||
return self._websocket.connected
|
||||
|
||||
def get_session_last_seen(self, session_id: str) -> datetime | None:
|
||||
"""Return the last time we saw this session, or None if never."""
|
||||
return self._session_last_seen.get(session_id)
|
||||
|
||||
def forget_session(self, session_id: str) -> None:
|
||||
"""Drop a session's last-seen entry (called when its device is pruned)."""
|
||||
self._session_last_seen.pop(session_id, 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
|
||||
# When WS is connected, slow REST polling to a safety-net cadence.
|
||||
self.update_interval = timedelta(seconds=DEFAULT_SCAN_INTERVAL_WS)
|
||||
_LOGGER.info("Emby WebSocket connected, using real-time updates")
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
@@ -156,35 +198,114 @@ class EmbyCoordinator(DataUpdateCoordinator[dict[str, EmbySession]]):
|
||||
_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._record_last_seen(sessions)
|
||||
self.async_set_updated_data(sessions)
|
||||
return
|
||||
|
||||
elif message_type in (
|
||||
if 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())
|
||||
self._apply_playback_event(data, stopped=False)
|
||||
return
|
||||
|
||||
if message_type == WS_MESSAGE_PLAYBACK_STOP:
|
||||
self._apply_playback_event(data, stopped=True)
|
||||
|
||||
@callback
|
||||
def _apply_playback_event(self, data: Any, *, stopped: bool) -> None:
|
||||
"""Update a single session in place from a PlaybackProgress/Start/Stop event."""
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
|
||||
# SessionId is the per-client session; PlaySessionId is per-stream
|
||||
# and can collide across devices, so we don't fall back to it.
|
||||
session_id = data.get("SessionId")
|
||||
if not session_id or not self.data:
|
||||
return
|
||||
|
||||
session = self.data.get(session_id)
|
||||
if session is None:
|
||||
# We don't yet know this session; the next Sessions push will
|
||||
# introduce it.
|
||||
return
|
||||
|
||||
now = utcnow()
|
||||
if stopped:
|
||||
updated = replace(
|
||||
session,
|
||||
now_playing=None,
|
||||
play_state=None,
|
||||
last_seen=now,
|
||||
)
|
||||
else:
|
||||
now_playing_data = data.get("NowPlayingItem") or data.get("Item")
|
||||
now_playing = (
|
||||
self._parse_now_playing(now_playing_data)
|
||||
if now_playing_data
|
||||
else session.now_playing
|
||||
)
|
||||
|
||||
play_state_data = data.get("PlayState") or data
|
||||
play_state = self._parse_play_state(play_state_data)
|
||||
|
||||
updated = replace(
|
||||
session,
|
||||
now_playing=now_playing,
|
||||
play_state=play_state,
|
||||
last_seen=now,
|
||||
)
|
||||
|
||||
new_data = dict(self.data)
|
||||
new_data[session_id] = updated
|
||||
self._session_last_seen[session_id] = now
|
||||
self.async_set_updated_data(new_data)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, EmbySession]:
|
||||
"""Fetch sessions from Emby API (polling fallback)."""
|
||||
"""Fetch sessions from Emby API (polling fallback / periodic refresh)."""
|
||||
request_started = utcnow()
|
||||
try:
|
||||
sessions_data = await self.api.get_sessions()
|
||||
return self._parse_sessions(sessions_data)
|
||||
except EmbyAuthenticationError as err:
|
||||
# Surfacing ConfigEntryAuthFailed triggers the HA reauth flow.
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Authentication failed: {err}"
|
||||
) from err
|
||||
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]:
|
||||
rest_sessions = self._parse_sessions(sessions_data)
|
||||
|
||||
# If WebSocket is connected, prefer any session our WS callback has
|
||||
# touched since we kicked off this REST request — its state is more
|
||||
# current than what the REST snapshot just returned.
|
||||
if self._websocket.connected and self.data:
|
||||
for sid, ws_session in self.data.items():
|
||||
if (
|
||||
sid in rest_sessions
|
||||
and ws_session.last_seen > request_started
|
||||
):
|
||||
rest_sessions[sid] = ws_session
|
||||
|
||||
self._record_last_seen(rest_sessions)
|
||||
return rest_sessions
|
||||
|
||||
def _record_last_seen(self, sessions: dict[str, EmbySession]) -> None:
|
||||
"""Update the last-seen map from a freshly parsed sessions dict."""
|
||||
now = utcnow()
|
||||
for sid in sessions:
|
||||
self._session_last_seen[sid] = now
|
||||
|
||||
def _parse_sessions(
|
||||
self, sessions_data: list[dict[str, Any]]
|
||||
) -> dict[str, EmbySession]:
|
||||
"""Parse session data into EmbySession objects."""
|
||||
sessions: dict[str, EmbySession] = {}
|
||||
now = utcnow()
|
||||
|
||||
for session_data in sessions_data:
|
||||
# Only include sessions that support remote control
|
||||
if not session_data.get("SupportsRemoteControl", False):
|
||||
continue
|
||||
|
||||
@@ -192,19 +313,21 @@ class EmbyCoordinator(DataUpdateCoordinator[dict[str, EmbySession]]):
|
||||
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)
|
||||
now_playing = (
|
||||
self._parse_now_playing(now_playing_data)
|
||||
if now_playing_data
|
||||
else None
|
||||
)
|
||||
|
||||
# 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)
|
||||
play_state = (
|
||||
self._parse_play_state(play_state_data)
|
||||
if play_state_data
|
||||
else None
|
||||
)
|
||||
|
||||
session = EmbySession(
|
||||
sessions[session_id] = EmbySession(
|
||||
session_id=session_id,
|
||||
device_id=session_data.get("DeviceId", ""),
|
||||
device_name=session_data.get("DeviceName", "Unknown Device"),
|
||||
@@ -212,29 +335,33 @@ class EmbyCoordinator(DataUpdateCoordinator[dict[str, EmbySession]]):
|
||||
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),
|
||||
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", []),
|
||||
playable_media_types=tuple(
|
||||
session_data.get("PlayableMediaTypes", []) or []
|
||||
),
|
||||
supported_commands=tuple(
|
||||
session_data.get("SupportedCommands", []) or []
|
||||
),
|
||||
last_seen=now,
|
||||
)
|
||||
|
||||
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", [])
|
||||
artists = data.get("Artists", []) or []
|
||||
artist = ", ".join(artists) if artists else data.get("AlbumArtist")
|
||||
|
||||
# Get the image item ID (for series/seasons, might be different from item ID)
|
||||
# Pick the most relevant image source.
|
||||
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
|
||||
image_item_id = data.get("ParentId")
|
||||
|
||||
return EmbyNowPlaying(
|
||||
item_id=data.get("Id", ""),
|
||||
@@ -246,38 +373,44 @@ class EmbyCoordinator(DataUpdateCoordinator[dict[str, EmbySession]]):
|
||||
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),
|
||||
index_number=_safe_int(data.get("IndexNumber")),
|
||||
parent_index_number=_safe_int(data.get("ParentIndexNumber")),
|
||||
duration_ticks=_safe_int(data.get("RunTimeTicks"), default=0) or 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"),
|
||||
backdrop_image_tags=tuple(data.get("BackdropImageTags", []) or []),
|
||||
genres=tuple(data.get("Genres", []) or []),
|
||||
production_year=_safe_int(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"),
|
||||
updated_at=utcnow(),
|
||||
is_paused=bool(data.get("IsPaused", False)),
|
||||
is_muted=bool(data.get("IsMuted", False)),
|
||||
volume_level=_safe_int(data.get("VolumeLevel"), default=100) or 0,
|
||||
position_ticks=_safe_int(data.get("PositionTicks"), default=0) or 0,
|
||||
can_seek=bool(data.get("CanSeek", True)),
|
||||
repeat_mode=str(data.get("RepeatMode") or "RepeatNone"),
|
||||
shuffle_mode=str(data.get("ShuffleMode") or "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)
|
||||
"""Update the polling scan interval.
|
||||
|
||||
If WebSocket is connected, this only takes effect after disconnect.
|
||||
"""
|
||||
self._poll_interval = interval
|
||||
if not self._websocket.connected:
|
||||
self.update_interval = timedelta(seconds=interval)
|
||||
_LOGGER.debug("Updated polling interval to %d seconds", interval)
|
||||
|
||||
async def async_shutdown(self) -> None:
|
||||
"""Shut down the coordinator."""
|
||||
if self._remove_ws_callback:
|
||||
self._remove_ws_callback()
|
||||
|
||||
self._remove_ws_callback = None
|
||||
await self._websocket.close()
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Diagnostics support for Emby Media Player."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import EmbyConfigEntry
|
||||
from .const import CONF_API_KEY
|
||||
|
||||
TO_REDACT_ENTRY = {CONF_API_KEY}
|
||||
# Session-level fields that could allow Emby command injection if leaked
|
||||
# alongside the API key.
|
||||
TO_REDACT_SESSION = {"session_id", "device_id", "user_id"}
|
||||
|
||||
|
||||
def _stable_token(value: str) -> str:
|
||||
"""Stable, irreversible token for a session identifier.
|
||||
|
||||
The first 10 chars of an MD5 are enough to correlate entries in a single
|
||||
diagnostics dump without exposing the real Emby ID.
|
||||
"""
|
||||
return "sid-" + hashlib.md5(value.encode("utf-8")).hexdigest()[:10] # noqa: S324
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: EmbyConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
runtime = entry.runtime_data
|
||||
coordinator = runtime.coordinator
|
||||
|
||||
sessions_dump: dict[str, Any] = {}
|
||||
if coordinator.data:
|
||||
for sid, session in coordinator.data.items():
|
||||
session_dict = asdict(session)
|
||||
# Convert datetimes to isoformat for JSON friendliness.
|
||||
for key, value in list(session_dict.items()):
|
||||
if hasattr(value, "isoformat"):
|
||||
session_dict[key] = value.isoformat()
|
||||
# Nested play_state.updated_at may also be a datetime.
|
||||
if isinstance(value, dict):
|
||||
for sub_k, sub_v in list(value.items()):
|
||||
if hasattr(sub_v, "isoformat"):
|
||||
value[sub_k] = sub_v.isoformat()
|
||||
sessions_dump[_stable_token(sid)] = async_redact_data(
|
||||
session_dict, TO_REDACT_SESSION
|
||||
)
|
||||
|
||||
return {
|
||||
"entry": {
|
||||
"title": entry.title,
|
||||
"data": async_redact_data(dict(entry.data), TO_REDACT_ENTRY),
|
||||
"options": dict(entry.options),
|
||||
"unique_id": entry.unique_id,
|
||||
},
|
||||
"server_id": runtime.server_id,
|
||||
"websocket_connected": coordinator.websocket_connected,
|
||||
"last_update_success": coordinator.last_update_success,
|
||||
"sessions": sessions_dump,
|
||||
}
|
||||
@@ -4,9 +4,26 @@
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"dependencies": [],
|
||||
"documentation": "https://github.com/your-repo/haos-integration-emby",
|
||||
"documentation": "https://git.dolgolyov-family.by/alexei.dolgolyov/haos-integration-emby",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"issue_tracker": "https://github.com/your-repo/haos-integration-emby/issues",
|
||||
"issue_tracker": "https://git.dolgolyov-family.by/alexei.dolgolyov/haos-integration-emby/issues",
|
||||
"loggers": ["custom_components.emby_player"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": [],
|
||||
"version": "1.0.0"
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Emby"
|
||||
},
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1",
|
||||
"manufacturer": "Emby"
|
||||
}
|
||||
],
|
||||
"version": "0.2.0",
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_emby._tcp.local."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,26 +3,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
BrowseMedia,
|
||||
MediaPlayerDeviceClass,
|
||||
MediaPlayerEnqueue,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
MediaType,
|
||||
RepeatMode,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
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 .api import EmbyApiError
|
||||
from .browse_media import async_browse_media
|
||||
from .const import (
|
||||
ATTR_CLIENT_NAME,
|
||||
@@ -30,21 +34,32 @@ from .const import (
|
||||
ATTR_DEVICE_NAME,
|
||||
ATTR_ITEM_ID,
|
||||
ATTR_ITEM_TYPE,
|
||||
ATTR_PLAY_METHOD,
|
||||
ATTR_SESSION_ID,
|
||||
ATTR_USER_NAME,
|
||||
DOMAIN,
|
||||
EMBY_ID_PATTERN,
|
||||
ITEM_TYPE_AUDIO,
|
||||
ITEM_TYPE_EPISODE,
|
||||
ITEM_TYPE_MOVIE,
|
||||
MEDIA_TYPE_AUDIO,
|
||||
MEDIA_TYPE_VIDEO,
|
||||
PLAY_COMMAND_PLAY_LAST,
|
||||
PLAY_COMMAND_PLAY_NEXT,
|
||||
PLAY_COMMAND_PLAY_NOW,
|
||||
REPEAT_MODE_ALL,
|
||||
REPEAT_MODE_NONE,
|
||||
REPEAT_MODE_ONE,
|
||||
STALE_PRUNE_GRACE_SECONDS,
|
||||
STALE_SESSION_TIMEOUT,
|
||||
TICKS_PER_SECOND,
|
||||
)
|
||||
from .coordinator import EmbyCoordinator, EmbySession
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Supported features for Emby media player
|
||||
_EMBY_ID_RE = re.compile(EMBY_ID_PATTERN)
|
||||
|
||||
SUPPORTED_FEATURES = (
|
||||
MediaPlayerEntityFeature.PAUSE
|
||||
| MediaPlayerEntityFeature.PLAY
|
||||
@@ -56,8 +71,37 @@ SUPPORTED_FEATURES = (
|
||||
| MediaPlayerEntityFeature.NEXT_TRACK
|
||||
| MediaPlayerEntityFeature.PLAY_MEDIA
|
||||
| MediaPlayerEntityFeature.BROWSE_MEDIA
|
||||
| MediaPlayerEntityFeature.REPEAT_SET
|
||||
)
|
||||
|
||||
# HA RepeatMode <-> Emby repeat mode
|
||||
_HA_TO_EMBY_REPEAT = {
|
||||
RepeatMode.OFF: REPEAT_MODE_NONE,
|
||||
RepeatMode.ONE: REPEAT_MODE_ONE,
|
||||
RepeatMode.ALL: REPEAT_MODE_ALL,
|
||||
}
|
||||
_EMBY_TO_HA_REPEAT = {v: k for k, v in _HA_TO_EMBY_REPEAT.items()}
|
||||
|
||||
# Explicit HA enqueue → Emby play-command mapping. Anything not in this map
|
||||
# falls back to "PlayNow".
|
||||
_ENQUEUE_TO_PLAY_COMMAND: dict[MediaPlayerEnqueue | None, str] = {
|
||||
None: PLAY_COMMAND_PLAY_NOW,
|
||||
MediaPlayerEnqueue.PLAY: PLAY_COMMAND_PLAY_NOW,
|
||||
MediaPlayerEnqueue.REPLACE: PLAY_COMMAND_PLAY_NOW,
|
||||
MediaPlayerEnqueue.NEXT: PLAY_COMMAND_PLAY_NEXT,
|
||||
MediaPlayerEnqueue.ADD: PLAY_COMMAND_PLAY_LAST,
|
||||
}
|
||||
|
||||
|
||||
def _device_class_for_client(client_name: str) -> MediaPlayerDeviceClass | None:
|
||||
"""Pick a sensible device class from the Emby client identifier."""
|
||||
name = (client_name or "").lower()
|
||||
if any(token in name for token in ("android tv", "fire tv", "roku", "kodi", "tv")):
|
||||
return MediaPlayerDeviceClass.TV
|
||||
if any(token in name for token in ("speaker", "music")):
|
||||
return MediaPlayerDeviceClass.SPEAKER
|
||||
return None
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -68,18 +112,18 @@ async def async_setup_entry(
|
||||
runtime_data: EmbyRuntimeData = entry.runtime_data
|
||||
coordinator = runtime_data.coordinator
|
||||
|
||||
# Track which sessions we've already created entities for
|
||||
tracked_sessions: set[str] = set()
|
||||
setup_started = utcnow()
|
||||
|
||||
@callback
|
||||
def async_update_entities() -> None:
|
||||
"""Add new entities for new sessions."""
|
||||
"""Add new entities for new sessions and prune stale ones."""
|
||||
if coordinator.data is None:
|
||||
return
|
||||
|
||||
current_sessions = set(coordinator.data.keys())
|
||||
new_sessions = current_sessions - tracked_sessions
|
||||
|
||||
new_sessions = current_sessions - tracked_sessions
|
||||
if new_sessions:
|
||||
new_entities = [
|
||||
EmbyMediaPlayer(coordinator, entry, session_id)
|
||||
@@ -87,42 +131,89 @@ async def async_setup_entry(
|
||||
]
|
||||
async_add_entities(new_entities)
|
||||
tracked_sessions.update(new_sessions)
|
||||
_LOGGER.debug("Added %d new Emby media player entities", len(new_entities))
|
||||
_LOGGER.debug(
|
||||
"Added %d new Emby media player entities", len(new_entities)
|
||||
)
|
||||
|
||||
_prune_stale_devices(
|
||||
hass, entry, coordinator, tracked_sessions, setup_started
|
||||
)
|
||||
|
||||
# Register listener for coordinator updates
|
||||
entry.async_on_unload(coordinator.async_add_listener(async_update_entities))
|
||||
|
||||
# Add entities for existing sessions
|
||||
async_update_entities()
|
||||
|
||||
|
||||
@callback
|
||||
def _prune_stale_devices(
|
||||
hass: HomeAssistant,
|
||||
entry: EmbyConfigEntry,
|
||||
coordinator: EmbyCoordinator,
|
||||
tracked_sessions: set[str],
|
||||
setup_started: datetime,
|
||||
) -> None:
|
||||
"""Remove device registry entries for sessions absent for too long.
|
||||
|
||||
We use the coordinator's per-session ``last_seen`` map as the source of
|
||||
truth, and skip pruning entirely for the first ``STALE_PRUNE_GRACE_SECONDS``
|
||||
after setup so that sessions that haven't come online yet aren't wiped.
|
||||
"""
|
||||
if coordinator.data is None:
|
||||
return
|
||||
|
||||
now = utcnow()
|
||||
if (now - setup_started).total_seconds() < STALE_PRUNE_GRACE_SECONDS:
|
||||
return
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
current_ids = set(coordinator.data.keys())
|
||||
stale = tracked_sessions - current_ids
|
||||
if not stale:
|
||||
return
|
||||
|
||||
for session_id in list(stale):
|
||||
last_seen = coordinator.get_session_last_seen(session_id)
|
||||
if last_seen is None:
|
||||
# Never seen, but we tracked it (created an entity) — likely
|
||||
# added during this HA boot but already gone. Use setup_started
|
||||
# as the floor.
|
||||
last_seen = setup_started
|
||||
if (now - last_seen).total_seconds() <= STALE_SESSION_TIMEOUT:
|
||||
continue
|
||||
|
||||
device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, session_id)}
|
||||
)
|
||||
if device is not None:
|
||||
_LOGGER.debug("Removing stale Emby device %s", session_id)
|
||||
device_registry.async_remove_device(device.id)
|
||||
tracked_sessions.discard(session_id)
|
||||
coordinator.forget_session(session_id)
|
||||
|
||||
|
||||
class EmbyMediaPlayer(CoordinatorEntity[EmbyCoordinator], MediaPlayerEntity):
|
||||
"""Representation of an Emby media player."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_device_class = MediaPlayerDeviceClass.TV
|
||||
_attr_name = None # use device name only
|
||||
_attr_supported_features = SUPPORTED_FEATURES
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: EmbyCoordinator,
|
||||
entry: ConfigEntry,
|
||||
entry: EmbyConfigEntry,
|
||||
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})"
|
||||
|
||||
session = self._session
|
||||
client_name = session.client_name if session else ""
|
||||
device_class = _device_class_for_client(client_name)
|
||||
if device_class is not None:
|
||||
self._attr_device_class = device_class
|
||||
|
||||
@property
|
||||
def _session(self) -> EmbySession | None:
|
||||
@@ -139,7 +230,9 @@ class EmbyMediaPlayer(CoordinatorEntity[EmbyCoordinator], MediaPlayerEntity):
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return self.coordinator.last_update_success and self._session is not None
|
||||
ws_ok = self.coordinator.websocket_connected
|
||||
polling_ok = self.coordinator.last_update_success
|
||||
return (ws_ok or polling_ok) and self._session is not None
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
@@ -150,11 +243,12 @@ class EmbyMediaPlayer(CoordinatorEntity[EmbyCoordinator], MediaPlayerEntity):
|
||||
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self._session_id)},
|
||||
name=f"{device_name}",
|
||||
name=device_name,
|
||||
manufacturer="Emby",
|
||||
model=client_name,
|
||||
sw_version=session.app_version if session else None,
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
via_device=(DOMAIN, self._entry.entry_id),
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -163,7 +257,6 @@ class EmbyMediaPlayer(CoordinatorEntity[EmbyCoordinator], MediaPlayerEntity):
|
||||
session = self._session
|
||||
if session is None:
|
||||
return MediaPlayerState.OFF
|
||||
|
||||
if session.is_playing:
|
||||
return MediaPlayerState.PLAYING
|
||||
if session.is_paused:
|
||||
@@ -198,32 +291,33 @@ class EmbyMediaPlayer(CoordinatorEntity[EmbyCoordinator], MediaPlayerEntity):
|
||||
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
|
||||
if not session or not session.now_playing:
|
||||
return None
|
||||
np = session.now_playing
|
||||
if np.media_type == MEDIA_TYPE_AUDIO:
|
||||
return MediaType.MUSIC
|
||||
if np.media_type == MEDIA_TYPE_VIDEO:
|
||||
if np.item_type == ITEM_TYPE_MOVIE:
|
||||
return MediaType.MOVIE
|
||||
if np.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
|
||||
if not session or not session.now_playing:
|
||||
return None
|
||||
np = session.now_playing
|
||||
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
|
||||
|
||||
@property
|
||||
def media_artist(self) -> str | None:
|
||||
@@ -291,26 +385,18 @@ class EmbyMediaPlayer(CoordinatorEntity[EmbyCoordinator], MediaPlayerEntity):
|
||||
|
||||
@property
|
||||
def media_position_updated_at(self) -> datetime | None:
|
||||
"""Return when position was last updated."""
|
||||
"""Return when position was last updated by the coordinator."""
|
||||
session = self._session
|
||||
if session and session.play_state and session.now_playing:
|
||||
return utcnow()
|
||||
if session and session.play_state:
|
||||
return session.play_state.updated_at
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_image_url(self) -> str | None:
|
||||
"""Return the image URL of current playing media."""
|
||||
def repeat(self) -> RepeatMode | None:
|
||||
"""Return current repeat mode."""
|
||||
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,
|
||||
)
|
||||
if session and session.play_state:
|
||||
return _EMBY_TO_HA_REPEAT.get(session.play_state.repeat_mode)
|
||||
return None
|
||||
|
||||
@property
|
||||
@@ -320,60 +406,106 @@ class EmbyMediaPlayer(CoordinatorEntity[EmbyCoordinator], MediaPlayerEntity):
|
||||
if session is None:
|
||||
return {}
|
||||
|
||||
attrs = {
|
||||
attrs: dict[str, Any] = {
|
||||
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.user_name:
|
||||
attrs[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
|
||||
if session.play_state and session.play_state.play_method:
|
||||
attrs[ATTR_PLAY_METHOD] = session.play_state.play_method
|
||||
|
||||
return attrs
|
||||
|
||||
@property
|
||||
def media_image_remotely_accessible(self) -> bool:
|
||||
"""The image is fetched server-side via our proxy."""
|
||||
return False
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Playback Control Methods
|
||||
# Image proxying — keeps the API key off the client browser
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def async_get_media_image(
|
||||
self,
|
||||
) -> tuple[bytes | None, str | None]:
|
||||
"""Fetch the current media image server-side using the API key in a header."""
|
||||
session = self._session
|
||||
if not session or not session.now_playing:
|
||||
return None, None
|
||||
np = session.now_playing
|
||||
item_id = np.primary_image_item_id or np.item_id
|
||||
if not item_id:
|
||||
return None, None
|
||||
|
||||
try:
|
||||
return await self._runtime_data.api.fetch_image(
|
||||
item_id,
|
||||
image_type="Primary",
|
||||
max_width=500,
|
||||
max_height=500,
|
||||
)
|
||||
except EmbyApiError as err:
|
||||
_LOGGER.debug("Failed to fetch media image: %s", err)
|
||||
return None, None
|
||||
|
||||
async def async_get_browse_image(
|
||||
self,
|
||||
media_content_type: MediaType | str,
|
||||
media_content_id: str,
|
||||
media_image_id: str | None = None,
|
||||
) -> tuple[bytes | None, str | None]:
|
||||
"""Fetch a browse-tree image."""
|
||||
if not media_content_id or not _EMBY_ID_RE.fullmatch(media_content_id):
|
||||
return None, None
|
||||
try:
|
||||
return await self._runtime_data.api.fetch_image(
|
||||
media_content_id,
|
||||
image_type="Primary",
|
||||
max_width=300,
|
||||
)
|
||||
except EmbyApiError as err:
|
||||
_LOGGER.debug("Failed to fetch browse image: %s", err)
|
||||
return None, None
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Playback Control
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
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)
|
||||
position_ticks = max(0, round(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)
|
||||
volume_percent = int(max(0.0, min(1.0, 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."""
|
||||
@@ -381,7 +513,11 @@ class EmbyMediaPlayer(CoordinatorEntity[EmbyCoordinator], MediaPlayerEntity):
|
||||
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()
|
||||
|
||||
async def async_set_repeat(self, repeat: RepeatMode) -> None:
|
||||
"""Set repeat mode."""
|
||||
emby_mode = _HA_TO_EMBY_REPEAT.get(repeat, REPEAT_MODE_NONE)
|
||||
await self._runtime_data.api.set_repeat_mode(self._session_id, emby_mode)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Media Browsing & Playing
|
||||
@@ -394,17 +530,39 @@ class EmbyMediaPlayer(CoordinatorEntity[EmbyCoordinator], MediaPlayerEntity):
|
||||
**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,
|
||||
if not isinstance(media_id, str) or not media_id.strip():
|
||||
raise ServiceValidationError("media_id must be a non-empty string")
|
||||
item_id = media_id.strip()
|
||||
if not _EMBY_ID_RE.fullmatch(item_id):
|
||||
raise ServiceValidationError(
|
||||
f"media_id is not a valid Emby item id: {media_id!r}"
|
||||
)
|
||||
|
||||
# Map HA enqueue semantics to Emby play commands.
|
||||
enqueue = kwargs.get("enqueue")
|
||||
play_command = _ENQUEUE_TO_PLAY_COMMAND.get(
|
||||
enqueue, PLAY_COMMAND_PLAY_NOW
|
||||
)
|
||||
await self._runtime_data.api.play_media(
|
||||
self._session_id,
|
||||
item_ids=[media_id],
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
position = kwargs.get("position")
|
||||
if position is not None:
|
||||
if not isinstance(position, (int, float)) or position < 0:
|
||||
raise ServiceValidationError(
|
||||
"position must be a non-negative number"
|
||||
)
|
||||
start_position_ticks = round(position * TICKS_PER_SECOND)
|
||||
else:
|
||||
start_position_ticks = 0
|
||||
|
||||
try:
|
||||
await self._runtime_data.api.play_media(
|
||||
self._session_id,
|
||||
item_ids=[item_id],
|
||||
play_command=play_command,
|
||||
start_position_ticks=start_position_ticks,
|
||||
)
|
||||
except EmbyApiError as err:
|
||||
raise ServiceValidationError(str(err)) from err
|
||||
|
||||
async def async_browse_media(
|
||||
self,
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Services for the Emby Media Player integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .api import EmbyApiError
|
||||
from .const import (
|
||||
ATTR_HEADER,
|
||||
ATTR_MESSAGE,
|
||||
ATTR_REPEAT_MODE,
|
||||
ATTR_TIMEOUT_MS,
|
||||
DOMAIN,
|
||||
REPEAT_MODE_ALL,
|
||||
REPEAT_MODE_NONE,
|
||||
REPEAT_MODE_ONE,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import EmbyRuntimeData
|
||||
from .api import EmbyApiClient
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SERVICE_SEND_MESSAGE = "send_message"
|
||||
SERVICE_SET_REPEAT = "set_repeat"
|
||||
SERVICE_REFRESH_LIBRARY = "refresh_library"
|
||||
|
||||
_REPEAT_MODES = (REPEAT_MODE_NONE, REPEAT_MODE_ONE, REPEAT_MODE_ALL)
|
||||
|
||||
_SEND_MESSAGE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
|
||||
vol.Required(ATTR_MESSAGE): cv.string,
|
||||
vol.Optional(ATTR_HEADER): cv.string,
|
||||
vol.Optional(ATTR_TIMEOUT_MS): vol.All(
|
||||
cv.positive_int, vol.Range(min=100, max=60000)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
_SET_REPEAT_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
|
||||
vol.Required(ATTR_REPEAT_MODE): vol.In(_REPEAT_MODES),
|
||||
}
|
||||
)
|
||||
|
||||
_REFRESH_LIBRARY_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _resolve_sessions(
|
||||
hass: HomeAssistant, entity_ids: list[str]
|
||||
) -> list[tuple["EmbyRuntimeData", str]]:
|
||||
"""Resolve entity_ids into (runtime_data, session_id) pairs."""
|
||||
entity_registry = er.async_get(hass)
|
||||
resolved: list[tuple[EmbyRuntimeData, str]] = []
|
||||
|
||||
for entity_id in entity_ids:
|
||||
entry = entity_registry.async_get(entity_id)
|
||||
if (
|
||||
entry is None
|
||||
or entry.platform != DOMAIN
|
||||
or entry.domain != MEDIA_PLAYER_DOMAIN
|
||||
):
|
||||
raise ServiceValidationError(
|
||||
f"{entity_id} is not an Emby media player"
|
||||
)
|
||||
|
||||
config_entry = hass.config_entries.async_get_entry(entry.config_entry_id)
|
||||
if (
|
||||
config_entry is None
|
||||
or config_entry.state is not ConfigEntryState.LOADED
|
||||
):
|
||||
raise HomeAssistantError(
|
||||
f"Emby integration for {entity_id} is not loaded"
|
||||
)
|
||||
|
||||
# unique_id is "{entry_id}_{session_id}"
|
||||
prefix = f"{config_entry.entry_id}_"
|
||||
unique_id = entry.unique_id or ""
|
||||
if not unique_id.startswith(prefix):
|
||||
raise HomeAssistantError(
|
||||
f"Cannot resolve session for {entity_id}"
|
||||
)
|
||||
session_id = unique_id.removeprefix(prefix)
|
||||
resolved.append((config_entry.runtime_data, session_id))
|
||||
|
||||
return resolved
|
||||
|
||||
|
||||
def _resolve_apis(
|
||||
hass: HomeAssistant, entity_ids: list[str] | None
|
||||
) -> list["EmbyApiClient"]:
|
||||
"""Resolve unique API clients for the supplied entities, or all entries."""
|
||||
if entity_ids:
|
||||
return [rd.api for rd, _ in _resolve_sessions(hass, entity_ids)]
|
||||
|
||||
return [
|
||||
entry.runtime_data.api
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.state is ConfigEntryState.LOADED
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register integration-level services (idempotent)."""
|
||||
|
||||
if hass.services.has_service(DOMAIN, SERVICE_SEND_MESSAGE):
|
||||
return
|
||||
|
||||
async def _send_message(call: ServiceCall) -> None:
|
||||
targets = _resolve_sessions(hass, call.data[ATTR_ENTITY_ID])
|
||||
message = call.data[ATTR_MESSAGE]
|
||||
header = call.data.get(ATTR_HEADER)
|
||||
timeout_ms = call.data.get(ATTR_TIMEOUT_MS)
|
||||
|
||||
errors: list[str] = []
|
||||
for runtime_data, session_id in targets:
|
||||
try:
|
||||
await runtime_data.api.display_message(
|
||||
session_id, message, header=header, timeout_ms=timeout_ms
|
||||
)
|
||||
except EmbyApiError as err:
|
||||
errors.append(f"{session_id}: {err}")
|
||||
|
||||
if errors:
|
||||
raise HomeAssistantError(
|
||||
"Failed to send message to: " + "; ".join(errors)
|
||||
)
|
||||
|
||||
async def _set_repeat(call: ServiceCall) -> None:
|
||||
targets = _resolve_sessions(hass, call.data[ATTR_ENTITY_ID])
|
||||
mode = call.data[ATTR_REPEAT_MODE]
|
||||
|
||||
errors: list[str] = []
|
||||
for runtime_data, session_id in targets:
|
||||
try:
|
||||
await runtime_data.api.set_repeat_mode(session_id, mode)
|
||||
except EmbyApiError as err:
|
||||
errors.append(f"{session_id}: {err}")
|
||||
|
||||
if errors:
|
||||
raise HomeAssistantError(
|
||||
"Failed to set repeat mode on: " + "; ".join(errors)
|
||||
)
|
||||
|
||||
async def _refresh_library(call: ServiceCall) -> None:
|
||||
apis = _resolve_apis(hass, call.data.get(ATTR_ENTITY_ID))
|
||||
# De-duplicate API instances (multiple entities can share one server).
|
||||
seen: set[int] = set()
|
||||
errors: list[str] = []
|
||||
for api in apis:
|
||||
if id(api) in seen:
|
||||
continue
|
||||
seen.add(id(api))
|
||||
try:
|
||||
await api.refresh_library()
|
||||
except EmbyApiError as err:
|
||||
errors.append(str(err))
|
||||
|
||||
if errors:
|
||||
raise HomeAssistantError(
|
||||
"Failed to refresh library on one or more servers: "
|
||||
+ "; ".join(errors)
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SEND_MESSAGE, _send_message, schema=_SEND_MESSAGE_SCHEMA
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SET_REPEAT, _set_repeat, schema=_SET_REPEAT_SCHEMA
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_REFRESH_LIBRARY,
|
||||
_refresh_library,
|
||||
schema=_REFRESH_LIBRARY_SCHEMA,
|
||||
)
|
||||
|
||||
|
||||
def async_unload_services(hass: HomeAssistant) -> None:
|
||||
"""Remove integration-level services."""
|
||||
for service in (
|
||||
SERVICE_SEND_MESSAGE,
|
||||
SERVICE_SET_REPEAT,
|
||||
SERVICE_REFRESH_LIBRARY,
|
||||
):
|
||||
if hass.services.has_service(DOMAIN, service):
|
||||
hass.services.async_remove(DOMAIN, service)
|
||||
@@ -0,0 +1,63 @@
|
||||
send_message:
|
||||
name: Send Message
|
||||
description: >-
|
||||
Display a message on the Emby client device.
|
||||
target:
|
||||
entity:
|
||||
integration: emby_player
|
||||
domain: media_player
|
||||
fields:
|
||||
message:
|
||||
name: Message
|
||||
description: The text to display on the client.
|
||||
required: true
|
||||
example: "Pizza is here!"
|
||||
selector:
|
||||
text:
|
||||
header:
|
||||
name: Header
|
||||
description: Optional header/title for the message.
|
||||
example: "Doorbell"
|
||||
selector:
|
||||
text:
|
||||
timeout_ms:
|
||||
name: Timeout (ms)
|
||||
description: How long to display the message, in milliseconds.
|
||||
example: 5000
|
||||
default: 5000
|
||||
selector:
|
||||
number:
|
||||
min: 100
|
||||
max: 60000
|
||||
step: 100
|
||||
mode: box
|
||||
unit_of_measurement: ms
|
||||
|
||||
set_repeat:
|
||||
name: Set Repeat Mode
|
||||
description: Set the repeat mode of the current playback.
|
||||
target:
|
||||
entity:
|
||||
integration: emby_player
|
||||
domain: media_player
|
||||
fields:
|
||||
repeat_mode:
|
||||
name: Repeat Mode
|
||||
description: One of RepeatNone, RepeatOne, RepeatAll.
|
||||
required: true
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- RepeatNone
|
||||
- RepeatOne
|
||||
- RepeatAll
|
||||
|
||||
refresh_library:
|
||||
name: Refresh Library
|
||||
description: >-
|
||||
Trigger an Emby server library scan. If no entity is provided, all
|
||||
configured Emby servers are refreshed.
|
||||
target:
|
||||
entity:
|
||||
integration: emby_player
|
||||
domain: media_player
|
||||
@@ -8,7 +8,8 @@
|
||||
"host": "Host",
|
||||
"port": "Port",
|
||||
"api_key": "API Key",
|
||||
"ssl": "Use SSL"
|
||||
"ssl": "Use SSL",
|
||||
"verify_ssl": "Verify SSL certificate"
|
||||
}
|
||||
},
|
||||
"user_select": {
|
||||
@@ -17,6 +18,13 @@
|
||||
"data": {
|
||||
"user_id": "User"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "Re-authenticate Emby",
|
||||
"description": "Provide a new API key for {host}. The previous key is no longer accepted by the server.",
|
||||
"data": {
|
||||
"api_key": "API Key"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -26,7 +34,8 @@
|
||||
"unknown": "An unexpected error occurred."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This Emby server and user combination is already configured."
|
||||
"already_configured": "This Emby server and user combination is already configured.",
|
||||
"reauth_successful": "Re-authentication was successful."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
@@ -39,5 +48,39 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"send_message": {
|
||||
"name": "Send message",
|
||||
"description": "Display a message on the Emby client.",
|
||||
"fields": {
|
||||
"message": {
|
||||
"name": "Message",
|
||||
"description": "The text to display."
|
||||
},
|
||||
"header": {
|
||||
"name": "Header",
|
||||
"description": "Optional header/title."
|
||||
},
|
||||
"timeout_ms": {
|
||||
"name": "Timeout (ms)",
|
||||
"description": "How long to display the message in milliseconds."
|
||||
}
|
||||
}
|
||||
},
|
||||
"set_repeat": {
|
||||
"name": "Set repeat mode",
|
||||
"description": "Change the current Emby session's repeat mode.",
|
||||
"fields": {
|
||||
"repeat_mode": {
|
||||
"name": "Repeat mode",
|
||||
"description": "RepeatNone, RepeatOne or RepeatAll."
|
||||
}
|
||||
}
|
||||
},
|
||||
"refresh_library": {
|
||||
"name": "Refresh library",
|
||||
"description": "Trigger an Emby library scan."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"host": "Host",
|
||||
"port": "Port",
|
||||
"api_key": "API Key",
|
||||
"ssl": "Use SSL"
|
||||
"ssl": "Use SSL",
|
||||
"verify_ssl": "Verify SSL certificate"
|
||||
}
|
||||
},
|
||||
"user_select": {
|
||||
@@ -17,6 +18,13 @@
|
||||
"data": {
|
||||
"user_id": "User"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "Re-authenticate Emby",
|
||||
"description": "Provide a new API key for {host}. The previous key is no longer accepted by the server.",
|
||||
"data": {
|
||||
"api_key": "API Key"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -26,7 +34,8 @@
|
||||
"unknown": "An unexpected error occurred."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This Emby server and user combination is already configured."
|
||||
"already_configured": "This Emby server and user combination is already configured.",
|
||||
"reauth_successful": "Re-authentication was successful."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
@@ -39,5 +48,39 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"send_message": {
|
||||
"name": "Send message",
|
||||
"description": "Display a message on the Emby client.",
|
||||
"fields": {
|
||||
"message": {
|
||||
"name": "Message",
|
||||
"description": "The text to display."
|
||||
},
|
||||
"header": {
|
||||
"name": "Header",
|
||||
"description": "Optional header/title."
|
||||
},
|
||||
"timeout_ms": {
|
||||
"name": "Timeout (ms)",
|
||||
"description": "How long to display the message in milliseconds."
|
||||
}
|
||||
}
|
||||
},
|
||||
"set_repeat": {
|
||||
"name": "Set repeat mode",
|
||||
"description": "Change the current Emby session's repeat mode.",
|
||||
"fields": {
|
||||
"repeat_mode": {
|
||||
"name": "Repeat mode",
|
||||
"description": "RepeatNone, RepeatOne or RepeatAll."
|
||||
}
|
||||
}
|
||||
},
|
||||
"refresh_library": {
|
||||
"name": "Refresh library",
|
||||
"description": "Trigger an Emby library scan."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,129 +3,181 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
import random
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .const import (
|
||||
DEVICE_ID,
|
||||
DEFAULT_DEVICE_VERSION,
|
||||
DEVICE_NAME,
|
||||
DEVICE_VERSION,
|
||||
WEBSOCKET_PATH,
|
||||
WS_HEARTBEAT,
|
||||
WS_MESSAGE_FORCE_KEEP_ALIVE,
|
||||
WS_MESSAGE_KEEP_ALIVE,
|
||||
WS_MESSAGE_PLAYBACK_PROGRESS,
|
||||
WS_MESSAGE_PLAYBACK_START,
|
||||
WS_MESSAGE_PLAYBACK_STOP,
|
||||
WS_MESSAGE_SESSIONS,
|
||||
WS_MESSAGE_SESSIONS_START,
|
||||
WS_MESSAGE_SESSIONS_STOP,
|
||||
WS_RECONNECT_MAX_DELAY,
|
||||
WS_RECONNECT_MIN_DELAY,
|
||||
)
|
||||
|
||||
_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,
|
||||
}
|
||||
# Message types we surface to subscribers
|
||||
TRACKED_MESSAGE_TYPES = frozenset(
|
||||
{
|
||||
WS_MESSAGE_SESSIONS,
|
||||
WS_MESSAGE_PLAYBACK_START,
|
||||
WS_MESSAGE_PLAYBACK_STOP,
|
||||
WS_MESSAGE_PLAYBACK_PROGRESS,
|
||||
}
|
||||
)
|
||||
|
||||
# Callbacks may be sync or async; both forms are supported.
|
||||
WSCallback = Callable[[str, Any], Awaitable[None] | None]
|
||||
|
||||
# Bound exponent so we don't overflow on long outages.
|
||||
_MAX_BACKOFF_EXPONENT = 6
|
||||
|
||||
|
||||
class EmbyWebSocket:
|
||||
"""WebSocket client for real-time Emby updates."""
|
||||
"""WebSocket client for real-time Emby updates.
|
||||
|
||||
The aiohttp session is owned by Home Assistant and is never closed here.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
api_key: str,
|
||||
device_id: str,
|
||||
session: aiohttp.ClientSession,
|
||||
ssl: bool = False,
|
||||
session: aiohttp.ClientSession | None = None,
|
||||
verify_ssl: bool = True,
|
||||
client_version: str = DEFAULT_DEVICE_VERSION,
|
||||
) -> None:
|
||||
"""Initialize the WebSocket client."""
|
||||
self._host = host
|
||||
if not host or not host.strip():
|
||||
raise ValueError("host must not be empty")
|
||||
if not api_key:
|
||||
raise ValueError("api_key must not be empty")
|
||||
if not device_id:
|
||||
raise ValueError("device_id must not be empty")
|
||||
|
||||
self._host = host.strip().rstrip("/")
|
||||
self._port = port
|
||||
self._api_key = api_key
|
||||
self._device_id = device_id
|
||||
self._ssl = ssl
|
||||
self._verify_ssl = verify_ssl
|
||||
self._session = session
|
||||
self._owns_session = session is None
|
||||
self._client_version = client_version
|
||||
|
||||
protocol = "wss" if ssl else "ws"
|
||||
self._url = f"{protocol}://{host}:{port}{WEBSOCKET_PATH}"
|
||||
self._url = f"{protocol}://{self._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._callbacks: list[WSCallback] = []
|
||||
self._listen_task: asyncio.Task[None] | None = None
|
||||
self._reconnect_task: asyncio.Task[None] | None = None
|
||||
self._running = False
|
||||
self._reconnect_interval = 30 # seconds
|
||||
self._reconnect_attempts = 0
|
||||
|
||||
@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
|
||||
def _ssl_kwarg(self) -> dict[str, Any]:
|
||||
"""Return ssl kwarg for aiohttp depending on config."""
|
||||
if not self._ssl:
|
||||
return {}
|
||||
return {"ssl": self._verify_ssl}
|
||||
|
||||
def _backoff_delay(self) -> float:
|
||||
"""Compute exponential backoff with jitter for reconnects."""
|
||||
exponent = min(self._reconnect_attempts, _MAX_BACKOFF_EXPONENT)
|
||||
base = min(
|
||||
WS_RECONNECT_MAX_DELAY,
|
||||
WS_RECONNECT_MIN_DELAY * (2**exponent),
|
||||
)
|
||||
jitter = random.uniform(0, base * 0.2) # noqa: S311 - non-crypto jitter
|
||||
return base + jitter
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to Emby WebSocket."""
|
||||
"""Connect to Emby WebSocket. Returns True on success."""
|
||||
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,
|
||||
# API token in headers (not query string) keeps it out of proxy logs.
|
||||
headers = {
|
||||
"X-Emby-Token": self._api_key,
|
||||
"X-Emby-Client": DEVICE_NAME,
|
||||
"X-Emby-Device-Name": DEVICE_NAME,
|
||||
"X-Emby-Device-Id": self._device_id,
|
||||
"X-Emby-Client-Version": self._client_version,
|
||||
}
|
||||
# deviceId is also required as a query param by some Emby versions.
|
||||
params = {"deviceId": self._device_id}
|
||||
|
||||
try:
|
||||
self._ws = await session.ws_connect(
|
||||
self._ws = await self._session.ws_connect(
|
||||
self._url,
|
||||
params=params,
|
||||
heartbeat=30,
|
||||
headers=headers,
|
||||
heartbeat=WS_HEARTBEAT,
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
**self._ssl_kwarg(),
|
||||
)
|
||||
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.WSServerHandshakeError as err:
|
||||
if err.status in (401, 403):
|
||||
_LOGGER.warning("WebSocket auth failed: %s", err)
|
||||
self._running = False
|
||||
return False
|
||||
_LOGGER.warning("WebSocket handshake failed: %s", err)
|
||||
return False
|
||||
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)
|
||||
except TimeoutError:
|
||||
_LOGGER.warning("Timeout connecting to Emby WebSocket")
|
||||
return False
|
||||
|
||||
self._running = True
|
||||
self._reconnect_attempts = 0
|
||||
_LOGGER.debug("Connected to Emby WebSocket at %s", self._url)
|
||||
self._listen_task = asyncio.create_task(
|
||||
self._listen(), name="emby_ws_listen"
|
||||
)
|
||||
return True
|
||||
|
||||
async def _listen(self) -> None:
|
||||
"""Listen for WebSocket messages."""
|
||||
if not self._ws:
|
||||
ws = self._ws
|
||||
if ws is None:
|
||||
return
|
||||
|
||||
try:
|
||||
async for msg in self._ws:
|
||||
async for msg in ws:
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
try:
|
||||
data = json.loads(msg.data)
|
||||
await self._handle_message(data)
|
||||
await self._handle_message(json.loads(msg.data))
|
||||
except json.JSONDecodeError:
|
||||
_LOGGER.warning("Invalid JSON received: %s", msg.data)
|
||||
_LOGGER.debug("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"
|
||||
_LOGGER.debug(
|
||||
"WebSocket error: %s",
|
||||
ws.exception() if ws else "Unknown",
|
||||
)
|
||||
break
|
||||
|
||||
@@ -139,50 +191,78 @@ class EmbyWebSocket:
|
||||
|
||||
except asyncio.CancelledError:
|
||||
_LOGGER.debug("WebSocket listener cancelled")
|
||||
except Exception as err:
|
||||
_LOGGER.exception("Error in WebSocket listener: %s", err)
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - log and reconnect
|
||||
_LOGGER.exception("Unexpected error in WebSocket listener")
|
||||
finally:
|
||||
self._ws = None
|
||||
self._schedule_reconnect()
|
||||
|
||||
# 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())
|
||||
def _schedule_reconnect(self) -> None:
|
||||
"""Schedule a reconnect attempt unless one is already pending."""
|
||||
if not self._running:
|
||||
return
|
||||
if self._reconnect_task is not None and not self._reconnect_task.done():
|
||||
# Already scheduled; do not stack reconnects.
|
||||
return
|
||||
|
||||
async def _reconnect(self) -> None:
|
||||
"""Attempt to reconnect to WebSocket."""
|
||||
await asyncio.sleep(self._reconnect_interval)
|
||||
self._reconnect_attempts += 1
|
||||
delay = self._backoff_delay()
|
||||
_LOGGER.info(
|
||||
"WebSocket disconnected, reconnecting in %.1fs (attempt %d)",
|
||||
delay,
|
||||
self._reconnect_attempts,
|
||||
)
|
||||
self._reconnect_task = asyncio.create_task(
|
||||
self._reconnect(delay), name="emby_ws_reconnect"
|
||||
)
|
||||
|
||||
if self._running and not self.connected:
|
||||
_LOGGER.debug("Attempting WebSocket reconnection...")
|
||||
if await self.connect():
|
||||
await self.subscribe_to_sessions()
|
||||
async def _reconnect(self, delay: float) -> None:
|
||||
"""Attempt to reconnect to WebSocket after a delay."""
|
||||
try:
|
||||
await asyncio.sleep(delay)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
|
||||
if not self._running or self.connected:
|
||||
return
|
||||
|
||||
_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)
|
||||
# Echo ForceKeepAlive so Emby doesn't drop the connection.
|
||||
if msg_type == WS_MESSAGE_FORCE_KEEP_ALIVE:
|
||||
await self._send_message(WS_MESSAGE_KEEP_ALIVE, "")
|
||||
return
|
||||
|
||||
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")
|
||||
if msg_type not in TRACKED_MESSAGE_TYPES:
|
||||
return
|
||||
|
||||
for cb in list(self._callbacks):
|
||||
try:
|
||||
result = cb(msg_type, data)
|
||||
if inspect.isawaitable(result):
|
||||
# Detach so a slow async callback doesn't block the reader.
|
||||
asyncio.create_task(
|
||||
_swallow_callback(result),
|
||||
name="emby_ws_callback",
|
||||
)
|
||||
except Exception: # noqa: BLE001 - never let a cb kill us
|
||||
_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")
|
||||
_LOGGER.debug("Cannot subscribe: WebSocket not connected")
|
||||
return
|
||||
|
||||
# Request session updates every 1500ms
|
||||
# Request session updates roughly every 1500ms.
|
||||
await self._send_message(WS_MESSAGE_SESSIONS_START, "0,1500")
|
||||
_LOGGER.debug("Subscribed to session updates")
|
||||
|
||||
@@ -193,23 +273,19 @@ class EmbyWebSocket:
|
||||
|
||||
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:
|
||||
ws = self._ws
|
||||
if ws is None or 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)
|
||||
await ws.send_json({"MessageType": message_type, "Data": data})
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.debug("Failed to send WebSocket message: %s", err)
|
||||
|
||||
def add_callback(self, callback: Callable[[str, Any], None]) -> Callable[[], None]:
|
||||
"""Add a callback for WebSocket messages.
|
||||
def add_callback(self, callback: WSCallback) -> Callable[[], None]:
|
||||
"""Register a callback for tracked WebSocket messages.
|
||||
|
||||
Returns a function to remove the callback.
|
||||
Returns a function that removes the callback when called.
|
||||
"""
|
||||
self._callbacks.append(callback)
|
||||
|
||||
@@ -219,26 +295,37 @@ class EmbyWebSocket:
|
||||
|
||||
return remove_callback
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from WebSocket."""
|
||||
async def close(self) -> None:
|
||||
"""Close the WebSocket and cancel any pending reconnect."""
|
||||
self._running = False
|
||||
|
||||
if self._reconnect_task and not self._reconnect_task.done():
|
||||
self._reconnect_task.cancel()
|
||||
try:
|
||||
await self._reconnect_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._reconnect_task = None
|
||||
|
||||
if self._listen_task and not self._listen_task.done():
|
||||
self._listen_task.cancel()
|
||||
try:
|
||||
await self._listen_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._listen_task = None
|
||||
|
||||
if self._ws and not self._ws.closed:
|
||||
await self._ws.close()
|
||||
|
||||
self._ws = None
|
||||
|
||||
self._callbacks.clear()
|
||||
_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()
|
||||
async def _swallow_callback(awaitable: Awaitable[None]) -> None:
|
||||
"""Run an async callback and log any exception."""
|
||||
try:
|
||||
await awaitable
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("Error in async WebSocket callback")
|
||||
|
||||
Reference in New Issue
Block a user