Address additional Google Photos integration feedback (#124957)

* Address review feedback

* Fix typing for  arguments
This commit is contained in:
Allen Porter 2024-08-31 01:10:45 -07:00 committed by GitHub
parent 3bfcb1ebdd
commit 2cab9f7fe9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 72 additions and 55 deletions

View File

@ -42,7 +42,7 @@ class OAuth2FlowHandler(
client = api.AsyncConfigFlowAuth(self.hass, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) client = api.AsyncConfigFlowAuth(self.hass, data[CONF_TOKEN][CONF_ACCESS_TOKEN])
try: try:
user_resource_info = await client.get_user_info() user_resource_info = await client.get_user_info()
await client.list_media_items() await client.list_media_items(page_size=1)
except GooglePhotosApiError as ex: except GooglePhotosApiError as ex:
return self.async_abort( return self.async_abort(
reason="access_not_configured", reason="access_not_configured",

View File

@ -1,6 +1,7 @@
"""Media source for Google Photos.""" """Media source for Google Photos."""
from dataclasses import dataclass from dataclasses import dataclass
from enum import StrEnum
import logging import logging
from typing import Any, cast from typing import Any, cast
@ -28,7 +29,7 @@ MAX_PHOTOS = 50
PAGE_SIZE = 100 PAGE_SIZE = 100
THUMBNAIL_SIZE = 256 THUMBNAIL_SIZE = 256
LARGE_IMAGE_SIZE = 2048 LARGE_IMAGE_SIZE = 2160
# Markers for parts of PhotosIdentifier url pattern. # Markers for parts of PhotosIdentifier url pattern.
@ -47,6 +48,21 @@ RECENT_PHOTOS_ALBUM = "recent"
RECENT_PHOTOS_TITLE = "Recent Photos" RECENT_PHOTOS_TITLE = "Recent Photos"
class PhotosIdentifierType(StrEnum):
"""Type for a PhotosIdentifier."""
PHOTO = "p"
ALBUM = "a"
@classmethod
def of(cls, name: str) -> "PhotosIdentifierType":
"""Parse a PhotosIdentifierType by string value."""
for enum in PhotosIdentifierType:
if enum.value == name:
return enum
raise ValueError(f"Invalid PhotosIdentifierType: {name}")
@dataclass @dataclass
class PhotosIdentifier: class PhotosIdentifier:
"""Google Photos item identifier in a media source URL.""" """Google Photos item identifier in a media source URL."""
@ -54,47 +70,48 @@ class PhotosIdentifier:
config_entry_id: str config_entry_id: str
"""Identifies the account for the media item.""" """Identifies the account for the media item."""
album_media_id: str | None = None id_type: PhotosIdentifierType | None = None
"""Identifies the album contents to show. """Type of identifier"""
Not present at the same time as `photo_media_id`. media_id: str | None = None
""" """Identifies the album or photo contents to show."""
photo_media_id: str | None = None
"""Identifies an indiviidual photo or video.
Not present at the same time as `album_media_id`.
"""
def as_string(self) -> str: def as_string(self) -> str:
"""Serialize the identiifer as a string. """Serialize the identiifer as a string.
This is the opposite if parse_identifier(). This is the opposite if of().
""" """
if self.photo_media_id is None: if self.id_type is None:
if self.album_media_id is None: return self.config_entry_id
return self.config_entry_id return f"{self.config_entry_id}/{self.id_type}/{self.media_id}"
return f"{self.config_entry_id}/{PHOTO_SOURCE_IDENTIFIER_ALBUM}/{self.album_media_id}"
return f"{self.config_entry_id}/{PHOTO_SOURCE_IDENTIFIER_PHOTO}/{self.photo_media_id}"
@staticmethod
def of(identifier: str) -> "PhotosIdentifier":
"""Parse a PhotosIdentifier form a string.
def parse_identifier(identifier: str) -> PhotosIdentifier: This is the opposite of as_string().
"""Parse a PhotosIdentifier form a string. """
parts = identifier.split("/")
_LOGGER.debug("parts=%s", parts)
if len(parts) == 1:
return PhotosIdentifier(parts[0])
if len(parts) != 3:
raise BrowseError(f"Invalid identifier: {identifier}")
return PhotosIdentifier(parts[0], PhotosIdentifierType.of(parts[1]), parts[2])
This is the opposite of as_string(). @staticmethod
""" def album(config_entry_id: str, media_id: str) -> "PhotosIdentifier":
parts = identifier.split("/") """Create an album PhotosIdentifier."""
if len(parts) == 1: return PhotosIdentifier(config_entry_id, PhotosIdentifierType.ALBUM, media_id)
return PhotosIdentifier(parts[0])
if len(parts) != 3: @staticmethod
raise BrowseError(f"Invalid identifier: {identifier}") def photo(config_entry_id: str, media_id: str) -> "PhotosIdentifier":
if parts[1] == PHOTO_SOURCE_IDENTIFIER_PHOTO: """Create an album PhotosIdentifier."""
return PhotosIdentifier(parts[0], photo_media_id=parts[2]) return PhotosIdentifier(config_entry_id, PhotosIdentifierType.PHOTO, media_id)
return PhotosIdentifier(parts[0], album_media_id=parts[2])
async def async_get_media_source(hass: HomeAssistant) -> MediaSource: async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
"""Set up Synology media source.""" """Set up Google Photos media source."""
return GooglePhotosMediaSource(hass) return GooglePhotosMediaSource(hass)
@ -113,16 +130,20 @@ class GooglePhotosMediaSource(MediaSource):
This will resolve a specific media item to a url for the full photo or video contents. This will resolve a specific media item to a url for the full photo or video contents.
""" """
identifier = parse_identifier(item.identifier) try:
if identifier.photo_media_id is None: identifier = PhotosIdentifier.of(item.identifier)
except ValueError as err:
raise BrowseError(f"Could not parse identifier: {item.identifier}") from err
if (
identifier.media_id is None
or identifier.id_type != PhotosIdentifierType.PHOTO
):
raise BrowseError( raise BrowseError(
f"Could not resolve identifier without a photo_media_id: {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
media_item = await client.get_media_item( media_item = await client.get_media_item(media_item_id=identifier.media_id)
media_item_id=identifier.photo_media_id
)
is_video = media_item["mediaMetadata"].get("video") is not None is_video = media_item["mediaMetadata"].get("video") is not None
return PlayMedia( return PlayMedia(
url=( url=(
@ -158,24 +179,24 @@ class GooglePhotosMediaSource(MediaSource):
) )
# Determine the configuration entry for this item # Determine the configuration entry for this item
identifier = parse_identifier(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 client = entry.runtime_data
if identifier.album_media_id is None: source = _build_account(entry, identifier)
source = _build_account(entry, identifier) if identifier.id_type is None:
source.children = [ source.children = [
_build_album( _build_album(
RECENT_PHOTOS_TITLE, RECENT_PHOTOS_TITLE,
PhotosIdentifier( PhotosIdentifier.album(
identifier.config_entry_id, album_media_id=RECENT_PHOTOS_ALBUM identifier.config_entry_id, RECENT_PHOTOS_ALBUM
), ),
) )
] ]
return source return source
# Currently only supports listing a single album of recent photos. # Currently only supports listing a single album of recent photos.
if identifier.album_media_id != RECENT_PHOTOS_ALBUM: if identifier.media_id != RECENT_PHOTOS_ALBUM:
raise BrowseError(f"Unsupported album: {identifier}") raise BrowseError(f"Unsupported album: {identifier}")
# Fetch recent items # Fetch recent items
@ -194,12 +215,9 @@ class GooglePhotosMediaSource(MediaSource):
break break
# Render the grid of media item results # Render the grid of media item results
source = _build_account(entry, PhotosIdentifier(cast(str, entry.unique_id)))
source.children = [ source.children = [
_build_media_item( _build_media_item(
PhotosIdentifier( PhotosIdentifier.photo(identifier.config_entry_id, media_item["id"]),
identifier.config_entry_id, photo_media_id=media_item["id"]
),
media_item, media_item,
) )
for media_item in media_items for media_item in media_items
@ -250,7 +268,7 @@ def _build_album(title: str, identifier: PhotosIdentifier) -> BrowseMediaSource:
def _build_media_item( def _build_media_item(
identifier: PhotosIdentifier, media_item: dict[str, Any] identifier: PhotosIdentifier, media_item: dict[str, Any]
) -> BrowseMediaSource: ) -> BrowseMediaSource:
"""Build the node for an individual photos or video.""" """Build the node for an individual photo or video."""
is_video = media_item["mediaMetadata"].get("video") is not None is_video = media_item["mediaMetadata"].get("video") is not None
return BrowseMediaSource( return BrowseMediaSource(
domain=DOMAIN, domain=DOMAIN,
@ -269,10 +287,7 @@ def _media_url(media_item: dict[str, Any], max_size: int) -> 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
""" """
width = media_item["mediaMetadata"]["width"] return f"{media_item["baseUrl"]}=h{max_size}"
height = media_item["mediaMetadata"]["height"]
key = "h" if height > width else "w"
return f"{media_item["baseUrl"]}={key}{max_size}"
def _video_url(media_item: dict[str, Any]) -> str: def _video_url(media_item: dict[str, Any]) -> str:

View File

@ -58,7 +58,7 @@ async def test_no_config_entries(
(f"{CONFIG_ENTRY_ID}/p/id2", "example2.mp4"), (f"{CONFIG_ENTRY_ID}/p/id2", "example2.mp4"),
], ],
[ [
("http://img.example.com/id1=w2048", "image/jpeg"), ("http://img.example.com/id1=h2160", "image/jpeg"),
("http://img.example.com/id2=dv", "video/mp4"), ("http://img.example.com/id2=dv", "video/mp4"),
], ],
), ),
@ -90,7 +90,7 @@ async def test_recent_items(
hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}/a/recent" hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}/a/recent"
) )
assert browse.domain == DOMAIN assert browse.domain == DOMAIN
assert browse.identifier == CONFIG_ENTRY_ID assert browse.identifier == f"{CONFIG_ENTRY_ID}/a/recent"
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
@ -136,7 +136,9 @@ async def test_invalid_album_id(hass: HomeAssistant) -> None:
@pytest.mark.parametrize( @pytest.mark.parametrize(
("identifier", "expected_error"), ("identifier", "expected_error"),
[ [
("invalid-config-entry", "without a photo_media_id"), (CONFIG_ENTRY_ID, "not a Photo"),
("invalid-config-entry/a/example", "not a Photo"),
("invalid-config-entry/q/example", "Could not parse"),
("too/many/slashes/in/path", "Invalid identifier"), ("too/many/slashes/in/path", "Invalid identifier"),
], ],
) )