mirror of
https://github.com/home-assistant/core.git
synced 2025-07-09 14:27:07 +00:00
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:
parent
741b025751
commit
27bed0cdcb
@ -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)
|
||||||
|
|
||||||
|
61
homeassistant/components/google_photos/coordinator.py
Normal file
61
homeassistant/components/google_photos/coordinator.py
Normal 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)
|
||||||
|
)
|
@ -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,
|
||||||
|
@ -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]
|
||||||
|
@ -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": {
|
||||||
|
@ -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]
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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(
|
||||||
|
Loading…
x
Reference in New Issue
Block a user