Production-readiness pass: security hardening, performance improvements, new services (send_message, set_repeat, refresh_library), diagnostics, reauth flow, image proxy, per-instance device IDs, exponential WS reconnect backoff, ID validation, stale device cleanup, and supporting integration plumbing. Three rounds of independent code review applied. See RELEASE_NOTES.md for the full changelog.
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user