Require Google Photos uploads to target an album (#126651)

* Require uploads to target an album

* Remove edge case where albums are not loaded on startup which no longer happens

* Update homeassistant/components/google_photos/strings.json

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Allen Porter 2024-09-24 08:26:33 -07:00 committed by GitHub
parent 437bbe5c6e
commit 412489c102
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 153 additions and 6 deletions

View file

@ -13,7 +13,7 @@ from typing import Final
from google_photos_library_api.api import GooglePhotosLibraryApi
from google_photos_library_api.exceptions import GooglePhotosApiError
from google_photos_library_api.model import Album
from google_photos_library_api.model import Album, NewAlbum
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@ -59,3 +59,13 @@ class GooglePhotosUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]):
return await asyncio.gather(
*(self.client.get_album(album_id) for album_id in self.data)
)
async def get_or_create_album(self, album: str) -> str:
"""Return an existing album id or create a new one."""
for album_id, album_title in self.data.items():
if album_title == album:
return album_id
new_album = await self.client.create_album(NewAlbum(title=album))
_LOGGER.debug("Created new album: %s", new_album)
self.data[new_album.id] = new_album.title
return new_album.id

View file

@ -24,12 +24,14 @@ from .const import DOMAIN, UPLOAD_SCOPE
from .types import GooglePhotosConfigEntry
CONF_CONFIG_ENTRY_ID = "config_entry_id"
CONF_ALBUM = "album"
UPLOAD_SERVICE = "upload"
UPLOAD_SERVICE_SCHEMA = vol.Schema(
{
vol.Required(CONF_CONFIG_ENTRY_ID): cv.string,
vol.Required(CONF_FILENAME): vol.All(cv.ensure_list, [cv.string]),
vol.Required(CONF_ALBUM): cv.string,
}
)
CONTENT_SIZE_LIMIT = 20 * 1024 * 1024
@ -96,12 +98,23 @@ def async_register_services(hass: HomeAssistant) -> None:
translation_key="missing_upload_permission",
translation_placeholders={"target": DOMAIN},
)
client_api = config_entry.runtime_data.client
coordinator = config_entry.runtime_data
client_api = coordinator.client
upload_tasks = []
file_results = await hass.async_add_executor_job(
_read_file_contents, hass, call.data[CONF_FILENAME]
)
album = call.data[CONF_ALBUM]
try:
album_id = await coordinator.get_or_create_album(album)
except GooglePhotosApiError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="create_album_error",
translation_placeholders={"message": str(err)},
) from err
for mime_type, content in file_results:
upload_tasks.append(client_api.upload_content(content, mime_type))
try:
@ -119,7 +132,8 @@ def async_register_services(hass: HomeAssistant) -> None:
SimpleMediaItem(upload_token=upload_result.upload_token)
)
for upload_result in upload_results
]
],
album_id=album_id,
)
except GooglePhotosApiError as err:
raise HomeAssistantError(
@ -135,7 +149,8 @@ def async_register_services(hass: HomeAssistant) -> None:
for item_result in upload_result.new_media_item_results
if item_result.media_item and item_result.media_item.id
}
]
],
"album_id": album_id,
}
return None

View file

@ -9,3 +9,7 @@ upload:
required: false
selector:
object:
album:
required: true
selector:
text:

View file

@ -52,6 +52,9 @@
"upload_error": {
"message": "Failed to upload content: {message}"
},
"create_album_error": {
"message": "Failed to create album: {message}"
},
"api_error": {
"message": "Google Photos API responded with error: {message}"
},
@ -72,6 +75,11 @@
"name": "Filename",
"description": "Path to the image or video to upload.",
"example": "/config/www/image.jpg"
},
"album": {
"name": "Album",
"description": "Album name that is the destination for the uploaded content.",
"example": "Family photos"
}
}
}

View file

