diff --git a/homeassistant/components/google_photos/api.py b/homeassistant/components/google_photos/api.py index c5de03d7d21..0bbb2fe162b 100644 --- a/homeassistant/components/google_photos/api.py +++ b/homeassistant/components/google_photos/api.py @@ -9,7 +9,7 @@ from aiohttp.client_exceptions import ClientError from google.oauth2.credentials import Credentials from googleapiclient.discovery import Resource, build from googleapiclient.errors import HttpError -from googleapiclient.http import BatchHttpRequest, HttpRequest +from googleapiclient.http import HttpRequest from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant @@ -27,6 +27,9 @@ GET_MEDIA_ITEM_FIELDS = ( ) LIST_MEDIA_ITEM_FIELDS = f"nextPageToken,mediaItems({GET_MEDIA_ITEM_FIELDS})" UPLOAD_API = "https://photoslibrary.googleapis.com/v1/uploads" +LIST_ALBUMS_FIELDS = ( + "nextPageToken,albums(id,title,coverPhotoBaseUrl,coverPhotoMediaItemId)" +) class AuthBase(ABC): @@ -61,14 +64,38 @@ class AuthBase(ABC): return await self._execute(cmd) async def list_media_items( - self, page_size: int | None = None, page_token: str | None = None + self, + page_size: int | None = None, + page_token: str | None = None, + album_id: str | None = None, + favorites: bool = False, ) -> dict[str, Any]: """Get all MediaItem resources.""" service = await self._get_photos_service() - cmd: HttpRequest = service.mediaItems().list( + args: dict[str, Any] = { + "pageSize": (page_size or DEFAULT_PAGE_SIZE), + "pageToken": page_token, + } + cmd: HttpRequest + if album_id is not None or favorites: + if album_id is not None: + args["albumId"] = album_id + if favorites: + args["filters"] = {"featureFilter": {"includedFeatures": "FAVORITES"}} + cmd = service.mediaItems().search(body=args, fields=LIST_MEDIA_ITEM_FIELDS) + else: + cmd = service.mediaItems().list(**args, fields=LIST_MEDIA_ITEM_FIELDS) + return await self._execute(cmd) + + async def list_albums( + self, page_size: int | None = None, page_token: str | None = None + ) -> dict[str, Any]: + """Get all Album resources.""" + service = await self._get_photos_service() + cmd: HttpRequest = service.albums().list( pageSize=(page_size or DEFAULT_PAGE_SIZE), pageToken=page_token, - fields=LIST_MEDIA_ITEM_FIELDS, + fields=LIST_ALBUMS_FIELDS, ) return await self._execute(cmd) @@ -126,7 +153,7 @@ class AuthBase(ABC): partial(build, "oauth2", "v2", credentials=Credentials(token=token)) # type: ignore[no-untyped-call] ) - async def _execute(self, request: HttpRequest | BatchHttpRequest) -> dict[str, Any]: + async def _execute(self, request: HttpRequest) -> dict[str, Any]: try: result = await self._hass.async_add_executor_job(request.execute) except HttpError as err: diff --git a/homeassistant/components/google_photos/media_source.py b/homeassistant/components/google_photos/media_source.py index 9b922ee3201..a709dd66a0a 100644 --- a/homeassistant/components/google_photos/media_source.py +++ b/homeassistant/components/google_photos/media_source.py @@ -1,7 +1,7 @@ """Media source for Google Photos.""" from dataclasses import dataclass -from enum import StrEnum +from enum import Enum, StrEnum import logging from typing import Any, Self, cast @@ -25,14 +25,41 @@ _LOGGER = logging.getLogger(__name__) # 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 +MAX_RECENT_PHOTOS = 50 +MAX_ALBUMS = 50 PAGE_SIZE = 100 THUMBNAIL_SIZE = 256 LARGE_IMAGE_SIZE = 2160 -# Markers for parts of PhotosIdentifier url pattern. +@dataclass +class SpecialAlbumDetails: + """Details for a Special album.""" + + path: str + title: str + list_args: dict[str, Any] + max_photos: int | None + + +class SpecialAlbum(Enum): + """Special Album types.""" + + RECENT = SpecialAlbumDetails("recent", "Recent Photos", {}, MAX_RECENT_PHOTOS) + FAVORITE = SpecialAlbumDetails( + "favorites", "Favorite Photos", {"favorites": True}, None + ) + + @classmethod + def of(cls, path: str) -> Self | None: + """Parse a PhotosIdentifierType by string value.""" + for enum in cls: + if enum.value.path == path: + return enum + return None + + # The PhotosIdentifier can be in the following forms: # config-entry-id # config-entry-id/a/album-media-id @@ -40,12 +67,6 @@ LARGE_IMAGE_SIZE = 2160 # # 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" class PhotosIdentifierType(StrEnum): @@ -86,7 +107,6 @@ class PhotosIdentifier: def of(cls, identifier: str) -> Self: """Parse a PhotosIdentifier form a string.""" parts = identifier.split("/") - _LOGGER.debug("parts=%s", parts) if len(parts) == 1: return cls(parts[0]) if len(parts) != 3: @@ -179,27 +199,50 @@ class GooglePhotosMediaSource(MediaSource): source = _build_account(entry, identifier) if identifier.id_type is None: + result = await client.list_albums(page_size=MAX_ALBUMS) source.children = [ _build_album( - RECENT_PHOTOS_TITLE, + special_album.value.title, PhotosIdentifier.album( - identifier.config_entry_id, RECENT_PHOTOS_ALBUM + identifier.config_entry_id, special_album.value.path ), ) + for special_album in SpecialAlbum + ] + [ + _build_album( + album["title"], + PhotosIdentifier.album( + identifier.config_entry_id, + album["id"], + ), + _cover_photo_url(album, THUMBNAIL_SIZE), + ) + for album in result["albums"] ] return source - # Currently only supports listing a single album of recent photos. - if identifier.media_id != RECENT_PHOTOS_ALBUM: - raise BrowseError(f"Unsupported album: {identifier}") + if ( + identifier.id_type != PhotosIdentifierType.ALBUM + or identifier.media_id is None + ): + raise BrowseError(f"Unsupported identifier: {identifier}") + + list_args: dict[str, Any] + if special_album := SpecialAlbum.of(identifier.media_id): + list_args = special_album.value.list_args + else: + list_args = {"album_id": identifier.media_id} - # Fetch recent items media_items: list[dict[str, Any]] = [] page_token: str | None = None - while len(media_items) < MAX_PHOTOS: + while ( + not special_album + or (max_photos := special_album.value.max_photos) is None + or len(media_items) < max_photos + ): try: result = await client.list_media_items( - page_size=PAGE_SIZE, page_token=page_token + **list_args, page_size=PAGE_SIZE, page_token=page_token ) except GooglePhotosApiError as err: raise BrowseError(f"Error listing media items: {err}") from err @@ -255,7 +298,9 @@ def _build_account( ) -def _build_album(title: str, identifier: PhotosIdentifier) -> BrowseMediaSource: +def _build_album( + title: str, identifier: PhotosIdentifier, thumbnail_url: str | None = None +) -> BrowseMediaSource: """Build an album node.""" return BrowseMediaSource( domain=DOMAIN, @@ -265,6 +310,7 @@ def _build_album(title: str, identifier: PhotosIdentifier) -> BrowseMediaSource: title=title, can_play=False, can_expand=True, + thumbnail=thumbnail_url, ) @@ -299,3 +345,8 @@ def _video_url(media_item: dict[str, Any]) -> str: See https://developers.google.com/photos/library/guides/access-media-items#base-urls """ return f"{media_item["baseUrl"]}=dv" + + +def _cover_photo_url(album: dict[str, Any], max_size: int) -> str: + """Return a media item url for the cover photo of the album.""" + return f"{album["coverPhotoBaseUrl"]}=h{max_size}" diff --git a/tests/components/google_photos/conftest.py b/tests/components/google_photos/conftest.py index 2cdad5d4d10..f7289993258 100644 --- a/tests/components/google_photos/conftest.py +++ b/tests/components/google_photos/conftest.py @@ -15,7 +15,11 @@ 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 +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) USER_IDENTIFIER = "user-identifier-1" CONFIG_ENTRY_ID = "user-identifier-1" @@ -119,6 +123,7 @@ def mock_setup_api( return mock mock.return_value.mediaItems.return_value.list = list_media_items + mock.return_value.mediaItems.return_value.search = list_media_items # Mock a point lookup by reading contents of the fixture above def get_media_item(mediaItemId: str, **kwargs: Any) -> Mock: @@ -131,6 +136,10 @@ def mock_setup_api( return None mock.return_value.mediaItems.return_value.get = get_media_item + mock.return_value.albums.return_value.list.return_value.execute.return_value = ( + load_json_object_fixture("list_albums.json", DOMAIN) + ) + yield mock diff --git a/tests/components/google_photos/fixtures/list_albums.json b/tests/components/google_photos/fixtures/list_albums.json new file mode 100644 index 00000000000..57f2873715b --- /dev/null +++ b/tests/components/google_photos/fixtures/list_albums.json @@ -0,0 +1,12 @@ +{ + "albums": [ + { + "id": "album-media-id-1", + "title": "Album title", + "isWriteable": true, + "mediaItemsCount": 7, + "coverPhotoBaseUrl": "http://img.example.com/id3", + "coverPhotoMediaItemId": "cover-photo-media-id-3" + } + ] +} diff --git a/tests/components/google_photos/test_media_source.py b/tests/components/google_photos/test_media_source.py index ff4993eb3df..1028a34aec1 100644 --- a/tests/components/google_photos/test_media_source.py +++ b/tests/components/google_photos/test_media_source.py @@ -65,6 +65,14 @@ async def test_no_read_scopes( @pytest.mark.usefixtures("setup_integration", "setup_api") +@pytest.mark.parametrize( + ("album_path", "expected_album_title"), + [ + (f"{CONFIG_ENTRY_ID}/a/recent", "Recent Photos"), + (f"{CONFIG_ENTRY_ID}/a/favorites", "Favorite Photos"), + (f"{CONFIG_ENTRY_ID}/a/album-media-id-1", "Album title"), + ], +) @pytest.mark.parametrize( ("fixture_name", "expected_results", "expected_medias"), [ @@ -82,8 +90,10 @@ async def test_no_read_scopes( ), ], ) -async def test_recent_items( +async def test_browse_albums( hass: HomeAssistant, + album_path: str, + expected_album_title: str, expected_results: list[tuple[str, str]], expected_medias: list[tuple[str, str]], ) -> None: @@ -101,14 +111,14 @@ async def test_recent_items( assert browse.identifier == CONFIG_ENTRY_ID assert browse.title == "Account Name" assert [(child.identifier, child.title) for child in browse.children] == [ - (f"{CONFIG_ENTRY_ID}/a/recent", "Recent Photos") + (f"{CONFIG_ENTRY_ID}/a/recent", "Recent Photos"), + (f"{CONFIG_ENTRY_ID}/a/favorites", "Favorite Photos"), + (f"{CONFIG_ENTRY_ID}/a/album-media-id-1", "Album title"), ] - browse = await async_browse_media( - hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}/a/recent" - ) + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{album_path}") assert browse.domain == DOMAIN - assert browse.identifier == f"{CONFIG_ENTRY_ID}/a/recent" + assert browse.identifier == album_path assert browse.title == "Account Name" assert [ (child.identifier, child.title) for child in browse.children @@ -134,7 +144,25 @@ async def test_invalid_config_entry(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("setup_integration", "setup_api") @pytest.mark.parametrize("fixture_name", ["list_mediaitems.json"]) -async def test_invalid_album_id(hass: HomeAssistant) -> None: +async def test_browse_invalid_path(hass: HomeAssistant) -> None: + """Test browsing to a photo is not possible.""" + 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="Unsupported identifier"): + await async_browse_media( + hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}/p/some-photo-id" + ) + + +@pytest.mark.usefixtures("setup_integration") +@pytest.mark.parametrize("fixture_name", ["list_mediaitems.json"]) +async def test_invalid_album_id(hass: HomeAssistant, setup_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 @@ -144,7 +172,12 @@ async def test_invalid_album_id(hass: HomeAssistant) -> None: (CONFIG_ENTRY_ID, "Account Name") ] - with pytest.raises(BrowseError, match="Unsupported album"): + setup_api.return_value.mediaItems.return_value.search = Mock() + setup_api.return_value.mediaItems.return_value.search.return_value.execute.side_effect = HttpError( + Response({"status": "404"}), b"" + ) + + 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" )