Add Google Photos media source support for albums and favorites (#124985)

This commit is contained in:
Allen Porter 2024-08-31 14:39:18 -07:00 committed by GitHub
parent ef84a8869e
commit 30772da0e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 165 additions and 33 deletions

View File

@ -9,7 +9,7 @@ from aiohttp.client_exceptions import ClientError
from google.oauth2.credentials import Credentials from google.oauth2.credentials import Credentials
from googleapiclient.discovery import Resource, build from googleapiclient.discovery import Resource, build
from googleapiclient.errors import HttpError 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.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -27,6 +27,9 @@ GET_MEDIA_ITEM_FIELDS = (
) )
LIST_MEDIA_ITEM_FIELDS = f"nextPageToken,mediaItems({GET_MEDIA_ITEM_FIELDS})" LIST_MEDIA_ITEM_FIELDS = f"nextPageToken,mediaItems({GET_MEDIA_ITEM_FIELDS})"
UPLOAD_API = "https://photoslibrary.googleapis.com/v1/uploads" UPLOAD_API = "https://photoslibrary.googleapis.com/v1/uploads"
LIST_ALBUMS_FIELDS = (
"nextPageToken,albums(id,title,coverPhotoBaseUrl,coverPhotoMediaItemId)"
)
class AuthBase(ABC): class AuthBase(ABC):
@ -61,14 +64,38 @@ class AuthBase(ABC):
return await self._execute(cmd) return await self._execute(cmd)
async def list_media_items( 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]: ) -> dict[str, Any]:
"""Get all MediaItem resources.""" """Get all MediaItem resources."""
service = await self._get_photos_service() 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), pageSize=(page_size or DEFAULT_PAGE_SIZE),
pageToken=page_token, pageToken=page_token,
fields=LIST_MEDIA_ITEM_FIELDS, fields=LIST_ALBUMS_FIELDS,
) )
return await self._execute(cmd) 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] 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: try:
result = await self._hass.async_add_executor_job(request.execute) result = await self._hass.async_add_executor_job(request.execute)
except HttpError as err: except HttpError as err:

View File

@ -1,7 +1,7 @@
"""Media source for Google Photos.""" """Media source for Google Photos."""
from dataclasses import dataclass from dataclasses import dataclass
from enum import StrEnum from enum import Enum, StrEnum
import logging import logging
from typing import Any, Self, cast 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 # 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 # 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. # 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 PAGE_SIZE = 100
THUMBNAIL_SIZE = 256 THUMBNAIL_SIZE = 256
LARGE_IMAGE_SIZE = 2160 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: # The PhotosIdentifier can be in the following forms:
# config-entry-id # config-entry-id
# config-entry-id/a/album-media-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 # The album-media-id can contain special reserved folder names for use by
# this integration for virtual folders like the `recent` album. # 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): class PhotosIdentifierType(StrEnum):
@ -86,7 +107,6 @@ class PhotosIdentifier:
def of(cls, identifier: str) -> Self: def of(cls, identifier: str) -> Self:
"""Parse a PhotosIdentifier form a string.""" """Parse a PhotosIdentifier form a string."""
parts = identifier.split("/") parts = identifier.split("/")
_LOGGER.debug("parts=%s", parts)
if len(parts) == 1: if len(parts) == 1:
return cls(parts[0]) return cls(parts[0])
if len(parts) != 3: if len(parts) != 3:
@ -179,27 +199,50 @@ class GooglePhotosMediaSource(MediaSource):
source = _build_account(entry, identifier) source = _build_account(entry, identifier)
if identifier.id_type is None: if identifier.id_type is None:
result = await client.list_albums(page_size=MAX_ALBUMS)
source.children = [ source.children = [
_build_album( _build_album(
RECENT_PHOTOS_TITLE, special_album.value.title,
PhotosIdentifier.album( 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 return source
# Currently only supports listing a single album of recent photos. if (
if identifier.media_id != RECENT_PHOTOS_ALBUM: identifier.id_type != PhotosIdentifierType.ALBUM
raise BrowseError(f"Unsupported album: {identifier}") 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]] = [] media_items: list[dict[str, Any]] = []
page_token: str | None = None 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: try:
result = await client.list_media_items( 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: except GooglePhotosApiError as err:
raise BrowseError(f"Error listing media items: {err}") from 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.""" """Build an album node."""
return BrowseMediaSource( return BrowseMediaSource(
domain=DOMAIN, domain=DOMAIN,
@ -265,6 +310,7 @@ def _build_album(title: str, identifier: PhotosIdentifier) -> BrowseMediaSource:
title=title, title=title,
can_play=False, can_play=False,
can_expand=True, 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 See https://developers.google.com/photos/library/guides/access-media-items#base-urls
""" """
return f"{media_item["baseUrl"]}=dv" 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}"

View File

@ -15,7 +15,11 @@ from homeassistant.components.google_photos.const import DOMAIN, OAUTH2_SCOPES
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component 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" USER_IDENTIFIER = "user-identifier-1"
CONFIG_ENTRY_ID = "user-identifier-1" CONFIG_ENTRY_ID = "user-identifier-1"
@ -119,6 +123,7 @@ def mock_setup_api(
return mock return mock
mock.return_value.mediaItems.return_value.list = list_media_items 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 # Mock a point lookup by reading contents of the fixture above
def get_media_item(mediaItemId: str, **kwargs: Any) -> Mock: def get_media_item(mediaItemId: str, **kwargs: Any) -> Mock:
@ -131,6 +136,10 @@ def mock_setup_api(
return None return None
mock.return_value.mediaItems.return_value.get = get_media_item 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 yield mock

View File

@ -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"
}
]
}

View File

@ -65,6 +65,14 @@ async def test_no_read_scopes(
@pytest.mark.usefixtures("setup_integration", "setup_api") @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( @pytest.mark.parametrize(
("fixture_name", "expected_results", "expected_medias"), ("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, hass: HomeAssistant,
album_path: str,
expected_album_title: str,
expected_results: list[tuple[str, str]], expected_results: list[tuple[str, str]],
expected_medias: list[tuple[str, str]], expected_medias: list[tuple[str, str]],
) -> None: ) -> None:
@ -101,14 +111,14 @@ async def test_recent_items(
assert browse.identifier == CONFIG_ENTRY_ID assert browse.identifier == CONFIG_ENTRY_ID
assert browse.title == "Account Name" assert browse.title == "Account Name"
assert [(child.identifier, child.title) for child in browse.children] == [ 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( browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{album_path}")
hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}/a/recent"
)
assert browse.domain == DOMAIN assert browse.domain == DOMAIN
assert browse.identifier == f"{CONFIG_ENTRY_ID}/a/recent" assert browse.identifier == album_path
assert browse.title == "Account Name" assert browse.title == "Account Name"
assert [ assert [
(child.identifier, child.title) for child in browse.children (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.usefixtures("setup_integration", "setup_api")
@pytest.mark.parametrize("fixture_name", ["list_mediaitems.json"]) @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.""" """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
@ -144,7 +172,12 @@ async def test_invalid_album_id(hass: HomeAssistant) -> None:
(CONFIG_ENTRY_ID, "Account Name") (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( await async_browse_media(
hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}/a/invalid-album-id" hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}/a/invalid-album-id"
) )