@ -7,6 +7,7 @@ from unittest.mock import Mock, patch
from google_photos_library_api.exceptions import GooglePhotosApiError
from google_photos_library_api.model import (
Album,
CreateMediaItemsResult,
MediaItem,
NewMediaItemResult,
@ -16,6 +17,7 @@ import pytest
from homeassistant.components.google_photos.const import DOMAIN, READ_SCOPE
from homeassistant.components.google_photos.services import (
CONF_ALBUM,
CONF_CONFIG_ENTRY_ID,
UPLOAD_SERVICE,
)
@ -27,6 +29,7 @@ from homeassistant.exceptions import HomeAssistantError
from tests.common import MockConfigEntry
TEST_FILENAME = "doorbell_snapshot.jpg"
ALBUM_TITLE = "Album title"
@dataclass
@ -96,12 +99,16 @@ async def test_upload_service(
{
CONF_CONFIG_ENTRY_ID: config_entry.entry_id,
CONF_FILENAME: TEST_FILENAME,
CONF_ALBUM: ALBUM_TITLE,
},
blocking=True,
return_response=True,
)
assert response == {"media_items": [{"media_item_id": "new-media-item-id-1"}]}
assert response == {
"media_items": [{"media_item_id": "new-media-item-id-1"}],
"album_id": "album-media-id-1",
}
@pytest.mark.usefixtures("setup_integration")
@ -117,6 +124,7 @@ async def test_upload_service_config_entry_not_found(
{
CONF_CONFIG_ENTRY_ID: "invalid-config-entry-id",
CONF_FILENAME: TEST_FILENAME,
CONF_ALBUM: ALBUM_TITLE,
},
blocking=True,
return_response=True,
@ -141,6 +149,7 @@ async def test_config_entry_not_loaded(
{
CONF_CONFIG_ENTRY_ID: config_entry.unique_id,
CONF_FILENAME: TEST_FILENAME,
CONF_ALBUM: ALBUM_TITLE,
},
blocking=True,
return_response=True,
@ -163,6 +172,7 @@ async def test_path_is_not_allowed(
{
CONF_CONFIG_ENTRY_ID: config_entry.entry_id,
CONF_FILENAME: TEST_FILENAME,
CONF_ALBUM: ALBUM_TITLE,
},
blocking=True,
return_response=True,
@ -183,6 +193,7 @@ async def test_filename_does_not_exist(
{
CONF_CONFIG_ENTRY_ID: config_entry.entry_id,
CONF_FILENAME: TEST_FILENAME,
CONF_ALBUM: ALBUM_TITLE,
},
blocking=True,
return_response=True,
@ -206,6 +217,7 @@ async def test_upload_service_upload_content_failure(
{
CONF_CONFIG_ENTRY_ID: config_entry.entry_id,
CONF_FILENAME: TEST_FILENAME,
CONF_ALBUM: ALBUM_TITLE,
},
blocking=True,
return_response=True,
@ -231,6 +243,7 @@ async def test_upload_service_fails_create(
{
CONF_CONFIG_ENTRY_ID: config_entry.entry_id,
CONF_FILENAME: TEST_FILENAME,
CONF_ALBUM: ALBUM_TITLE,
},
blocking=True,
return_response=True,
@ -257,6 +270,7 @@ async def test_upload_service_no_scope(
{
CONF_CONFIG_ENTRY_ID: config_entry.entry_id,
CONF_FILENAME: TEST_FILENAME,
CONF_ALBUM: ALBUM_TITLE,
},
blocking=True,
return_response=True,
@ -280,6 +294,102 @@ async def test_upload_size_limit(
{
CONF_CONFIG_ENTRY_ID: config_entry.entry_id,
CONF_FILENAME: TEST_FILENAME,
CONF_ALBUM: ALBUM_TITLE,
},
blocking=True,
return_response=True,
)
@pytest.mark.usefixtures("setup_integration")
async def test_upload_to_new_album(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_api: Mock,
) -> None:
"""Test service call to upload content to a new album."""
assert hass.services.has_service(DOMAIN, "upload")
mock_api.create_media_items.return_value = CreateMediaItemsResult(
new_media_item_results=[
NewMediaItemResult(
upload_token="some-upload-token",
status=Status(code=200),
media_item=MediaItem(id="new-media-item-id-1"),
)
]
)
mock_api.create_album.return_value = Album(id="album-media-id-2", title="New Album")
response = await hass.services.async_call(
DOMAIN,
UPLOAD_SERVICE,
{
CONF_CONFIG_ENTRY_ID: config_entry.entry_id,
CONF_FILENAME: TEST_FILENAME,
CONF_ALBUM: "New Album",
},
blocking=True,
return_response=True,
)
# Verify media item was created with the new album id
mock_api.create_album.assert_awaited()
assert response == {
"media_items": [{"media_item_id": "new-media-item-id-1"}],
"album_id": "album-media-id-2",
}
# Upload an additional item to the same album and assert that no new album is created
mock_api.create_album.reset_mock()
mock_api.create_media_items.reset_mock()
mock_api.create_media_items.return_value = CreateMediaItemsResult(
new_media_item_results=[
NewMediaItemResult(
upload_token="some-upload-token",
status=Status(code=200),
media_item=MediaItem(id="new-media-item-id-3"),
)
]
)
response = await hass.services.async_call(
DOMAIN,
UPLOAD_SERVICE,
{
CONF_CONFIG_ENTRY_ID: config_entry.entry_id,
CONF_FILENAME: TEST_FILENAME,
CONF_ALBUM: "New Album",
},
blocking=True,
return_response=True,
)
# Verify the album created last time is used
mock_api.create_album.assert_not_awaited()
assert response == {
"media_items": [{"media_item_id": "new-media-item-id-3"}],
"album_id": "album-media-id-2",
}
@pytest.mark.usefixtures("setup_integration")
async def test_create_album_failed(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_api: Mock,
) -> None:
"""Test service call to upload content to a new album but creating the album fails."""
assert hass.services.has_service(DOMAIN, "upload")
mock_api.create_album.side_effect = GooglePhotosApiError()
with pytest.raises(HomeAssistantError, match="Failed to create album"):
await hass.services.async_call(
DOMAIN,
UPLOAD_SERVICE,
{
CONF_CONFIG_ENTRY_ID: config_entry.entry_id,
CONF_FILENAME: TEST_FILENAME,
CONF_ALBUM: "New Album",
},
blocking=True,
return_response=True,