mirror of
https://github.com/home-assistant/core.git
synced 2025-07-25 22:27:07 +00:00
Address additional Google Photos integration feedback (#124957)
* Address review feedback * Fix typing for arguments
This commit is contained in:
parent
3bfcb1ebdd
commit
2cab9f7fe9
@ -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",
|
||||||
|
@ -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:
|
||||||
|
@ -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"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user