From 412489c10282e53305263f123a20a3a33abe5c70 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 24 Sep 2024 08:26:33 -0700 Subject: [PATCH] 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 --- .../components/google_photos/coordinator.py | 12 +- .../components/google_photos/services.py | 23 +++- .../components/google_photos/services.yaml | 4 + .../components/google_photos/strings.json | 8 ++ .../components/google_photos/test_services.py | 112 +++++++++++++++++- 5 files changed, 153 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/google_photos/coordinator.py b/homeassistant/components/google_photos/coordinator.py index 1c22740cbd0..3ba5a8124d6 100644 --- a/homeassistant/components/google_photos/coordinator.py +++ b/homeassistant/components/google_photos/coordinator.py @@ -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 diff --git a/homeassistant/components/google_photos/services.py b/homeassistant/components/google_photos/services.py index 0be213c6981..f23a706b2e2 100644 --- a/homeassistant/components/google_photos/services.py +++ b/homeassistant/components/google_photos/services.py @@ -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 diff --git a/homeassistant/components/google_photos/services.yaml b/homeassistant/components/google_photos/services.yaml index 047305c0bca..ec3b94c453b 100644 --- a/homeassistant/components/google_photos/services.yaml +++ b/homeassistant/components/google_photos/services.yaml @@ -9,3 +9,7 @@ upload: required: false selector: object: + album: + required: true + selector: + text: diff --git a/homeassistant/components/google_photos/strings.json b/homeassistant/components/google_photos/strings.json index 17e018dabee..2333783fc00 100644 --- a/homeassistant/components/google_photos/strings.json +++ b/homeassistant/components/google_photos/strings.json @@ -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" } } } diff --git a/tests/components/google_photos/test_services.py b/tests/components/google_photos/test_services.py index 10f4543bcc2..381fb1c431f 100644 --- a/tests/components/google_photos/test_services.py +++ b/tests/components/google_photos/test_services.py @@ -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,