chore: release v0.2.0
Release / release (push) Successful in 2s

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:
2026-05-26 13:16:36 +03:00
parent 56c1125ef2
commit 6ae0ed1787
18 changed files with 2170 additions and 645 deletions
+133 -90
View File
@@ -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 560 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.
+197 -73
View File
@@ -1,96 +1,190 @@
# Emby Media Player
[![hacs_badge](https://img.shields.io/badge/HACS-Custom-orange.svg)](https://github.com/hacs/integration)
[![version](https://img.shields.io/badge/version-0.2.0-blue.svg)](RELEASE_NOTES.md)
[![Home Assistant](https://img.shields.io/badge/Home%20Assistant-2024.10%2B-blue.svg)](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 **SettingsDevices & 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 **ExtendedAPI 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** (560 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 (10060000 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
+149
View File
@@ -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))
---
+118 -30
View File
@@ -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
+238 -83
View File
@@ -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
+62 -66
View File
@@ -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,
)
+151 -46
View File
@@ -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(
+59 -8
View File
@@ -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
+195 -62
View File
@@ -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,
}
+20 -3
View File
@@ -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."
}
]
}
+241 -83
View File
@@ -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,
+205
View File
@@ -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
+45 -2
View File
@@ -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."
}
}
}
+183 -96
View File
@@ -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")
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "Emby Media Player",
"homeassistant": "2024.1.0",
"homeassistant": "2024.10.0",
"render_readme": true,
"content_in_root": false
}