diff --git a/.coveragerc b/.coveragerc index cd2fe222b49c0c6e4fb0ee780ac6007ae739104c..abba56ebfea40d91ec132b0c4da1307c49eed792 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1004,6 +1004,7 @@ omit = homeassistant/components/x10/light.py homeassistant/components/xbox/__init__.py homeassistant/components/xbox/api.py + homeassistant/components/xbox/browse_media.py homeassistant/components/xbox/media_player.py homeassistant/components/xbox_live/sensor.py homeassistant/components/xeoma/camera.py diff --git a/homeassistant/components/xbox/browse_media.py b/homeassistant/components/xbox/browse_media.py new file mode 100644 index 0000000000000000000000000000000000000000..a91713931c22436e376f2eccb058827a714e74fd --- /dev/null +++ b/homeassistant/components/xbox/browse_media.py @@ -0,0 +1,178 @@ +"""Support for media browsing.""" +from typing import Dict, List, Optional + +from xbox.webapi.api.client import XboxLiveClient +from xbox.webapi.api.provider.catalog.const import HOME_APP_IDS, SYSTEM_PFN_ID_MAP +from xbox.webapi.api.provider.catalog.models import ( + AlternateIdType, + CatalogResponse, + FieldsTemplate, + Image, +) +from xbox.webapi.api.provider.smartglass.models import ( + InstalledPackage, + InstalledPackagesList, +) + +from homeassistant.components.media_player import BrowseMedia +from homeassistant.components.media_player.const import ( + MEDIA_CLASS_APP, + MEDIA_CLASS_DIRECTORY, + MEDIA_CLASS_GAME, + MEDIA_TYPE_APP, + MEDIA_TYPE_GAME, +) + +TYPE_MAP = { + "App": { + "type": MEDIA_TYPE_APP, + "class": MEDIA_CLASS_APP, + }, + "Game": { + "type": MEDIA_TYPE_GAME, + "class": MEDIA_CLASS_GAME, + }, +} + + +async def build_item_response( + client: XboxLiveClient, + device_id: str, + tv_configured: bool, + media_content_type: str, + media_content_id: str, +) -> Optional[BrowseMedia]: + """Create response payload for the provided media query.""" + apps: InstalledPackagesList = await client.smartglass.get_installed_apps(device_id) + + if media_content_type in [None, "library"]: + library_info = BrowseMedia( + media_class=MEDIA_CLASS_DIRECTORY, + media_content_id="library", + media_content_type="library", + title="Installed Applications", + can_play=False, + can_expand=True, + children=[], + ) + + # Add Home + id_type = AlternateIdType.LEGACY_XBOX_PRODUCT_ID + home_catalog: CatalogResponse = ( + await client.catalog.get_product_from_alternate_id( + HOME_APP_IDS[id_type], id_type + ) + ) + home_thumb = _find_media_image( + home_catalog.products[0].localized_properties[0].images + ) + library_info.children.append( + BrowseMedia( + media_class=MEDIA_CLASS_APP, + media_content_id="Home", + media_content_type=MEDIA_TYPE_APP, + title="Home", + can_play=True, + can_expand=False, + thumbnail=home_thumb.uri, + ) + ) + + # Add TV if configured + if tv_configured: + tv_catalog: CatalogResponse = ( + await client.catalog.get_product_from_alternate_id( + SYSTEM_PFN_ID_MAP["Microsoft.Xbox.LiveTV_8wekyb3d8bbwe"][id_type], + id_type, + ) + ) + tv_thumb = _find_media_image( + tv_catalog.products[0].localized_properties[0].images + ) + library_info.children.append( + BrowseMedia( + media_class=MEDIA_CLASS_APP, + media_content_id="TV", + media_content_type=MEDIA_TYPE_APP, + title="Live TV", + can_play=True, + can_expand=False, + thumbnail=tv_thumb.uri, + ) + ) + + content_types = sorted( + {app.content_type for app in apps.result if app.content_type in TYPE_MAP} + ) + for c_type in content_types: + library_info.children.append( + BrowseMedia( + media_class=MEDIA_CLASS_DIRECTORY, + media_content_id=c_type, + media_content_type=TYPE_MAP[c_type]["type"], + title=f"{c_type}s", + can_play=False, + can_expand=True, + children_media_class=TYPE_MAP[c_type]["class"], + ) + ) + + return library_info + + app_details = await client.catalog.get_products( + [ + app.one_store_product_id + for app in apps.result + if app.content_type == media_content_id and app.one_store_product_id + ], + FieldsTemplate.BROWSE, + ) + + images = { + prod.product_id: prod.localized_properties[0].images + for prod in app_details.products + } + + return BrowseMedia( + media_class=MEDIA_CLASS_DIRECTORY, + media_content_id=media_content_id, + media_content_type=media_content_type, + title=f"{media_content_id}s", + can_play=False, + can_expand=True, + children=[ + item_payload(app, images) + for app in apps.result + if app.content_type == media_content_id and app.one_store_product_id + ], + children_media_class=TYPE_MAP[media_content_id]["class"], + ) + + +def item_payload(item: InstalledPackage, images: Dict[str, List[Image]]): + """Create response payload for a single media item.""" + thumbnail = None + image = _find_media_image(images.get(item.one_store_product_id, [])) + if image is not None: + thumbnail = image.uri + if thumbnail[0] == "/": + thumbnail = f"https:{thumbnail}" + + return BrowseMedia( + media_class=TYPE_MAP[item.content_type]["class"], + media_content_id=item.one_store_product_id, + media_content_type=TYPE_MAP[item.content_type]["type"], + title=item.name, + can_play=True, + can_expand=False, + thumbnail=thumbnail, + ) + + +def _find_media_image(images=List[Image]) -> Optional[Image]: + purpose_order = ["Poster", "Tile", "Logo", "BoxArt"] + for purpose in purpose_order: + for image in images: + if image.image_purpose == purpose and image.width >= 300: + return image + return None diff --git a/homeassistant/components/xbox/media_player.py b/homeassistant/components/xbox/media_player.py index 465b57589e7520ff26d9dec13b8ca7f6cff08f0e..e1ca2c3e931a72bb8b747e2700c13e0d8b75a847 100644 --- a/homeassistant/components/xbox/media_player.py +++ b/homeassistant/components/xbox/media_player.py @@ -19,9 +19,11 @@ from homeassistant.components.media_player import MediaPlayerEntity from homeassistant.components.media_player.const import ( MEDIA_TYPE_APP, MEDIA_TYPE_GAME, + SUPPORT_BROWSE_MEDIA, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, + SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, @@ -30,6 +32,7 @@ from homeassistant.components.media_player.const import ( ) from homeassistant.const import STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING +from .browse_media import build_item_response from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -43,6 +46,8 @@ SUPPORT_XBOX = ( | SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_MUTE + | SUPPORT_BROWSE_MEDIA + | SUPPORT_PLAY_MEDIA ) XBOX_STATE_MAP = { @@ -60,6 +65,11 @@ async def async_setup_entry(hass, entry, async_add_entities): """Set up Xbox media_player from a config entry.""" client: XboxLiveClient = hass.data[DOMAIN][entry.entry_id] consoles: SmartglassConsoleList = await client.smartglass.get_console_list() + _LOGGER.debug( + "Found %d consoles: %s", + len(consoles.result), + consoles.dict(), + ) async_add_entities( [XboxMediaPlayer(client, console) for console in consoles.result], True ) @@ -146,6 +156,12 @@ class XboxMediaPlayer(MediaPlayerEntity): await self.client.smartglass.get_console_status(self._console.id) ) + _LOGGER.debug( + "%s status: %s", + self._console.name, + status.dict(), + ) + if status.focus_app_aumid: if ( not self._console_status @@ -216,6 +232,25 @@ class XboxMediaPlayer(MediaPlayerEntity): """Send next track command.""" await self.client.smartglass.next(self._console.id) + async def async_browse_media(self, media_content_type=None, media_content_id=None): + """Implement the websocket media browsing helper.""" + return await build_item_response( + self.client, + self._console.id, + self._console_status.is_tv_configured, + media_content_type, + media_content_id, + ) + + async def async_play_media(self, media_type, media_id, **kwargs): + """Launch an app on the Xbox.""" + if media_id == "Home": + await self.client.smartglass.go_home(self._console.id) + elif media_id == "TV": + await self.client.smartglass.show_tv_guide(self._console.id) + else: + await self.client.smartglass.launch_app(self._console.id, media_id) + @property def device_info(self): """Return a device description for device registry."""