mirror of
https://github.com/home-assistant/core.git
synced 2025-07-29 08:07:45 +00:00
Add upload_file action to immich integration (#147295)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
This commit is contained in:
parent
18c5437fe7
commit
40ce228c9c
@ -16,13 +16,25 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import ImmichConfigEntry, ImmichDataUpdateCoordinator
|
||||
from .services import async_setup_services
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.UPDATE]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up immich integration."""
|
||||
await async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bool:
|
||||
"""Set up Immich from a config entry."""
|
||||
|
||||
|
@ -11,5 +11,10 @@
|
||||
"default": "mdi:file-video"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"upload_file": {
|
||||
"service": "mdi:upload"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
98
homeassistant/components/immich/services.py
Normal file
98
homeassistant/components/immich/services.py
Normal file
@ -0,0 +1,98 @@
|
||||
"""Services for the Immich integration."""
|
||||
|
||||
import logging
|
||||
|
||||
from aioimmich.exceptions import ImmichError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.media_source import async_resolve_media
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers.selector import MediaSelector
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import ImmichConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_ALBUM_ID = "album_id"
|
||||
CONF_CONFIG_ENTRY_ID = "config_entry_id"
|
||||
CONF_FILE = "file"
|
||||
|
||||
SERVICE_UPLOAD_FILE = "upload_file"
|
||||
SERVICE_SCHEMA_UPLOAD_FILE = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_CONFIG_ENTRY_ID): str,
|
||||
vol.Required(CONF_FILE): MediaSelector({"accept": ["image/*", "video/*"]}),
|
||||
vol.Optional(CONF_ALBUM_ID): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _async_upload_file(service_call: ServiceCall) -> None:
|
||||
"""Call immich upload file service."""
|
||||
_LOGGER.debug(
|
||||
"Executing service %s with arguments %s",
|
||||
service_call.service,
|
||||
service_call.data,
|
||||
)
|
||||
hass = service_call.hass
|
||||
target_entry: ImmichConfigEntry | None = hass.config_entries.async_get_entry(
|
||||
service_call.data[CONF_CONFIG_ENTRY_ID]
|
||||
)
|
||||
source_media_id = service_call.data[CONF_FILE]["media_content_id"]
|
||||
|
||||
if not target_entry:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_not_found",
|
||||
)
|
||||
|
||||
if target_entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_not_loaded",
|
||||
)
|
||||
|
||||
media = await async_resolve_media(hass, source_media_id, None)
|
||||
if media.path is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN, translation_key="only_local_media_supported"
|
||||
)
|
||||
|
||||
coordinator = target_entry.runtime_data
|
||||
|
||||
if target_album := service_call.data.get(CONF_ALBUM_ID):
|
||||
try:
|
||||
await coordinator.api.albums.async_get_album_info(target_album, True)
|
||||
except ImmichError as ex:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="album_not_found",
|
||||
translation_placeholders={"album_id": target_album, "error": str(ex)},
|
||||
) from ex
|
||||
|
||||
try:
|
||||
upload_result = await coordinator.api.assets.async_upload_asset(str(media.path))
|
||||
if target_album:
|
||||
await coordinator.api.albums.async_add_assets_to_album(
|
||||
target_album, [upload_result.asset_id]
|
||||
)
|
||||
except ImmichError as ex:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="upload_failed",
|
||||
translation_placeholders={"file": str(media.path), "error": str(ex)},
|
||||
) from ex
|
||||
|
||||
|
||||
async def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services for immich integration."""
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_UPLOAD_FILE,
|
||||
_async_upload_file,
|
||||
SERVICE_SCHEMA_UPLOAD_FILE,
|
||||
)
|
18
homeassistant/components/immich/services.yaml
Normal file
18
homeassistant/components/immich/services.yaml
Normal file
@ -0,0 +1,18 @@
|
||||
upload_file:
|
||||
fields:
|
||||
config_entry_id:
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: immich
|
||||
file:
|
||||
required: true
|
||||
selector:
|
||||
media:
|
||||
accept:
|
||||
- image/*
|
||||
- video/*
|
||||
album_id:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
@ -74,5 +74,42 @@
|
||||
"name": "Version"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"upload_file": {
|
||||
"name": "Upload file",
|
||||
"description": "Uploads a file to your Immich instance.",
|
||||
"fields": {
|
||||
"config_entry_id": {
|
||||
"name": "Immich instance",
|
||||
"description": "The Immich instance where to upload the file."
|
||||
},
|
||||
"file": {
|
||||
"name": "File",
|
||||
"description": "The path to the file to be uploaded."
|
||||
},
|
||||
"album_id": {
|
||||
"name": "Album ID",
|
||||
"description": "The album in which the file should be placed after uploading."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"config_entry_not_found": {
|
||||
"message": "Config entry not found."
|
||||
},
|
||||
"config_entry_not_loaded": {
|
||||
"message": "Config entry not loaded."
|
||||
},
|
||||
"only_local_media_supported": {
|
||||
"message": "Only local media files are currently supported."
|
||||
},
|
||||
"album_not_found": {
|
||||
"message": "Album with ID `{album_id}` not found ({error})."
|
||||
},
|
||||
"upload_failed": {
|
||||
"message": "Upload of file `{file}` failed ({error})."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,12 @@
|
||||
"""Common fixtures for the Immich tests."""
|
||||
|
||||
from collections.abc import AsyncGenerator, Generator
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from aioimmich import ImmichAlbums, ImmichAssests, ImmichServer, ImmichUsers
|
||||
from aioimmich.albums.models import ImmichAddAssetsToAlbumResponse
|
||||
from aioimmich.assets.models import ImmichAssetUploadResponse
|
||||
from aioimmich.server.models import (
|
||||
ImmichServerAbout,
|
||||
ImmichServerStatistics,
|
||||
@ -14,6 +17,7 @@ from aioimmich.users.models import ImmichUserObject
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.immich.const import DOMAIN
|
||||
from homeassistant.components.media_source import PlayMedia
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_HOST,
|
||||
@ -62,6 +66,12 @@ def mock_immich_albums() -> AsyncMock:
|
||||
mock = AsyncMock(spec=ImmichAlbums)
|
||||
mock.async_get_all_albums.return_value = [MOCK_ALBUM_WITHOUT_ASSETS]
|
||||
mock.async_get_album_info.return_value = MOCK_ALBUM_WITH_ASSETS
|
||||
mock.async_add_assets_to_album.return_value = [
|
||||
ImmichAddAssetsToAlbumResponse.from_dict(
|
||||
{"id": "abcdef-0123456789", "success": True}
|
||||
)
|
||||
]
|
||||
|
||||
return mock
|
||||
|
||||
|
||||
@ -71,6 +81,9 @@ def mock_immich_assets() -> AsyncMock:
|
||||
mock = AsyncMock(spec=ImmichAssests)
|
||||
mock.async_view_asset.return_value = b"xxxx"
|
||||
mock.async_play_video_stream.return_value = MockStreamReaderChunked(b"xxxx")
|
||||
mock.async_upload_asset.return_value = ImmichAssetUploadResponse.from_dict(
|
||||
{"id": "abcdef-0123456789", "status": "created"}
|
||||
)
|
||||
return mock
|
||||
|
||||
|
||||
@ -195,6 +208,20 @@ async def mock_non_admin_immich(mock_immich: AsyncMock) -> AsyncMock:
|
||||
return mock_immich
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_media_source() -> Generator[MagicMock]:
|
||||
"""Mock the media source."""
|
||||
with patch(
|
||||
"homeassistant.components.immich.services.async_resolve_media",
|
||||
return_value=PlayMedia(
|
||||
url="media-source://media_source/local/screenshot.jpg",
|
||||
mime_type="image/jpeg",
|
||||
path=Path("/media/screenshot.jpg"),
|
||||
),
|
||||
) as mock_media:
|
||||
yield mock_media
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def setup_media_source(hass: HomeAssistant) -> None:
|
||||
"""Set up media source."""
|
||||
|
277
tests/components/immich/test_services.py
Normal file
277
tests/components/immich/test_services.py
Normal file
@ -0,0 +1,277 @@
|
||||
"""Test the Immich services."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from aioimmich.exceptions import ImmichError, ImmichNotFoundError
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.immich.const import DOMAIN
|
||||
from homeassistant.components.immich.services import SERVICE_UPLOAD_FILE
|
||||
from homeassistant.components.media_source import PlayMedia
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_setup_services(
|
||||
hass: HomeAssistant,
|
||||
mock_immich: Mock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test setup of immich services."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
services = hass.services.async_services_for_domain(DOMAIN)
|
||||
assert services
|
||||
assert SERVICE_UPLOAD_FILE in services
|
||||
|
||||
|
||||
async def test_upload_file(
|
||||
hass: HomeAssistant,
|
||||
mock_immich: Mock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_media_source: Mock,
|
||||
) -> None:
|
||||
"""Test upload_file service."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_UPLOAD_FILE,
|
||||
{
|
||||
"config_entry_id": mock_config_entry.entry_id,
|
||||
"file": {
|
||||
"media_content_id": "media-source://media_source/local/screenshot.jpg",
|
||||
"media_content_type": "image/jpeg",
|
||||
},
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_immich.assets.async_upload_asset.assert_called_with("/media/screenshot.jpg")
|
||||
mock_immich.albums.async_get_album_info.assert_not_called()
|
||||
mock_immich.albums.async_add_assets_to_album.assert_not_called()
|
||||
|
||||
|
||||
async def test_upload_file_to_album(
|
||||
hass: HomeAssistant,
|
||||
mock_immich: Mock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_media_source: Mock,
|
||||
) -> None:
|
||||
"""Test upload_file service with target album_id."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_UPLOAD_FILE,
|
||||
{
|
||||
"config_entry_id": mock_config_entry.entry_id,
|
||||
"file": {
|
||||
"media_content_id": "media-source://media_source/local/screenshot.jpg",
|
||||
"media_content_type": "image/jpeg",
|
||||
},
|
||||
"album_id": "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_immich.assets.async_upload_asset.assert_called_with("/media/screenshot.jpg")
|
||||
mock_immich.albums.async_get_album_info.assert_called_with(
|
||||
"721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", True
|
||||
)
|
||||
mock_immich.albums.async_add_assets_to_album.assert_called_with(
|
||||
"721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", ["abcdef-0123456789"]
|
||||
)
|
||||
|
||||
|
||||
async def test_upload_file_config_entry_not_found(
|
||||
hass: HomeAssistant,
|
||||
mock_immich: Mock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test upload_file service raising config_entry_not_found."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
with pytest.raises(ServiceValidationError, match="Config entry not found"):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_UPLOAD_FILE,
|
||||
{
|
||||
"config_entry_id": "unknown_entry",
|
||||
"file": {
|
||||
"media_content_id": "media-source://media_source/local/screenshot.jpg",
|
||||
"media_content_type": "image/jpeg",
|
||||
},
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_upload_file_config_entry_not_loaded(
|
||||
hass: HomeAssistant,
|
||||
mock_immich: Mock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test upload_file service raising config_entry_not_loaded."""
|
||||
mock_config_entry.disabled_by = er.RegistryEntryDisabler.USER
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
with pytest.raises(ServiceValidationError, match="Config entry not loaded"):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_UPLOAD_FILE,
|
||||
{
|
||||
"config_entry_id": mock_config_entry.entry_id,
|
||||
"file": {
|
||||
"media_content_id": "media-source://media_source/local/screenshot.jpg",
|
||||
"media_content_type": "image/jpeg",
|
||||
},
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_upload_file_only_local_media_supported(
|
||||
hass: HomeAssistant,
|
||||
mock_immich: Mock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_media_source: Mock,
|
||||
) -> None:
|
||||
"""Test upload_file service raising only_local_media_supported."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.immich.services.async_resolve_media",
|
||||
return_value=PlayMedia(
|
||||
url="media-source://media_source/camera/some_entity_id",
|
||||
mime_type="image/jpeg",
|
||||
path=None, # Simulate non-local media
|
||||
),
|
||||
),
|
||||
pytest.raises(
|
||||
ServiceValidationError,
|
||||
match="Only local media files are currently supported",
|
||||
),
|
||||
):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_UPLOAD_FILE,
|
||||
{
|
||||
"config_entry_id": mock_config_entry.entry_id,
|
||||
"file": {
|
||||
"media_content_id": "media-source://media_source/local/screenshot.jpg",
|
||||
"media_content_type": "image/jpeg",
|
||||
},
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_upload_file_album_not_found(
|
||||
hass: HomeAssistant,
|
||||
mock_immich: Mock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_media_source: Mock,
|
||||
) -> None:
|
||||
"""Test upload_file service raising album_not_found."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
mock_immich.albums.async_get_album_info.side_effect = ImmichNotFoundError(
|
||||
{
|
||||
"message": "Not found or no album.read access",
|
||||
"error": "Bad Request",
|
||||
"statusCode": 400,
|
||||
"correlationId": "nyzxjkno",
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ServiceValidationError,
|
||||
match="Album with ID `721e1a4b-aa12-441e-8d3b-5ac7ab283bb6` not found",
|
||||
):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_UPLOAD_FILE,
|
||||
{
|
||||
"config_entry_id": mock_config_entry.entry_id,
|
||||
"file": {
|
||||
"media_content_id": "media-source://media_source/local/screenshot.jpg",
|
||||
"media_content_type": "image/jpeg",
|
||||
},
|
||||
"album_id": "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_upload_file_upload_failed(
|
||||
hass: HomeAssistant,
|
||||
mock_immich: Mock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_media_source: Mock,
|
||||
) -> None:
|
||||
"""Test upload_file service raising upload_failed."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
mock_immich.assets.async_upload_asset.side_effect = ImmichError(
|
||||
{
|
||||
"message": "Boom! Upload failed",
|
||||
"error": "Bad Request",
|
||||
"statusCode": 400,
|
||||
"correlationId": "nyzxjkno",
|
||||
}
|
||||
)
|
||||
with pytest.raises(
|
||||
ServiceValidationError, match="Upload of file `/media/screenshot.jpg` failed"
|
||||
):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_UPLOAD_FILE,
|
||||
{
|
||||
"config_entry_id": mock_config_entry.entry_id,
|
||||
"file": {
|
||||
"media_content_id": "media-source://media_source/local/screenshot.jpg",
|
||||
"media_content_type": "image/jpeg",
|
||||
},
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_upload_file_to_album_upload_failed(
|
||||
hass: HomeAssistant,
|
||||
mock_immich: Mock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_media_source: Mock,
|
||||
) -> None:
|
||||
"""Test upload_file service with target album_id raising upload_failed."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
mock_immich.albums.async_add_assets_to_album.side_effect = ImmichError(
|
||||
{
|
||||
"message": "Boom! Add to album failed.",
|
||||
"error": "Bad Request",
|
||||
"statusCode": 400,
|
||||
"correlationId": "nyzxjkno",
|
||||
}
|
||||
)
|
||||
with pytest.raises(
|
||||
ServiceValidationError, match="Upload of file `/media/screenshot.jpg` failed"
|
||||
):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_UPLOAD_FILE,
|
||||
{
|
||||
"config_entry_id": mock_config_entry.entry_id,
|
||||
"file": {
|
||||
"media_content_id": "media-source://media_source/local/screenshot.jpg",
|
||||
"media_content_type": "image/jpeg",
|
||||
},
|
||||
"album_id": "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
Loading…
x
Reference in New Issue
Block a user