diff --git a/homeassistant/components/immich/__init__.py b/homeassistant/components/immich/__init__.py index d40615dbe88..996e4f3ad8c 100644 --- a/homeassistant/components/immich/__init__.py +++ b/homeassistant/components/immich/__init__.py @@ -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.""" diff --git a/homeassistant/components/immich/icons.json b/homeassistant/components/immich/icons.json index 15bac6370a6..aefce3ed615 100644 --- a/homeassistant/components/immich/icons.json +++ b/homeassistant/components/immich/icons.json @@ -11,5 +11,10 @@ "default": "mdi:file-video" } } + }, + "services": { + "upload_file": { + "service": "mdi:upload" + } } } diff --git a/homeassistant/components/immich/services.py b/homeassistant/components/immich/services.py new file mode 100644 index 00000000000..fffd5d9110b --- /dev/null +++ b/homeassistant/components/immich/services.py @@ -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, + ) diff --git a/homeassistant/components/immich/services.yaml b/homeassistant/components/immich/services.yaml new file mode 100644 index 00000000000..7924a6a112c --- /dev/null +++ b/homeassistant/components/immich/services.yaml @@ -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: diff --git a/homeassistant/components/immich/strings.json b/homeassistant/components/immich/strings.json index 83ee7574630..90fccfa1bb1 100644 --- a/homeassistant/components/immich/strings.json +++ b/homeassistant/components/immich/strings.json @@ -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})." + } } } diff --git a/tests/components/immich/conftest.py b/tests/components/immich/conftest.py index 6c7813cbd85..48e36e70386 100644 --- a/tests/components/immich/conftest.py +++ b/tests/components/immich/conftest.py @@ -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.""" diff --git a/tests/components/immich/test_services.py b/tests/components/immich/test_services.py new file mode 100644 index 00000000000..5ba7cf96408 --- /dev/null +++ b/tests/components/immich/test_services.py @@ -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, + )