mirror of
https://github.com/home-assistant/core.git
synced 2025-07-21 12:17:07 +00:00
Add Google Photos media source support for albums and favorites (#124985)
This commit is contained in:
parent
ef84a8869e
commit
30772da0e1
@ -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:
|
||||||
|
@ -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}"
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
12
tests/components/google_photos/fixtures/list_albums.json
Normal file
12
tests/components/google_photos/fixtures/list_albums.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
@ -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"
|
||||||
)
|
)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user