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 <joostlek@outlook.com> * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * Fix comment typo * Update test fixtures from review feedback * Remove unnecessary test for services * Remove keyword argument --------- Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
parent
5e93394ae7
commit
c01bb44757
27 changed files with 1321 additions and 0 deletions
|
@ -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.*
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
"google_generative_ai_conversation",
|
||||
"google_mail",
|
||||
"google_maps",
|
||||
"google_photos",
|
||||
"google_pubsub",
|
||||
"google_sheets",
|
||||
"google_tasks",
|
||||
|
|
45
homeassistant/components/google_photos/__init__.py
Normal file
45
homeassistant/components/google_photos/__init__.py
Normal file
|
@ -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
|
143
homeassistant/components/google_photos/api.py
Normal file
143
homeassistant/components/google_photos/api.py
Normal file
|
@ -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
|
|
@ -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",
|
||||
}
|
54
homeassistant/components/google_photos/config_flow.py
Normal file
54
homeassistant/components/google_photos/config_flow.py
Normal file
|
@ -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)
|
10
homeassistant/components/google_photos/const.py
Normal file
10
homeassistant/components/google_photos/const.py
Normal file
|
@ -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",
|
||||
]
|
7
homeassistant/components/google_photos/exceptions.py
Normal file
7
homeassistant/components/google_photos/exceptions.py
Normal file
|
@ -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."""
|
10
homeassistant/components/google_photos/manifest.json
Normal file
10
homeassistant/components/google_photos/manifest.json
Normal file
|
@ -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"]
|
||||
}
|
283
homeassistant/components/google_photos/media_source.py
Normal file
283
homeassistant/components/google_photos/media_source.py
Normal file
|
@ -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"
|
29
homeassistant/components/google_photos/strings.json
Normal file
29
homeassistant/components/google_photos/strings.json
Normal file
|
@ -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%]"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -10,6 +10,7 @@ APPLICATION_CREDENTIALS = [
|
|||
"google",
|
||||
"google_assistant_sdk",
|
||||
"google_mail",
|
||||
"google_photos",
|
||||
"google_sheets",
|
||||
"google_tasks",
|
||||
"home_connect",
|
||||
|
|
|
@ -224,6 +224,7 @@ FLOWS = {
|
|||
"google_assistant_sdk",
|
||||
"google_generative_ai_conversation",
|
||||
"google_mail",
|
||||
"google_photos",
|
||||
"google_sheets",
|
||||
"google_tasks",
|
||||
"google_translate",
|
||||
|
|
|
@ -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,
|
||||
|
|
10
mypy.ini
10
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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
1
tests/components/google_photos/__init__.py
Normal file
1
tests/components/google_photos/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
"""Tests for the Google Photos integration."""
|
121
tests/components/google_photos/conftest.py
Normal file
121
tests/components/google_photos/conftest.py
Normal file
|
@ -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()
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
35
tests/components/google_photos/fixtures/list_mediaitems.json
Normal file
35
tests/components/google_photos/fixtures/list_mediaitems.json
Normal file
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
|
@ -0,0 +1,5 @@
|
|||
[
|
||||
{
|
||||
"mediaItems": []
|
||||
}
|
||||
]
|
1
tests/components/google_photos/fixtures/not_dict.json
Normal file
1
tests/components/google_photos/fixtures/not_dict.json
Normal file
|
@ -0,0 +1 @@
|
|||
["not a dictionary"]
|
205
tests/components/google_photos/test_config_flow.py
Normal file
205
tests/components/google_photos/test_config_flow.py
Normal file
|
@ -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"
|
109
tests/components/google_photos/test_init.py
Normal file
109
tests/components/google_photos/test_init.py
Normal file
|
@ -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
|
199
tests/components/google_photos/test_media_source.py
Normal file
199
tests/components/google_photos/test_media_source.py
Normal file
|
@ -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"
|
||||
)
|
Loading…
Add table
Reference in a new issue