diff --git a/CLAUDE.md b/CLAUDE.md index 0348acf..bb003ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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": "", "Arguments": {...}}`, NOT + `/Command/{commandName}?...`. Arguments must be string-typed values, so + `_send_command` stringifies everything. +- **WebSocket `ForceKeepAlive`.** Emby will close the WS connection if you + don't echo back `KeepAlive` to `ForceKeepAlive`. Already handled in + `_handle_message`; don't strip that path. +- **Non-admin API keys.** `GET /Users` returns 401/403 for non-admin tokens. + `EmbyApiClient.get_users` falls back to `/Users/Public`. +- **Ticks.** Emby uses 100ns ticks (`TICKS_PER_SECOND = 10_000_000`). Use + `round()` not `int()` when converting seconds → ticks to avoid off-by-one + on resume positions. +- **`NumberSelector` returns float.** Always `int(...)` before passing to + the API layer (port, scan_interval, etc.). +- **`PlaySessionId` vs `SessionId`.** WS playback events carry both; + `SessionId` is the per-client session, `PlaySessionId` is per-stream and + can collide across devices. The coordinator only keys off `SessionId`. -```python -# Attempt 1 - Wrong endpoint with body -endpoint = f"/Sessions/{session_id}/Command/SetVolume" -data = {"Arguments": {"Volume": 50}} +## Performance budget -# Attempt 2 - Wrong: query parameter -endpoint = f"/Sessions/{session_id}/Command/SetVolume?Volume=50" +- **WS connected.** Trust WS for live updates. REST poll runs every 5 min + (`DEFAULT_SCAN_INTERVAL_WS = 300`) as a safety net; the merge logic in + `_async_update_data` keeps WS-current sessions intact. +- **WS down.** Polling falls back to the user-configured `scan_interval` + (default 10 s; range 5–60 s). +- **Image fetches.** Run on a separate 30 s timeout + (`IMAGE_FETCH_TIMEOUT_SECONDS`), independent of the 15 s REST timeout. -# Correct format -endpoint = f"/Sessions/{session_id}/Command" -data = {"Name": "SetVolume", "Arguments": {"Volume": "50"}} # Arguments as STRINGS -``` +## Security checklist (anything that touches the API key or user URLs) -**Impact:** Volume control didn't work even though mute/unmute worked fine. +- [ ] API key only travels in headers (`X-Emby-Token`) or HA's own redacted + config entry storage — never URLs. +- [ ] All IDs flowing into REST paths validated. +- [ ] `image_type` validated against `ALLOWED_IMAGE_TYPES`. +- [ ] Diagnostics redact API key and hash session/device/user IDs. +- [ ] `verify_ssl` honoured for HTTPS; defaults to verifying. -**Lesson:** Emby general commands must be sent to `/Command` endpoint (not `/Command/{CommandName}`) with: -- `Name` field containing the command name -- `Arguments` as a dict with STRING values, not integers +## Files NOT to edit casually ---- +- `hacs.json` — bumping the `homeassistant` floor is a breaking change for + installed users; coordinate with a release. +- `manifest.json` `version` — must be bumped together with + `RELEASE_NOTES.md` and a Git tag. +- `RELEASE_NOTES.md` v0.x.0 sections — append new sections; don't rewrite + history. -### 3. NumberSelector Returns Float, Not Int +## Release flow -**Problem:** Home Assistant's `NumberSelector` widget returns float values, but port numbers must be integers. +1. Land all changes on a feature branch. +2. Bump `manifest.json` version + add a section to `RELEASE_NOTES.md` (newest + first). +3. Open a PR on Gitea; wait for review. +4. After merge, tag `vX.Y.Z` on the default branch — the Gitea release + workflow under `.gitea/` cuts the release. -```python -# Wrong - port could be 8096.0 -self._port = user_input.get(CONF_PORT, DEFAULT_PORT) +## Anti-patterns we've already paid for -# Correct -self._port = int(user_input.get(CONF_PORT, DEFAULT_PORT)) -``` - -**Impact:** Potential type errors or malformed URLs with decimal port numbers. - -**Lesson:** Always explicitly convert NumberSelector values to the expected type. - ---- - -### 4. Inconsistent Use of Constants - -**Problem:** Hardcoded endpoint paths in some methods instead of using defined constants. - -```python -# Wrong - hardcoded -endpoint = f"/Sessions/{session_id}/Playing" - -# Correct - using constant -endpoint = f"{ENDPOINT_SESSIONS}/{session_id}/Playing" -``` - -**Impact:** When the `/emby` prefix was added to constants, hardcoded paths were missed, causing inconsistent behavior. - -**Lesson:** Always use constants consistently. When fixing issues, search for all occurrences of the affected strings. - ---- - -### 5. Import Confusion Between Local and HA Constants - -**Problem:** Initially imported `CONF_HOST` and `CONF_PORT` from `homeassistant.const` in some files, while also defining them in local `const.py`. - -```python -# Inconsistent imports -from homeassistant.const import CONF_HOST, CONF_PORT # in __init__.py -from .const import CONF_HOST, CONF_PORT # in config_flow.py -``` - -**Impact:** Potential confusion and maintenance issues, though values were identical. - -**Lesson:** Choose one source for constants and use it consistently across all files. For custom integrations, prefer local constants for full control. - ---- - -## Best Practices Established - -1. **Test API endpoints with curl first** - Verify the exact request format before implementing in code -2. **Add debug logging** - Include request URLs and response status codes for troubleshooting -3. **Handle multiple API formats** - Some servers may respond differently; implement fallbacks when sensible -4. **Type conversion** - Always explicitly convert UI input values to expected types -5. **Consistent constant usage** - Define constants once and use them everywhere -6. **Wait for user approval before committing** - Always let the user test changes before creating git commits +- Bypassing constants and hardcoding endpoint paths — see history of the + missing `/emby` prefix. +- Reading API responses without `isinstance` checks — Emby sometimes + returns `null` or unexpected shapes for fields like `RunTimeTicks`. + Use the `_safe_int` helper in `coordinator.py` for any numeric field + coming off the wire. +- Reusing a fixed `DEVICE_ID` across HA instances — Emby will evict + whichever instance last registered, silently breaking the other. +- Including the API key in URLs returned to the browser via + `media_image_url`. The fix is the image proxy in `media_player.py`; don't + reintroduce the URL-with-key shortcut "for performance". +- `except Exception: pass` — always at least `_LOGGER.debug(...)` so + failures are diagnosable. diff --git a/README.md b/README.md index a6ab260..4466733 100644 --- a/README.md +++ b/README.md @@ -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 **Settings → Devices & Services**. +2. Click **Add Integration** and search for **Emby Media Player**. +3. Enter your Emby server details: + - **Host** — Emby server hostname or IP address. + - **Port** — default `8096` (HTTP) or `8920` (HTTPS). + - **API Key** — created in Emby Dashboard → Extended → API Keys. + - **Use SSL** — enable if your server uses HTTPS. + - **Verify SSL certificate** — disable only when using a self-signed certificate that HA's CA bundle doesn't trust. +4. Select the Emby user account to use for browsing & playback. +5. Click **Submit**. -### Getting an API Key +### Getting an API key -1. Open your Emby Server Dashboard -2. Navigate to **Extended** > **API Keys** -3. Click **New API Key** (+ button) -4. Give it a name (e.g., "Home Assistant") -5. Copy the generated key +1. Open your Emby Server Dashboard. +2. Navigate to **Extended → API Keys**. +3. Click **New API Key** (+ button). +4. Give it a name (e.g., `Home Assistant`). +5. Copy the generated key. + +> Admin keys list all users automatically. Non-admin keys fall back to the +> public users endpoint — the integration handles both. + +### Reauthentication + +If you regenerate the API key on the Emby server, Home Assistant will flag +the integration as needing attention and walk you through entering the new +key — no need to remove and re-add the integration. ## Options -After configuration, you can adjust the following options: +After configuration, open **Configure** on the integration to adjust: -- **Scan Interval**: Polling interval in seconds (5-60, default: 10). Used as a fallback when WebSocket connection is unavailable. +- **Scan Interval** (5–60 s, default `10`). Used as the polling cadence while + the WebSocket is unavailable. When the WebSocket is connected, the + integration drops to a 5-minute REST safety-net poll automatically. ## Supported Features -| Feature | Support | -|---------|---------| -| Play/Pause | Yes | -| Stop | Yes | -| Volume Control | Yes | -| Volume Mute | Yes | -| Seek | Yes | -| Next Track | Yes | -| Previous Track | Yes | -| Media Browser | Yes | -| Play Media | Yes | +| Feature | Support | +| ------------------- | ------- | +| Play / Pause | Yes | +| Stop | Yes | +| Volume Control | Yes | +| Volume Mute | Yes | +| Seek | Yes | +| Next Track | Yes | +| Previous Track | Yes | +| Repeat Mode | Yes | +| Media Browser | Yes | +| Play Media | Yes | +| Enqueue / Play Next | Yes | ## Entity Attributes Each media player entity exposes the following attributes: -- `session_id`: Emby session identifier -- `device_id`: Device identifier -- `device_name`: Name of the playback device -- `client_name`: Emby client application name -- `user_name`: Emby user name -- `item_id`: Currently playing item ID -- `item_type`: Type of media (Movie, Episode, Audio, etc.) +| Attribute | Description | +| -------------- | -------------------------------------------------------------------------- | +| `session_id` | Emby session identifier | +| `device_id` | Device identifier | +| `device_name` | Name of the playback device | +| `client_name` | Emby client application name | +| `user_name` | Emby user currently signed in on the client (when available) | +| `item_id` | Currently playing item ID (only while playing) | +| `item_type` | Type of media (`Movie`, `Episode`, `Audio`, …) | +| `play_method` | `DirectPlay`, `DirectStream`, or `Transcode` (only while playing) | + +## Services + +### `emby_player.send_message` + +Display a banner notification on an Emby client. Great for doorbells, alarms, +laundry timers, etc. + +| Field | Required | Description | +| ------------ | -------- | -------------------------------------------- | +| `entity_id` | Yes | One or more Emby media-player entities. | +| `message` | Yes | Text to display. | +| `header` | No | Optional header / title. | +| `timeout_ms` | No | How long to show the message (100–60000 ms). | + +```yaml +service: emby_player.send_message +target: + entity_id: media_player.emby_living_room_tv +data: + header: "Doorbell" + message: "Someone's at the front door" + timeout_ms: 5000 +``` + +### `emby_player.set_repeat` + +Set the repeat mode on the current playback. + +| Field | Required | Description | +| ------------- | -------- | -------------------------------------------------------- | +| `entity_id` | Yes | One or more Emby media-player entities. | +| `repeat_mode` | Yes | One of `RepeatNone`, `RepeatOne`, or `RepeatAll`. | + +```yaml +service: emby_player.set_repeat +target: + entity_id: media_player.emby_living_room_tv +data: + repeat_mode: RepeatAll +``` + +> The standard HA media-player repeat control is also wired up — `media_player.repeat_set` will work without using this service. + +### `emby_player.refresh_library` + +Trigger a server-side library scan. If `entity_id` is omitted, all configured +Emby servers are refreshed. + +```yaml +service: emby_player.refresh_library +``` + +## Diagnostics + +Open **Settings → Integrations → Emby Media Player → "..." → Download diagnostics** to get a redacted JSON dump containing the entry, the current sessions, server id, and WebSocket connection status. Useful when filing bug reports. + +The dump redacts the API key and replaces real session / device / user IDs with stable hashes so it's safe to share. ## Automation Examples -### Dim lights when playing a movie +### Dim lights when a movie starts ```yaml automation: @@ -110,7 +204,7 @@ automation: brightness_pct: 20 ``` -### Pause playback when doorbell rings +### Pause playback when the doorbell rings, then notify the screen ```yaml automation: @@ -123,32 +217,62 @@ automation: - service: media_player.media_pause target: entity_id: media_player.emby_living_room_tv + - service: emby_player.send_message + target: + entity_id: media_player.emby_living_room_tv + data: + header: "Doorbell" + message: "Someone is at the door" + timeout_ms: 8000 +``` + +### Toggle shuffle/repeat from a script + +```yaml +script: + emby_repeat_all: + sequence: + - service: media_player.repeat_set + target: + entity_id: media_player.emby_living_room_tv + data: + repeat: all ``` ## Troubleshooting -### Connection Issues +### Connection issues -- Verify the Emby server is accessible from Home Assistant -- Check that the API key is valid and has appropriate permissions -- Ensure the port is correct (default is 8096) +- Verify the Emby server is reachable from the Home Assistant host (try `ping` / `curl http://: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 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 18ea316..6d695e5 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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. + +--- + +
+Files Changed + +| 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 | + +
+ +--- + ## 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)) --- diff --git a/custom_components/emby_player/__init__.py b/custom_components/emby_player/__init__.py index 4ce106e..0ea3cfc 100644 --- a/custom_components/emby_player/__init__.py +++ b/custom_components/emby_player/__init__.py @@ -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 diff --git a/custom_components/emby_player/api.py b/custom_components/emby_player/api.py index bb0adc2..08a9315 100644 --- a/custom_components/emby_player/api.py +++ b/custom_components/emby_player/api.py @@ -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 "") + 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 diff --git a/custom_components/emby_player/browse_media.py b/custom_components/emby_player/browse_media.py index 4d81094..f6cfcdb 100644 --- a/custom_components/emby_player/browse_media.py +++ b/custom_components/emby_player/browse_media.py @@ -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, ) diff --git a/custom_components/emby_player/config_flow.py b/custom_components/emby_player/config_flow.py index 72cb6f5..e037d44 100644 --- a/custom_components/emby_player/config_flow.py +++ b/custom_components/emby_player/config_flow.py @@ -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( diff --git a/custom_components/emby_player/const.py b/custom_components/emby_player/const.py index 4f04812..7e71a8a 100644 --- a/custom_components/emby_player/const.py +++ b/custom_components/emby_player/const.py @@ -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 diff --git a/custom_components/emby_player/coordinator.py b/custom_components/emby_player/coordinator.py index 6b190ad..60f93df 100644 --- a/custom_components/emby_player/coordinator.py +++ b/custom_components/emby_player/coordinator.py @@ -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() diff --git a/custom_components/emby_player/diagnostics.py b/custom_components/emby_player/diagnostics.py new file mode 100644 index 0000000..8ae8aac --- /dev/null +++ b/custom_components/emby_player/diagnostics.py @@ -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, + } diff --git a/custom_components/emby_player/manifest.json b/custom_components/emby_player/manifest.json index 6c12807..7e74dbd 100644 --- a/custom_components/emby_player/manifest.json +++ b/custom_components/emby_player/manifest.json @@ -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." + } + ] } diff --git a/custom_components/emby_player/media_player.py b/custom_components/emby_player/media_player.py index 8907e5b..b623c6a 100644 --- a/custom_components/emby_player/media_player.py +++ b/custom_components/emby_player/media_player.py @@ -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, diff --git a/custom_components/emby_player/services.py b/custom_components/emby_player/services.py new file mode 100644 index 0000000..2f9a5cf --- /dev/null +++ b/custom_components/emby_player/services.py @@ -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) diff --git a/custom_components/emby_player/services.yaml b/custom_components/emby_player/services.yaml new file mode 100644 index 0000000..a07c40a --- /dev/null +++ b/custom_components/emby_player/services.yaml @@ -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 diff --git a/custom_components/emby_player/strings.json b/custom_components/emby_player/strings.json index 8959d67..979dd93 100644 --- a/custom_components/emby_player/strings.json +++ b/custom_components/emby_player/strings.json @@ -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." + } } } diff --git a/custom_components/emby_player/translations/en.json b/custom_components/emby_player/translations/en.json index 8959d67..979dd93 100644 --- a/custom_components/emby_player/translations/en.json +++ b/custom_components/emby_player/translations/en.json @@ -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." + } } } diff --git a/custom_components/emby_player/websocket.py b/custom_components/emby_player/websocket.py index bc098f3..47b4cce 100644 --- a/custom_components/emby_player/websocket.py +++ b/custom_components/emby_player/websocket.py @@ -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") diff --git a/hacs.json b/hacs.json index e78bf41..6390385 100644 --- a/hacs.json +++ b/hacs.json @@ -1,6 +1,6 @@ { "name": "Emby Media Player", - "homeassistant": "2024.1.0", + "homeassistant": "2024.10.0", "render_readme": true, "content_in_root": false }