Update Google Photos to have a DataUpdateCoordinator for loading albums (#126443)

* Update Google Photos to have a data update coordiantor for loading albums

* Remove album from services

* Remove action string changes

* Revert services.yaml change

* Simplify integration by blocking startup on album loading

* Update homeassistant/components/google_photos/coordinator.py

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Allen Porter 2024-09-24 07:34:40 -07:00 committed by GitHub
parent 741b025751
commit 27bed0cdcb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 101 additions and 51 deletions

View File

@ -12,6 +12,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from . import api from . import api
from .const import DOMAIN from .const import DOMAIN
from .coordinator import GooglePhotosUpdateCoordinator
from .services import async_register_services from .services import async_register_services
from .types import GooglePhotosConfigEntry from .types import GooglePhotosConfigEntry
@ -42,7 +43,9 @@ async def async_setup_entry(
raise ConfigEntryNotReady from err raise ConfigEntryNotReady from err
except ClientError as err: except ClientError as err:
raise ConfigEntryNotReady from 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) async_register_services(hass)

View File

@ -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)
)

View File

@ -149,7 +149,7 @@ class GooglePhotosMediaSource(MediaSource):
f"Could not resolve identiifer that is not a Photo: {identifier}" f"Could not resolve identiifer that is not a Photo: {identifier}"
) )
entry = self._async_config_entry(identifier.config_entry_id) 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) media_item = await client.get_media_item(media_item_id=identifier.media_id)
if not media_item.mime_type: if not media_item.mime_type:
raise BrowseError("Could not determine mime type of media item") raise BrowseError("Could not determine mime type of media item")
@ -189,7 +189,8 @@ class GooglePhotosMediaSource(MediaSource):
# Determine the configuration entry for this item # Determine the configuration entry for this item
identifier = PhotosIdentifier.of(item.identifier) identifier = PhotosIdentifier.of(item.identifier)
entry = self._async_config_entry(identifier.config_entry_id) 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) source = _build_account(entry, identifier)
if identifier.id_type is None: if identifier.id_type is None:
@ -202,15 +203,8 @@ class GooglePhotosMediaSource(MediaSource):
) )
for special_album in SpecialAlbum 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( source.children.extend(
_build_album( _build_album(
album.title, album.title,

View File

@ -97,7 +97,7 @@ def async_register_services(hass: HomeAssistant) -> None:
translation_placeholders={"target": DOMAIN}, translation_placeholders={"target": DOMAIN},
) )
client_api = config_entry.runtime_data client_api = config_entry.runtime_data.client
upload_tasks = [] upload_tasks = []
file_results = await hass.async_add_executor_job( file_results = await hass.async_add_executor_job(
_read_file_contents, hass, call.data[CONF_FILENAME] _read_file_contents, hass, call.data[CONF_FILENAME]

View File

@ -54,6 +54,9 @@
}, },
"api_error": { "api_error": {
"message": "Google Photos API responded with error: {message}" "message": "Google Photos API responded with error: {message}"
},
"albums_failed": {
"message": "Cannot fetch albums from the Google Photos API"
} }
}, },
"services": { "services": {

View File

@ -1,7 +1,7 @@
"""Google Photos types.""" """Google Photos types."""
from google_photos_library_api.api import GooglePhotosLibraryApi
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
type GooglePhotosConfigEntry = ConfigEntry[GooglePhotosLibraryApi] from .coordinator import GooglePhotosUpdateCoordinator
type GooglePhotosConfigEntry = ConfigEntry[GooglePhotosUpdateCoordinator]

View File

@ -171,6 +171,17 @@ def mock_client_api(
mock_api.list_albums.return_value.__aiter__ = list_albums mock_api.list_albums.return_value.__aiter__ = list_albums
mock_api.list_albums.return_value.__anext__ = list_albums mock_api.list_albums.return_value.__anext__ = list_albums
mock_api.list_albums.side_effect = api_error 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 return mock_api

View File

@ -4,6 +4,7 @@ import http
import time import time
from aiohttp import ClientError from aiohttp import ClientError
from google_photos_library_api.exceptions import GooglePhotosApiError
import pytest import pytest
from homeassistant.components.google_photos.const import OAUTH2_TOKEN from homeassistant.components.google_photos.const import OAUTH2_TOKEN
@ -20,6 +21,7 @@ async def test_setup(
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
) -> None: ) -> None:
"""Test successful setup and unload.""" """Test successful setup and unload."""
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED assert config_entry.state is ConfigEntryState.LOADED
await hass.config_entries.async_unload(config_entry.entry_id) await hass.config_entries.async_unload(config_entry.entry_id)
@ -68,7 +70,6 @@ async def test_expired_token_refresh_success(
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
) -> None: ) -> None:
"""Test expired token is refreshed.""" """Test expired token is refreshed."""
assert config_entry.state is ConfigEntryState.LOADED assert config_entry.state is ConfigEntryState.LOADED
assert config_entry.data["token"]["access_token"] == "updated-access-token" assert config_entry.data["token"]["access_token"] == "updated-access-token"
assert config_entry.data["token"]["expires_in"] == 3600 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.""" """Test failure while refreshing token with a transient error."""
assert config_entry.state is expected_state 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

View File

@ -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.usefixtures("setup_integration")
@pytest.mark.parametrize( @pytest.mark.parametrize(
("identifier", "expected_error"), ("identifier", "expected_error"),
@ -193,8 +175,7 @@ async def test_missing_photo_id(
@pytest.mark.usefixtures("setup_integration", "mock_api") @pytest.mark.usefixtures("setup_integration", "mock_api")
@pytest.mark.parametrize("api_error", [GooglePhotosApiError("some error")]) async def test_list_media_items_failure(hass: HomeAssistant, mock_api: Mock) -> None:
async def test_list_albums_failure(hass: HomeAssistant) -> None:
"""Test browsing to an album id that does not exist.""" """Test browsing to an album id that does not exist."""
browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}")
assert browse.domain == DOMAIN assert browse.domain == DOMAIN
@ -204,21 +185,7 @@ async def test_list_albums_failure(hass: HomeAssistant) -> None:
(CONFIG_ENTRY_ID, "Account Name") (CONFIG_ENTRY_ID, "Account Name")
] ]
with pytest.raises(BrowseError, match="Error listing albums"): mock_api.list_media_items.side_effect = GooglePhotosApiError("some error")
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")
]
with pytest.raises(BrowseError, match="Error listing media items"): with pytest.raises(BrowseError, match="Error listing media items"):
await async_browse_media( await async_browse_media(