From c01bb44757f312123d5fabac75bb8a7ed826cb2a Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 30 Aug 2024 07:27:19 -0700 Subject: [PATCH] Add Google Photos integration (#124835) * Add Google Photos integration * Mark credentials typing * Add code review suggestions to simpilfy google_photos * Update tests/components/google_photos/conftest.py Co-authored-by: Joost Lekkerkerker * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker * Fix comment typo * Update test fixtures from review feedback * Remove unnecessary test for services * Remove keyword argument --------- Co-authored-by: Joost Lekkerkerker --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/brands/google.json | 1 + .../components/google_photos/__init__.py | 45 +++ homeassistant/components/google_photos/api.py | 143 +++++++++ .../google_photos/application_credentials.py | 23 ++ .../components/google_photos/config_flow.py | 54 ++++ .../components/google_photos/const.py | 10 + .../components/google_photos/exceptions.py | 7 + .../components/google_photos/manifest.json | 10 + .../components/google_photos/media_source.py | 283 ++++++++++++++++++ .../components/google_photos/strings.json | 29 ++ .../generated/application_credentials.py | 1 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 1 + requirements_test_all.txt | 1 + tests/components/google_photos/__init__.py | 1 + tests/components/google_photos/conftest.py | 121 ++++++++ .../fixtures/api_not_enabled_response.json | 17 ++ .../fixtures/list_mediaitems.json | 35 +++ .../fixtures/list_mediaitems_empty.json | 5 + .../google_photos/fixtures/not_dict.json | 1 + .../google_photos/test_config_flow.py | 205 +++++++++++++ tests/components/google_photos/test_init.py | 109 +++++++ .../google_photos/test_media_source.py | 199 ++++++++++++ 27 files changed, 1321 insertions(+) create mode 100644 homeassistant/components/google_photos/__init__.py create mode 100644 homeassistant/components/google_photos/api.py create mode 100644 homeassistant/components/google_photos/application_credentials.py create mode 100644 homeassistant/components/google_photos/config_flow.py create mode 100644 homeassistant/components/google_photos/const.py create mode 100644 homeassistant/components/google_photos/exceptions.py create mode 100644 homeassistant/components/google_photos/manifest.json create mode 100644 homeassistant/components/google_photos/media_source.py create mode 100644 homeassistant/components/google_photos/strings.json create mode 100644 tests/components/google_photos/__init__.py create mode 100644 tests/components/google_photos/conftest.py create mode 100644 tests/components/google_photos/fixtures/api_not_enabled_response.json create mode 100644 tests/components/google_photos/fixtures/list_mediaitems.json create mode 100644 tests/components/google_photos/fixtures/list_mediaitems_empty.json create mode 100644 tests/components/google_photos/fixtures/not_dict.json create mode 100644 tests/components/google_photos/test_config_flow.py create mode 100644 tests/components/google_photos/test_init.py create mode 100644 tests/components/google_photos/test_media_source.py diff --git a/.strict-typing b/.strict-typing index a65ccf3ec88..d77c12293c4 100644 --- a/.strict-typing +++ b/.strict-typing @@ -209,6 +209,7 @@ homeassistant.components.glances.* homeassistant.components.goalzero.* homeassistant.components.google.* homeassistant.components.google_assistant_sdk.* +homeassistant.components.google_photos.* homeassistant.components.google_sheets.* homeassistant.components.gpsd.* homeassistant.components.greeneye_monitor.* diff --git a/CODEOWNERS b/CODEOWNERS index 0ebc49eda50..8ae6aa367b5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -554,6 +554,8 @@ build.json @home-assistant/supervisor /tests/components/google_generative_ai_conversation/ @tronikos /homeassistant/components/google_mail/ @tkdrob /tests/components/google_mail/ @tkdrob +/homeassistant/components/google_photos/ @allenporter +/tests/components/google_photos/ @allenporter /homeassistant/components/google_sheets/ @tkdrob /tests/components/google_sheets/ @tkdrob /homeassistant/components/google_tasks/ @allenporter diff --git a/homeassistant/brands/google.json b/homeassistant/brands/google.json index 7c6ebc044e9..460c92076d8 100644 --- a/homeassistant/brands/google.json +++ b/homeassistant/brands/google.json @@ -9,6 +9,7 @@ "google_generative_ai_conversation", "google_mail", "google_maps", + "google_photos", "google_pubsub", "google_sheets", "google_tasks", diff --git a/homeassistant/components/google_photos/__init__.py b/homeassistant/components/google_photos/__init__.py new file mode 100644 index 00000000000..ab1ee4a63a4 --- /dev/null +++ b/homeassistant/components/google_photos/__init__.py @@ -0,0 +1,45 @@ +"""The Google Photos integration.""" + +from __future__ import annotations + +from aiohttp import ClientError, ClientResponseError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_entry_oauth2_flow + +from . import api +from .const import DOMAIN + +type GooglePhotosConfigEntry = ConfigEntry[api.AsyncConfigEntryAuth] + +__all__ = [ + "DOMAIN", +] + + +async def async_setup_entry( + hass: HomeAssistant, entry: GooglePhotosConfigEntry +) -> bool: + """Set up Google Photos from a config entry.""" + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + ) + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + auth = api.AsyncConfigEntryAuth(hass, session) + try: + await auth.async_get_access_token() + except (ClientResponseError, ClientError) as err: + raise ConfigEntryNotReady from err + entry.runtime_data = auth + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: GooglePhotosConfigEntry +) -> bool: + """Unload a config entry.""" + return True diff --git a/homeassistant/components/google_photos/api.py b/homeassistant/components/google_photos/api.py new file mode 100644 index 00000000000..2fa6ee2d8f6 --- /dev/null +++ b/homeassistant/components/google_photos/api.py @@ -0,0 +1,143 @@ +"""API for Google Photos bound to Home Assistant OAuth.""" + +from abc import ABC, abstractmethod +from functools import partial +import logging +from typing import Any, cast + +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import Resource, build +from googleapiclient.errors import HttpError +from googleapiclient.http import BatchHttpRequest, HttpRequest + +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow + +from .exceptions import GooglePhotosApiError + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_PAGE_SIZE = 20 + +# Only included necessary fields to limit response sizes +GET_MEDIA_ITEM_FIELDS = ( + "id,baseUrl,mimeType,filename,mediaMetadata(width,height,photo,video)" +) +LIST_MEDIA_ITEM_FIELDS = f"nextPageToken,mediaItems({GET_MEDIA_ITEM_FIELDS})" + + +class AuthBase(ABC): + """Base class for Google Photos authentication library. + + Provides an asyncio interface around the blocking client library. + """ + + def __init__( + self, + hass: HomeAssistant, + ) -> None: + """Initialize Google Photos auth.""" + self._hass = hass + + @abstractmethod + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + + async def get_user_info(self) -> dict[str, Any]: + """Get the user profile info.""" + service = await self._get_profile_service() + cmd: HttpRequest = service.userinfo().get() + return await self._execute(cmd) + + async def get_media_item(self, media_item_id: str) -> dict[str, Any]: + """Get all MediaItem resources.""" + service = await self._get_photos_service() + cmd: HttpRequest = service.mediaItems().get( + media_item_id, fields=GET_MEDIA_ITEM_FIELDS + ) + return await self._execute(cmd) + + async def list_media_items( + self, page_size: int | None = None, page_token: str | None = None + ) -> dict[str, Any]: + """Get all MediaItem resources.""" + service = await self._get_photos_service() + cmd: HttpRequest = service.mediaItems().list( + pageSize=(page_size or DEFAULT_PAGE_SIZE), + pageToken=page_token, + fields=LIST_MEDIA_ITEM_FIELDS, + ) + return await self._execute(cmd) + + async def _get_photos_service(self) -> Resource: + """Get current photos library API resource.""" + token = await self.async_get_access_token() + return await self._hass.async_add_executor_job( + partial( + build, + "photoslibrary", + "v1", + credentials=Credentials(token=token), # type: ignore[no-untyped-call] + static_discovery=False, + ) + ) + + async def _get_profile_service(self) -> Resource: + """Get current profile service API resource.""" + token = await self.async_get_access_token() + return await self._hass.async_add_executor_job( + partial(build, "oauth2", "v2", credentials=Credentials(token=token)) # type: ignore[no-untyped-call] + ) + + async def _execute(self, request: HttpRequest | BatchHttpRequest) -> dict[str, Any]: + try: + result = await self._hass.async_add_executor_job(request.execute) + except HttpError as err: + raise GooglePhotosApiError( + f"Google Photos API responded with error ({err.status_code}): {err.reason}" + ) from err + if not isinstance(result, dict): + raise GooglePhotosApiError( + f"Google Photos API replied with unexpected response: {result}" + ) + if error := result.get("error"): + message = error.get("message", "Unknown Error") + raise GooglePhotosApiError(f"Google Photos API response: {message}") + return cast(dict[str, Any], result) + + +class AsyncConfigEntryAuth(AuthBase): + """Provide Google Photos authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + hass: HomeAssistant, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize AsyncConfigEntryAuth.""" + super().__init__(hass) + self._oauth_session = oauth_session + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + if not self._oauth_session.valid_token: + await self._oauth_session.async_ensure_token_valid() + return cast(str, self._oauth_session.token[CONF_ACCESS_TOKEN]) + + +class AsyncConfigFlowAuth(AuthBase): + """An API client used during the config flow with a fixed token.""" + + def __init__( + self, + hass: HomeAssistant, + token: str, + ) -> None: + """Initialize ConfigFlowAuth.""" + super().__init__(hass) + self._token = token + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + return self._token diff --git a/homeassistant/components/google_photos/application_credentials.py b/homeassistant/components/google_photos/application_credentials.py new file mode 100644 index 00000000000..fc6cdbd272d --- /dev/null +++ b/homeassistant/components/google_photos/application_credentials.py @@ -0,0 +1,23 @@ +"""application_credentials platform the Google Photos integration.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) + + +async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + return { + "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", + "more_info_url": "https://www.home-assistant.io/integrations/google_photos/", + "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", + } diff --git a/homeassistant/components/google_photos/config_flow.py b/homeassistant/components/google_photos/config_flow.py new file mode 100644 index 00000000000..9bc4b35b6b4 --- /dev/null +++ b/homeassistant/components/google_photos/config_flow.py @@ -0,0 +1,54 @@ +"""Config flow for Google Photos.""" + +import logging +from typing import Any + +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.helpers import config_entry_oauth2_flow + +from . import api +from .const import DOMAIN, OAUTH2_SCOPES +from .exceptions import GooglePhotosApiError + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Google Photos OAuth2 authentication.""" + + DOMAIN = DOMAIN + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return { + "scope": " ".join(OAUTH2_SCOPES), + # Add params to ensure we get back a refresh token + "access_type": "offline", + "prompt": "consent", + } + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + """Create an entry for the flow.""" + client = api.AsyncConfigFlowAuth(self.hass, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) + try: + user_resource_info = await client.get_user_info() + await client.list_media_items() + except GooglePhotosApiError as ex: + return self.async_abort( + reason="access_not_configured", + description_placeholders={"message": str(ex)}, + ) + except Exception: + self.logger.exception("Unknown error occurred") + return self.async_abort(reason="unknown") + user_id = user_resource_info["id"] + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=user_resource_info["name"], data=data) diff --git a/homeassistant/components/google_photos/const.py b/homeassistant/components/google_photos/const.py new file mode 100644 index 00000000000..7752f817608 --- /dev/null +++ b/homeassistant/components/google_photos/const.py @@ -0,0 +1,10 @@ +"""Constants for the Google Photos integration.""" + +DOMAIN = "google_photos" + +OAUTH2_AUTHORIZE = "https://accounts.google.com/o/oauth2/v2/auth" +OAUTH2_TOKEN = "https://oauth2.googleapis.com/token" +OAUTH2_SCOPES = [ + "https://www.googleapis.com/auth/photoslibrary.readonly", + "https://www.googleapis.com/auth/userinfo.profile", +] diff --git a/homeassistant/components/google_photos/exceptions.py b/homeassistant/components/google_photos/exceptions.py new file mode 100644 index 00000000000..b1a40688677 --- /dev/null +++ b/homeassistant/components/google_photos/exceptions.py @@ -0,0 +1,7 @@ +"""Exceptions for Google Photos api calls.""" + +from homeassistant.exceptions import HomeAssistantError + + +class GooglePhotosApiError(HomeAssistantError): + """Error talking to the Google Photos API.""" diff --git a/homeassistant/components/google_photos/manifest.json b/homeassistant/components/google_photos/manifest.json new file mode 100644 index 00000000000..3299b437d29 --- /dev/null +++ b/homeassistant/components/google_photos/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "google_photos", + "name": "Google Photos", + "codeowners": ["@allenporter"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/google_photos", + "iot_class": "cloud_polling", + "requirements": ["google-api-python-client==2.71.0"] +} diff --git a/homeassistant/components/google_photos/media_source.py b/homeassistant/components/google_photos/media_source.py new file mode 100644 index 00000000000..e6011cb0e61 --- /dev/null +++ b/homeassistant/components/google_photos/media_source.py @@ -0,0 +1,283 @@ +"""Media source for Google Photos.""" + +from dataclasses import dataclass +import logging +from typing import Any, cast + +from homeassistant.components.media_player import MediaClass, MediaType +from homeassistant.components.media_source import ( + BrowseError, + BrowseMediaSource, + MediaSource, + MediaSourceItem, + PlayMedia, +) +from homeassistant.core import HomeAssistant + +from . import GooglePhotosConfigEntry +from .const import DOMAIN +from .exceptions import GooglePhotosApiError + +_LOGGER = logging.getLogger(__name__) + +# Media Sources do not support paging, so we only show a subset of recent +# photos when displaying the users library. We fetch a minimum of 50 photos +# unless we run out, but in pages of 100 at a time given sometimes responses +# may only contain a handful of items Fetches at least 50 photos. +MAX_PHOTOS = 50 +PAGE_SIZE = 100 + +THUMBNAIL_SIZE = 256 +LARGE_IMAGE_SIZE = 2048 + + +# Markers for parts of PhotosIdentifier url pattern. +# The PhotosIdentifier can be in the following forms: +# config-entry-id +# config-entry-id/a/album-media-id +# config-entry-id/p/photo-media-id +# +# The album-media-id can contain special reserved folder names for use by +# this integration for virtual folders like the `recent` album. +PHOTO_SOURCE_IDENTIFIER_PHOTO = "p" +PHOTO_SOURCE_IDENTIFIER_ALBUM = "a" + +# Currently supports a single album of recent photos +RECENT_PHOTOS_ALBUM = "recent" +RECENT_PHOTOS_TITLE = "Recent Photos" + + +@dataclass +class PhotosIdentifier: + """Google Photos item identifier in a media source URL.""" + + config_entry_id: str + """Identifies the account for the media item.""" + + album_media_id: str | None = None + """Identifies the album contents to show. + + Not present at the same time as `photo_media_id`. + """ + + photo_media_id: str | None = None + """Identifies an indiviidual photo or video. + + Not present at the same time as `album_media_id`. + """ + + def as_string(self) -> str: + """Serialize the identiifer as a string. + + This is the opposite if parse_identifier(). + """ + if self.photo_media_id is None: + if self.album_media_id is None: + return self.config_entry_id + return f"{self.config_entry_id}/{PHOTO_SOURCE_IDENTIFIER_ALBUM}/{self.album_media_id}" + return f"{self.config_entry_id}/{PHOTO_SOURCE_IDENTIFIER_PHOTO}/{self.photo_media_id}" + + +def parse_identifier(identifier: str) -> PhotosIdentifier: + """Parse a PhotosIdentifier form a string. + + This is the opposite of as_string(). + """ + parts = identifier.split("/") + if len(parts) == 1: + return PhotosIdentifier(parts[0]) + if len(parts) != 3: + raise BrowseError(f"Invalid identifier: {identifier}") + if parts[1] == PHOTO_SOURCE_IDENTIFIER_PHOTO: + return PhotosIdentifier(parts[0], photo_media_id=parts[2]) + return PhotosIdentifier(parts[0], album_media_id=parts[2]) + + +async def async_get_media_source(hass: HomeAssistant) -> MediaSource: + """Set up Synology media source.""" + return GooglePhotosMediaSource(hass) + + +class GooglePhotosMediaSource(MediaSource): + """Provide Google Photos as media sources.""" + + name = "Google Photos" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize Google Photos source.""" + super().__init__(DOMAIN) + self.hass = hass + + async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: + """Resolve media identifier to a url. + + This will resolve a specific media item to a url for the full photo or video contents. + """ + identifier = parse_identifier(item.identifier) + if identifier.photo_media_id is None: + raise BrowseError( + f"Could not resolve identifier without a photo_media_id: {identifier}" + ) + entry = self._async_config_entry(identifier.config_entry_id) + client = entry.runtime_data + media_item = await client.get_media_item( + media_item_id=identifier.photo_media_id + ) + is_video = media_item["mediaMetadata"].get("video") is not None + return PlayMedia( + url=( + _video_url(media_item) + if is_video + else _media_url(media_item, LARGE_IMAGE_SIZE) + ), + mime_type=media_item["mimeType"], + ) + + async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource: + """Return details about the media source. + + This renders the multi-level album structure for an account, its albums, + or the contents of an album. This will return a BrowseMediaSource with a + single level of children at the next level of the hierarchy. + """ + if not item.identifier: + # Top level view that lists all accounts. + return BrowseMediaSource( + domain=DOMAIN, + identifier=None, + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title="Google Photos", + can_play=False, + can_expand=True, + children_media_class=MediaClass.DIRECTORY, + children=[ + _build_account(entry, PhotosIdentifier(cast(str, entry.unique_id))) + for entry in self.hass.config_entries.async_loaded_entries(DOMAIN) + ], + ) + + # Determine the configuration entry for this item + identifier = parse_identifier(item.identifier) + entry = self._async_config_entry(identifier.config_entry_id) + client = entry.runtime_data + + if identifier.album_media_id is None: + source = _build_account(entry, identifier) + source.children = [ + _build_album( + RECENT_PHOTOS_TITLE, + PhotosIdentifier( + identifier.config_entry_id, album_media_id=RECENT_PHOTOS_ALBUM + ), + ) + ] + return source + + # Currently only supports listing a single album of recent photos. + if identifier.album_media_id != RECENT_PHOTOS_ALBUM: + raise BrowseError(f"Unsupported album: {identifier}") + + # Fetch recent items + media_items: list[dict[str, Any]] = [] + page_token: str | None = None + while len(media_items) < MAX_PHOTOS: + try: + result = await client.list_media_items( + page_size=PAGE_SIZE, page_token=page_token + ) + except GooglePhotosApiError as err: + raise BrowseError(f"Error listing media items: {err}") from err + media_items.extend(result["mediaItems"]) + page_token = result.get("nextPageToken") + if page_token is None: + break + + # Render the grid of media item results + source = _build_account(entry, PhotosIdentifier(cast(str, entry.unique_id))) + source.children = [ + _build_media_item( + PhotosIdentifier( + identifier.config_entry_id, photo_media_id=media_item["id"] + ), + media_item, + ) + for media_item in media_items + ] + return source + + def _async_config_entry(self, config_entry_id: str) -> GooglePhotosConfigEntry: + """Return a config entry with the specified id.""" + entry = self.hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, config_entry_id + ) + if not entry: + raise BrowseError( + f"Could not find config entry for identifier: {config_entry_id}" + ) + return entry + + +def _build_account( + config_entry: GooglePhotosConfigEntry, + identifier: PhotosIdentifier, +) -> BrowseMediaSource: + """Build the root node for a Google Photos account for a config entry.""" + return BrowseMediaSource( + domain=DOMAIN, + identifier=identifier.as_string(), + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title=config_entry.title, + can_play=False, + can_expand=True, + ) + + +def _build_album(title: str, identifier: PhotosIdentifier) -> BrowseMediaSource: + """Build an album node.""" + return BrowseMediaSource( + domain=DOMAIN, + identifier=identifier.as_string(), + media_class=MediaClass.ALBUM, + media_content_type=MediaClass.ALBUM, + title=title, + can_play=False, + can_expand=True, + ) + + +def _build_media_item( + identifier: PhotosIdentifier, media_item: dict[str, Any] +) -> BrowseMediaSource: + """Build the node for an individual photos or video.""" + is_video = media_item["mediaMetadata"].get("video") is not None + return BrowseMediaSource( + domain=DOMAIN, + identifier=identifier.as_string(), + media_class=MediaClass.IMAGE if not is_video else MediaClass.VIDEO, + media_content_type=MediaType.IMAGE if not is_video else MediaType.VIDEO, + title=media_item["filename"], + can_play=is_video, + can_expand=False, + thumbnail=_media_url(media_item, THUMBNAIL_SIZE), + ) + + +def _media_url(media_item: dict[str, Any], max_size: int) -> str: + """Return a media item url with the specified max thumbnail size on the longest edge. + + See https://developers.google.com/photos/library/guides/access-media-items#base-urls + """ + width = media_item["mediaMetadata"]["width"] + height = media_item["mediaMetadata"]["height"] + key = "h" if height > width else "w" + return f"{media_item["baseUrl"]}={key}{max_size}" + + +def _video_url(media_item: dict[str, Any]) -> str: + """Return a video url for the item. + + See https://developers.google.com/photos/library/guides/access-media-items#base-urls + """ + return f"{media_item["baseUrl"]}=dv" diff --git a/homeassistant/components/google_photos/strings.json b/homeassistant/components/google_photos/strings.json new file mode 100644 index 00000000000..57bce01d9f8 --- /dev/null +++ b/homeassistant/components/google_photos/strings.json @@ -0,0 +1,29 @@ +{ + "application_credentials": { + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Photos. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n\n" + }, + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "access_not_configured": "Unable to access the Google API:\n\n{message}", + "unknown": "[%key:common::config_flow::error::unknown%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + } +} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 75fd489bad3..efb6f426d36 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -10,6 +10,7 @@ APPLICATION_CREDENTIALS = [ "google", "google_assistant_sdk", "google_mail", + "google_photos", "google_sheets", "google_tasks", "home_connect", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1756a896d25..d4342d80d41 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -224,6 +224,7 @@ FLOWS = { "google_assistant_sdk", "google_generative_ai_conversation", "google_mail", + "google_photos", "google_sheets", "google_tasks", "google_translate", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index d7cfe503dd9..8091d48ca4d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2280,6 +2280,12 @@ "iot_class": "cloud_polling", "name": "Google Maps" }, + "google_photos": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Google Photos" + }, "google_pubsub": { "integration_type": "hub", "config_flow": false, diff --git a/mypy.ini b/mypy.ini index 102ae5c8aa9..817060ac869 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1846,6 +1846,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.google_photos.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.google_sheets.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 8f89d72d9a0..18bdde48625 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -979,6 +979,7 @@ goalzero==0.2.2 goodwe==0.3.6 # homeassistant.components.google_mail +# homeassistant.components.google_photos # homeassistant.components.google_tasks google-api-python-client==2.71.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a1862a1340d..2a1e3e718eb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -829,6 +829,7 @@ goalzero==0.2.2 goodwe==0.3.6 # homeassistant.components.google_mail +# homeassistant.components.google_photos # homeassistant.components.google_tasks google-api-python-client==2.71.0 diff --git a/tests/components/google_photos/__init__.py b/tests/components/google_photos/__init__.py new file mode 100644 index 00000000000..fa345811216 --- /dev/null +++ b/tests/components/google_photos/__init__.py @@ -0,0 +1 @@ +"""Tests for the Google Photos integration.""" diff --git a/tests/components/google_photos/conftest.py b/tests/components/google_photos/conftest.py new file mode 100644 index 00000000000..874e55f0d33 --- /dev/null +++ b/tests/components/google_photos/conftest.py @@ -0,0 +1,121 @@ +"""Test fixtures for Google Photos.""" + +from collections.abc import Awaitable, Callable, Generator +import time +from typing import Any +from unittest.mock import Mock, patch + +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.google_photos.const import DOMAIN, OAUTH2_SCOPES +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_json_array_fixture + +USER_IDENTIFIER = "user-identifier-1" +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +FAKE_ACCESS_TOKEN = "some-access-token" +FAKE_REFRESH_TOKEN = "some-refresh-token" + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 + + +@pytest.fixture(name="token_entry") +def mock_token_entry(expires_at: int) -> dict[str, Any]: + """Fixture for OAuth 'token' data for a ConfigEntry.""" + return { + "access_token": FAKE_ACCESS_TOKEN, + "refresh_token": FAKE_REFRESH_TOKEN, + "scope": " ".join(OAUTH2_SCOPES), + "token_type": "Bearer", + "expires_at": expires_at, + } + + +@pytest.fixture(name="config_entry") +def mock_config_entry(token_entry: dict[str, Any]) -> MockConfigEntry: + """Fixture for a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="config-entry-id-123", + data={ + "auth_implementation": DOMAIN, + "token": token_entry, + }, + title="Account Name", + ) + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture(name="fixture_name") +def mock_fixture_name() -> str | None: + """Provide a json fixture file to load for list media item api responses.""" + return None + + +@pytest.fixture(name="setup_api") +def mock_setup_api(fixture_name: str) -> Generator[Mock, None, None]: + """Set up fake Google Photos API responses from fixtures.""" + with patch("homeassistant.components.google_photos.api.build") as mock: + mock.return_value.userinfo.return_value.get.return_value.execute.return_value = { + "id": USER_IDENTIFIER, + "name": "Test Name", + } + + responses = ( + load_json_array_fixture(fixture_name, DOMAIN) if fixture_name else [] + ) + + queue = list(responses) + + def list_media_items(**kwargs: Any) -> Mock: + mock = Mock() + mock.execute.return_value = queue.pop(0) + return mock + + mock.return_value.mediaItems.return_value.list = list_media_items + + # Mock a point lookup by reading contents of the fixture above + def get_media_item(media_item_id: str, **kwargs: Any) -> Mock: + for response in responses: + for media_item in response["mediaItems"]: + if media_item["id"] == media_item_id: + mock = Mock() + mock.execute.return_value = media_item + return mock + return None + + mock.return_value.mediaItems.return_value.get = get_media_item + yield mock + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> Callable[[], Awaitable[bool]]: + """Fixture to set up the integration.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/google_photos/fixtures/api_not_enabled_response.json b/tests/components/google_photos/fixtures/api_not_enabled_response.json new file mode 100644 index 00000000000..8933fcdc7bd --- /dev/null +++ b/tests/components/google_photos/fixtures/api_not_enabled_response.json @@ -0,0 +1,17 @@ +[ + { + "error": { + "code": 403, + "message": "Google Photos API has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/library/photoslibrary.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.", + "errors": [ + { + "message": "Google Photos API has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/library/photoslibrary.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.", + "domain": "usageLimits", + "reason": "accessNotConfigured", + "extendedHelp": "https://console.developers.google.com" + } + ], + "status": "PERMISSION_DENIED" + } + } +] diff --git a/tests/components/google_photos/fixtures/list_mediaitems.json b/tests/components/google_photos/fixtures/list_mediaitems.json new file mode 100644 index 00000000000..8e470a2fc04 --- /dev/null +++ b/tests/components/google_photos/fixtures/list_mediaitems.json @@ -0,0 +1,35 @@ +[ + { + "mediaItems": [ + { + "id": "id1", + "description": "some-descripton", + "productUrl": "http://example.com/id1", + "baseUrl": "http://img.example.com/id1", + "mimeType": "image/jpeg", + "mediaMetadata": { + "creationTime": "2014-10-02T15:01:23Z", + "width": 1600, + "height": 768 + }, + "filename": "example1.jpg" + }, + { + "id": "id2", + "description": "some-descripton", + "productUrl": "http://example.com/id2", + "baseUrl": "http://img.example.com/id2", + "mimeType": "video/mp4", + "mediaMetadata": { + "creationTime": "2014-10-02T16:01:23Z", + "width": 1600, + "height": 768, + "video": { + "cameraMake": "Pixel" + } + }, + "filename": "example2.mp4" + } + ] + } +] diff --git a/tests/components/google_photos/fixtures/list_mediaitems_empty.json b/tests/components/google_photos/fixtures/list_mediaitems_empty.json new file mode 100644 index 00000000000..bf6a4da855f --- /dev/null +++ b/tests/components/google_photos/fixtures/list_mediaitems_empty.json @@ -0,0 +1,5 @@ +[ + { + "mediaItems": [] + } +] diff --git a/tests/components/google_photos/fixtures/not_dict.json b/tests/components/google_photos/fixtures/not_dict.json new file mode 100644 index 00000000000..05e325337d2 --- /dev/null +++ b/tests/components/google_photos/fixtures/not_dict.json @@ -0,0 +1 @@ +["not a dictionary"] diff --git a/tests/components/google_photos/test_config_flow.py b/tests/components/google_photos/test_config_flow.py new file mode 100644 index 00000000000..e9f2a68f2f5 --- /dev/null +++ b/tests/components/google_photos/test_config_flow.py @@ -0,0 +1,205 @@ +"""Test the Google Photos config flow.""" + +from unittest.mock import Mock, patch + +from googleapiclient.errors import HttpError +from httplib2 import Response +import pytest + +from homeassistant import config_entries +from homeassistant.components.google_photos.const import ( + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow + +from .conftest import USER_IDENTIFIER + +from tests.common import load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + + +@pytest.mark.usefixtures("current_request_with_host", "setup_api") +@pytest.mark.parametrize("fixture_name", ["list_mediaitems.json"]) +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=https://www.googleapis.com/auth/photoslibrary.readonly" + "+https://www.googleapis.com/auth/userinfo.profile" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.google_photos.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.CREATE_ENTRY + config_entry = result["result"] + assert config_entry.unique_id == USER_IDENTIFIER + assert config_entry.title == "Test Name" + config_entry_data = dict(config_entry.data) + assert "token" in config_entry_data + assert "expires_at" in config_entry_data["token"] + del config_entry_data["token"]["expires_at"] + assert config_entry_data == { + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "expires_in": 60, + "refresh_token": "mock-refresh-token", + "type": "Bearer", + }, + } + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + +@pytest.mark.usefixtures( + "current_request_with_host", + "setup_credentials", +) +async def test_api_not_enabled( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + setup_api: Mock, +) -> None: + """Check flow aborts if api is not enabled.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=https://www.googleapis.com/auth/photoslibrary.readonly" + "+https://www.googleapis.com/auth/userinfo.profile" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + setup_api.return_value.mediaItems.return_value.list = Mock() + setup_api.return_value.mediaItems.return_value.list.return_value.execute.side_effect = HttpError( + Response({"status": "403"}), + bytes(load_fixture("google_photos/api_not_enabled_response.json"), "utf-8"), + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "access_not_configured" + assert result["description_placeholders"]["message"].endswith( + "Google Photos API has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/library/photoslibrary.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry." + ) + + +@pytest.mark.usefixtures("current_request_with_host", "setup_credentials") +async def test_general_exception( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Check flow aborts if exception happens.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=https://www.googleapis.com/auth/photoslibrary.readonly" + "+https://www.googleapis.com/auth/userinfo.profile" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.google_photos.api.build", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unknown" diff --git a/tests/components/google_photos/test_init.py b/tests/components/google_photos/test_init.py new file mode 100644 index 00000000000..a2f835c8611 --- /dev/null +++ b/tests/components/google_photos/test_init.py @@ -0,0 +1,109 @@ +"""Tests for Google Photos.""" + +import http +import time + +from aiohttp import ClientError +import pytest + +from homeassistant.components.google_photos.const import OAUTH2_TOKEN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +@pytest.mark.usefixtures("setup_integration") +async def test_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test successful setup and unload.""" + assert config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.fixture(name="refresh_token_status") +def mock_refresh_token_status() -> http.HTTPStatus: + """Fixture to set a token refresh status.""" + return http.HTTPStatus.OK + + +@pytest.fixture(name="refresh_token_exception") +def mock_refresh_token_exception() -> Exception | None: + """Fixture to set a token refresh status.""" + return None + + +@pytest.fixture(name="refresh_token") +def mock_refresh_token( + aioclient_mock: AiohttpClientMocker, + refresh_token_status: http.HTTPStatus, + refresh_token_exception: Exception | None, +) -> MockConfigEntry: + """Fixture to simulate a token refresh response.""" + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + exc=refresh_token_exception, + status=refresh_token_status, + json={ + "access_token": "updated-access-token", + "refresh_token": "updated-refresh-token", + "expires_at": time.time() + 3600, + "expires_in": 3600, + }, + ) + + +@pytest.mark.usefixtures("refresh_token", "setup_integration") +@pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"]) +async def test_expired_token_refresh_success( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test expired token is refreshed.""" + + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.data["token"]["access_token"] == "updated-access-token" + assert config_entry.data["token"]["expires_in"] == 3600 + + +@pytest.mark.usefixtures("refresh_token", "setup_integration") +@pytest.mark.parametrize( + ("expires_at", "refresh_token_status", "refresh_token_exception", "expected_state"), + [ + ( + time.time() - 3600, + http.HTTPStatus.NOT_FOUND, + None, + ConfigEntryState.SETUP_RETRY, + ), + ( + time.time() - 3600, + http.HTTPStatus.INTERNAL_SERVER_ERROR, + None, + ConfigEntryState.SETUP_RETRY, + ), + ( + time.time() - 3600, + None, + ClientError("Client exception raised"), + ConfigEntryState.SETUP_RETRY, + ), + ], + ids=["unauthorized", "internal_server_error", "client_error"], +) +async def test_expired_token_refresh_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + expected_state: ConfigEntryState, +) -> None: + """Test failure while refreshing token with a transient error.""" + + assert config_entry.state is expected_state diff --git a/tests/components/google_photos/test_media_source.py b/tests/components/google_photos/test_media_source.py new file mode 100644 index 00000000000..31c84f4811c --- /dev/null +++ b/tests/components/google_photos/test_media_source.py @@ -0,0 +1,199 @@ +"""Test the Google Photos media source.""" + +from typing import Any +from unittest.mock import Mock + +from googleapiclient.errors import HttpError +from httplib2 import Response +import pytest + +from homeassistant.components.google_photos.const import DOMAIN +from homeassistant.components.media_source import ( + URI_SCHEME, + BrowseError, + async_browse_media, + async_resolve_media, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +async def setup_components(hass: HomeAssistant) -> None: + """Fixture to initialize the integration.""" + await async_setup_component(hass, "media_source", {}) + + +@pytest.mark.usefixtures("setup_integration") +async def test_no_config_entries( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test a media source with no active config entry.""" + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") + + assert browse.domain == DOMAIN + assert browse.identifier is None + assert browse.title == "Google Photos" + assert browse.can_expand + assert not browse.children + + +@pytest.mark.usefixtures("setup_integration", "setup_api") +@pytest.mark.parametrize( + ("fixture_name", "expected_results", "expected_medias"), + [ + ("list_mediaitems_empty.json", [], []), + ( + "list_mediaitems.json", + [ + ("config-entry-id-123/p/id1", "example1.jpg"), + ("config-entry-id-123/p/id2", "example2.mp4"), + ], + [ + ("http://img.example.com/id1=w2048", "image/jpeg"), + ("http://img.example.com/id2=dv", "video/mp4"), + ], + ), + ], +) +async def test_recent_items( + hass: HomeAssistant, + expected_results: list[tuple[str, str]], + expected_medias: list[tuple[str, str]], +) -> None: + """Test a media source with no eligible camera devices.""" + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") + assert browse.domain == DOMAIN + assert browse.identifier is None + assert browse.title == "Google Photos" + assert [(child.identifier, child.title) for child in browse.children] == [ + ("config-entry-id-123", "Account Name") + ] + + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/config-entry-id-123") + assert browse.domain == DOMAIN + assert browse.identifier == "config-entry-id-123" + assert browse.title == "Account Name" + assert [(child.identifier, child.title) for child in browse.children] == [ + ("config-entry-id-123/a/recent", "Recent Photos") + ] + + browse = await async_browse_media( + hass, f"{URI_SCHEME}{DOMAIN}/config-entry-id-123/a/recent" + ) + assert browse.domain == DOMAIN + assert browse.identifier == "config-entry-id-123" + assert browse.title == "Account Name" + assert [ + (child.identifier, child.title) for child in browse.children + ] == expected_results + + media = [ + await async_resolve_media( + hass, f"{URI_SCHEME}{DOMAIN}/{child.identifier}", None + ) + for child in browse.children + ] + assert [ + (play_media.url, play_media.mime_type) for play_media in media + ] == expected_medias + + +@pytest.mark.usefixtures("setup_integration", "setup_api") +async def test_invalid_config_entry(hass: HomeAssistant) -> None: + """Test browsing to a config entry that does not exist.""" + with pytest.raises(BrowseError, match="Could not find config entry"): + await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/invalid-config-entry") + + +@pytest.mark.usefixtures("setup_integration", "setup_api") +@pytest.mark.parametrize("fixture_name", ["list_mediaitems.json"]) +async def test_invalid_album_id(hass: HomeAssistant) -> None: + """Test browsing to an album id that does not exist.""" + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") + assert browse.domain == DOMAIN + assert browse.identifier is None + assert browse.title == "Google Photos" + assert [(child.identifier, child.title) for child in browse.children] == [ + ("config-entry-id-123", "Account Name") + ] + + with pytest.raises(BrowseError, match="Unsupported album"): + await async_browse_media( + hass, f"{URI_SCHEME}{DOMAIN}/config-entry-id-123/a/invalid-album-id" + ) + + +@pytest.mark.usefixtures("setup_integration") +@pytest.mark.parametrize( + ("identifier", "expected_error"), + [ + ("invalid-config-entry", "without a photo_media_id"), + ("too/many/slashes/in/path", "Invalid identifier"), + ], +) +async def test_missing_photo_id( + hass: HomeAssistant, identifier: str, expected_error: str +) -> None: + """Test parsing an invalid media identifier.""" + with pytest.raises(BrowseError, match=expected_error): + await async_resolve_media(hass, f"{URI_SCHEME}{DOMAIN}/{identifier}", None) + + +@pytest.mark.usefixtures("setup_integration", "setup_api") +@pytest.mark.parametrize( + "side_effect", + [ + HttpError(Response({"status": "403"}), b""), + ], +) +async def test_list_media_items_failure( + hass: HomeAssistant, + setup_api: Any, + side_effect: HttpError | Response, +) -> None: + """Test browsing to an album id that does not exist.""" + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") + assert browse.domain == DOMAIN + assert browse.identifier is None + assert browse.title == "Google Photos" + assert [(child.identifier, child.title) for child in browse.children] == [ + ("config-entry-id-123", "Account Name") + ] + + setup_api.return_value.mediaItems.return_value.list = Mock() + setup_api.return_value.mediaItems.return_value.list.return_value.execute.side_effect = side_effect + + with pytest.raises(BrowseError, match="Error listing media items"): + await async_browse_media( + hass, f"{URI_SCHEME}{DOMAIN}/config-entry-id-123/a/recent" + ) + + +@pytest.mark.usefixtures("setup_integration", "setup_api") +@pytest.mark.parametrize( + "fixture_name", + [ + "api_not_enabled_response.json", + "not_dict.json", + ], +) +async def test_media_items_error_parsing_response(hass: HomeAssistant) -> None: + """Test browsing to an album id that does not exist.""" + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") + assert browse.domain == DOMAIN + assert browse.identifier is None + assert browse.title == "Google Photos" + assert [(child.identifier, child.title) for child in browse.children] == [ + ("config-entry-id-123", "Account Name") + ] + with pytest.raises(BrowseError, match="Error listing media items"): + await async_browse_media( + hass, f"{URI_SCHEME}{DOMAIN}/config-entry-id-123/a/recent" + )