diff --git a/homeassistant/components/google_photos/__init__.py b/homeassistant/components/google_photos/__init__.py index 950995e72c0..2a7109d8189 100644 --- a/homeassistant/components/google_photos/__init__.py +++ b/homeassistant/components/google_photos/__init__.py @@ -12,6 +12,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import api from .const import DOMAIN +from .coordinator import GooglePhotosUpdateCoordinator from .services import async_register_services from .types import GooglePhotosConfigEntry @@ -42,7 +43,9 @@ async def async_setup_entry( raise ConfigEntryNotReady from err except ClientError as err: raise ConfigEntryNotReady from err - entry.runtime_data = GooglePhotosLibraryApi(auth) + coordinator = GooglePhotosUpdateCoordinator(hass, GooglePhotosLibraryApi(auth)) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator async_register_services(hass) diff --git a/homeassistant/components/google_photos/coordinator.py b/homeassistant/components/google_photos/coordinator.py new file mode 100644 index 00000000000..1c22740cbd0 --- /dev/null +++ b/homeassistant/components/google_photos/coordinator.py @@ -0,0 +1,61 @@ +"""Coordinator for fetching data from Google Photos API. + +This coordinator fetches the list of Google Photos albums that were created by +Home Assistant, which for large libraries may take some time. The list of album +ids and titles is cached and this provides a method to refresh urls since they +are short lived. +""" + +import asyncio +import datetime +import logging +from typing import Final + +from google_photos_library_api.api import GooglePhotosLibraryApi +from google_photos_library_api.exceptions import GooglePhotosApiError +from google_photos_library_api.model import Album + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL: Final = datetime.timedelta(hours=24) +ALBUM_PAGE_SIZE = 50 + + +class GooglePhotosUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]): + """Coordinator for fetching Google Photos albums. + + The `data` object is a dict from Album ID to Album title. + """ + + def __init__(self, hass: HomeAssistant, client: GooglePhotosLibraryApi) -> None: + """Initialize TaskUpdateCoordinator.""" + super().__init__( + hass, + _LOGGER, + name="Google Photos", + update_interval=UPDATE_INTERVAL, + ) + self.client = client + + async def _async_update_data(self) -> dict[str, str]: + """Fetch albums from API endpoint.""" + albums: dict[str, str] = {} + try: + async for album_result in await self.client.list_albums( + page_size=ALBUM_PAGE_SIZE + ): + for album in album_result.albums: + albums[album.id] = album.title + except GooglePhotosApiError as err: + _LOGGER.debug("Error listing albums: %s", err) + raise UpdateFailed(f"Error listing albums: {err}") from err + return albums + + async def list_albums(self) -> list[Album]: + """Return Albums with refreshed URLs based on the cached list of album ids.""" + return await asyncio.gather( + *(self.client.get_album(album_id) for album_id in self.data) + ) diff --git a/homeassistant/components/google_photos/media_source.py b/homeassistant/components/google_photos/media_source.py index 2388869d75b..2bf913541cd 100644 --- a/homeassistant/components/google_photos/media_source.py +++ b/homeassistant/components/google_photos/media_source.py @@ -149,7 +149,7 @@ class GooglePhotosMediaSource(MediaSource): f"Could not resolve identiifer that is not a Photo: {identifier}" ) entry = self._async_config_entry(identifier.config_entry_id) - client = entry.runtime_data + client = entry.runtime_data.client media_item = await client.get_media_item(media_item_id=identifier.media_id) if not media_item.mime_type: raise BrowseError("Could not determine mime type of media item") @@ -189,7 +189,8 @@ class GooglePhotosMediaSource(MediaSource): # Determine the configuration entry for this item identifier = PhotosIdentifier.of(item.identifier) entry = self._async_config_entry(identifier.config_entry_id) - client = entry.runtime_data + coordinator = entry.runtime_data + client = coordinator.client source = _build_account(entry, identifier) if identifier.id_type is None: @@ -202,15 +203,8 @@ class GooglePhotosMediaSource(MediaSource): ) for special_album in SpecialAlbum ] - albums: list[Album] = [] - try: - async for album_result in await client.list_albums( - page_size=ALBUM_PAGE_SIZE - ): - albums.extend(album_result.albums) - except GooglePhotosApiError as err: - raise BrowseError(f"Error listing albums: {err}") from err + albums = await coordinator.list_albums() source.children.extend( _build_album( album.title, diff --git a/homeassistant/components/google_photos/services.py b/homeassistant/components/google_photos/services.py index 1687e812b1d..0be213c6981 100644 --- a/homeassistant/components/google_photos/services.py +++ b/homeassistant/components/google_photos/services.py @@ -97,7 +97,7 @@ def async_register_services(hass: HomeAssistant) -> None: translation_placeholders={"target": DOMAIN}, ) - client_api = config_entry.runtime_data + client_api = config_entry.runtime_data.client upload_tasks = [] file_results = await hass.async_add_executor_job( _read_file_contents, hass, call.data[CONF_FILENAME] diff --git a/homeassistant/components/google_photos/strings.json b/homeassistant/components/google_photos/strings.json index aaed29b124d..17e018dabee 100644 --- a/homeassistant/components/google_photos/strings.json +++ b/homeassistant/components/google_photos/strings.json @@ -54,6 +54,9 @@ }, "api_error": { "message": "Google Photos API responded with error: {message}" + }, + "albums_failed": { + "message": "Cannot fetch albums from the Google Photos API" } }, "services": { diff --git a/homeassistant/components/google_photos/types.py b/homeassistant/components/google_photos/types.py index 2fe57fe1d15..4f4cc1845e4 100644 --- a/homeassistant/components/google_photos/types.py +++ b/homeassistant/components/google_photos/types.py @@ -1,7 +1,7 @@ """Google Photos types.""" -from google_photos_library_api.api import GooglePhotosLibraryApi - from homeassistant.config_entries import ConfigEntry -type GooglePhotosConfigEntry = ConfigEntry[GooglePhotosLibraryApi] +from .coordinator import GooglePhotosUpdateCoordinator + +type GooglePhotosConfigEntry = ConfigEntry[GooglePhotosUpdateCoordinator] diff --git a/tests/components/google_photos/conftest.py b/tests/components/google_photos/conftest.py index c657cd14a53..c848122a9fd 100644 --- a/tests/components/google_photos/conftest.py +++ b/tests/components/google_photos/conftest.py @@ -171,6 +171,17 @@ def mock_client_api( mock_api.list_albums.return_value.__aiter__ = list_albums mock_api.list_albums.return_value.__anext__ = list_albums mock_api.list_albums.side_effect = api_error + + # Mock a point lookup by reading contents of the album fixture above + async def get_album(album_id: str, **kwargs: Any) -> Mock: + for album in load_json_object_fixture("list_albums.json", DOMAIN)["albums"]: + if album["id"] == album_id: + return Album.from_dict(album) + return None + + mock_api.get_album = get_album + mock_api.get_album.side_effect = api_error + return mock_api diff --git a/tests/components/google_photos/test_init.py b/tests/components/google_photos/test_init.py index ea236cfc712..80b051d092d 100644 --- a/tests/components/google_photos/test_init.py +++ b/tests/components/google_photos/test_init.py @@ -4,6 +4,7 @@ import http import time from aiohttp import ClientError +from google_photos_library_api.exceptions import GooglePhotosApiError import pytest from homeassistant.components.google_photos.const import OAUTH2_TOKEN @@ -20,6 +21,7 @@ async def test_setup( config_entry: MockConfigEntry, ) -> None: """Test successful setup and unload.""" + await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED await hass.config_entries.async_unload(config_entry.entry_id) @@ -68,7 +70,6 @@ async def test_expired_token_refresh_success( 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 @@ -107,3 +108,13 @@ async def test_expired_token_refresh_failure( """Test failure while refreshing token with a transient error.""" assert config_entry.state is expected_state + + +@pytest.mark.usefixtures("setup_integration") +@pytest.mark.parametrize("api_error", [GooglePhotosApiError("some error")]) +async def test_coordinator_init_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test init failure to load albums.""" + assert config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/google_photos/test_media_source.py b/tests/components/google_photos/test_media_source.py index 9d287998fa8..fd20117b86e 100644 --- a/tests/components/google_photos/test_media_source.py +++ b/tests/components/google_photos/test_media_source.py @@ -156,24 +156,6 @@ async def test_browse_invalid_path(hass: HomeAssistant) -> None: ) -@pytest.mark.usefixtures("setup_integration") -@pytest.mark.parametrize("api_error", [GooglePhotosApiError("some error")]) -async def test_invalid_album_id(hass: HomeAssistant, mock_api: Mock) -> 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, "Account Name") - ] - - with pytest.raises(BrowseError, match="Error listing media items"): - await async_browse_media( - hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}/a/invalid-album-id" - ) - - @pytest.mark.usefixtures("setup_integration") @pytest.mark.parametrize( ("identifier", "expected_error"), @@ -193,8 +175,7 @@ async def test_missing_photo_id( @pytest.mark.usefixtures("setup_integration", "mock_api") -@pytest.mark.parametrize("api_error", [GooglePhotosApiError("some error")]) -async def test_list_albums_failure(hass: HomeAssistant) -> None: +async def test_list_media_items_failure(hass: HomeAssistant, mock_api: Mock) -> 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 @@ -204,21 +185,7 @@ async def test_list_albums_failure(hass: HomeAssistant) -> None: (CONFIG_ENTRY_ID, "Account Name") ] - with pytest.raises(BrowseError, match="Error listing albums"): - await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}") - - -@pytest.mark.usefixtures("setup_integration", "mock_api") -@pytest.mark.parametrize("api_error", [GooglePhotosApiError("some error")]) -async def test_list_media_items_failure(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, "Account Name") - ] + mock_api.list_media_items.side_effect = GooglePhotosApiError("some error") with pytest.raises(BrowseError, match="Error listing media items"): await async_browse_media(