Add upload_file action to immich integration (#147295)

Co-authored-by: Norbert Rittel <norbert@rittel.de>
This commit is contained in:
Michael 2025-07-28 13:12:16 +02:00 committed by GitHub
parent 18c5437fe7
commit 40ce228c9c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 475 additions and 1 deletions

View File

@ -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."""

View File

@ -11,5 +11,10 @@
"default": "mdi:file-video"
}
}
},
"services": {
"upload_file": {
"service": "mdi:upload"
}
}
}

View 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,
)

View 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:

View File

@ -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})."
}
}
}

View File

@ -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."""

View 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,
)