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:
parent
437bbe5c6e
commit
412489c102
5 changed files with 153 additions and 6 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -9,3 +9,7 @@ upload:
|
|||
required: false
|
||||
selector:
|
||||
object:
|
||||
album:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Reference in a new issue