diff --git a/homeassistant/components/immich/manifest.json b/homeassistant/components/immich/manifest.json index bb8cbe720fd..fe7741821b6 100644 --- a/homeassistant/components/immich/manifest.json +++ b/homeassistant/components/immich/manifest.json @@ -3,6 +3,7 @@ "name": "Immich", "codeowners": ["@mib1185"], "config_flow": true, + "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/immich", "iot_class": "local_polling", "loggers": ["aioimmich"], diff --git a/homeassistant/components/immich/media_source.py b/homeassistant/components/immich/media_source.py new file mode 100644 index 00000000000..f267433f233 --- /dev/null +++ b/homeassistant/components/immich/media_source.py @@ -0,0 +1,209 @@ +"""Immich as a media source.""" + +from __future__ import annotations + +from logging import getLogger +import mimetypes + +from aiohttp.web import HTTPNotFound, Request, Response +from aioimmich.exceptions import ImmichError + +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.media_player import MediaClass +from homeassistant.components.media_source import ( + BrowseError, + BrowseMediaSource, + MediaSource, + MediaSourceItem, + PlayMedia, + Unresolvable, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import ImmichConfigEntry + +LOGGER = getLogger(__name__) + + +async def async_get_media_source(hass: HomeAssistant) -> MediaSource: + """Set up Immich media source.""" + entries = hass.config_entries.async_entries( + DOMAIN, include_disabled=False, include_ignore=False + ) + hass.http.register_view(ImmichMediaView(hass)) + return ImmichMediaSource(hass, entries) + + +class ImmichMediaSourceIdentifier: + """Immich media item identifier.""" + + def __init__(self, identifier: str) -> None: + """Split identifier into parts.""" + parts = identifier.split("/") + # coonfig_entry.unique_id/album_id/asset_it/filename + self.unique_id = parts[0] + self.album_id = parts[1] if len(parts) > 1 else None + self.asset_id = parts[2] if len(parts) > 2 else None + self.file_name = parts[3] if len(parts) > 2 else None + + +class ImmichMediaSource(MediaSource): + """Provide Immich as media sources.""" + + name = "Immich" + + def __init__(self, hass: HomeAssistant, entries: list[ConfigEntry]) -> None: + """Initialize Immich media source.""" + super().__init__(DOMAIN) + self.hass = hass + self.entries = entries + + async def async_browse_media( + self, + item: MediaSourceItem, + ) -> BrowseMediaSource: + """Return media.""" + if not self.hass.config_entries.async_loaded_entries(DOMAIN): + raise BrowseError("Immich is not configured") + return BrowseMediaSource( + domain=DOMAIN, + identifier=None, + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title="Immich", + can_play=False, + can_expand=True, + children_media_class=MediaClass.DIRECTORY, + children=[ + *await self._async_build_immich(item), + ], + ) + + async def _async_build_immich( + self, item: MediaSourceItem + ) -> list[BrowseMediaSource]: + """Handle browsing different immich instances.""" + if not item.identifier: + return [ + BrowseMediaSource( + domain=DOMAIN, + identifier=entry.unique_id, + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title=entry.title, + can_play=False, + can_expand=True, + ) + for entry in self.entries + ] + identifier = ImmichMediaSourceIdentifier(item.identifier) + entry: ImmichConfigEntry | None = ( + self.hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, identifier.unique_id + ) + ) + assert entry + immich_api = entry.runtime_data.api + + if identifier.album_id is None: + # Get Albums + try: + albums = await immich_api.albums.async_get_all_albums() + except ImmichError: + return [] + + return [ + BrowseMediaSource( + domain=DOMAIN, + identifier=f"{item.identifier}/{album.album_id}", + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title=album.name, + can_play=False, + can_expand=True, + thumbnail=f"/immich/{identifier.unique_id}/{album.thumbnail_asset_id}/thumb.jpg/thumbnail", + ) + for album in albums + ] + + # Request items of album + try: + album_info = await immich_api.albums.async_get_album_info( + identifier.album_id + ) + except ImmichError: + return [] + + return [ + BrowseMediaSource( + domain=DOMAIN, + identifier=( + f"{identifier.unique_id}/" + f"{identifier.album_id}/" + f"{asset.asset_id}/" + f"{asset.file_name}" + ), + media_class=MediaClass.IMAGE, + media_content_type=asset.mime_type, + title=asset.file_name, + can_play=False, + can_expand=False, + thumbnail=f"/immich/{identifier.unique_id}/{asset.asset_id}/{asset.file_name}/thumbnail", + ) + for asset in album_info.assets + if asset.mime_type.startswith("image/") + ] + + async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: + """Resolve media to a url.""" + identifier = ImmichMediaSourceIdentifier(item.identifier) + if identifier.file_name is None: + raise Unresolvable("No file name") + mime_type, _ = mimetypes.guess_type(identifier.file_name) + if not isinstance(mime_type, str): + raise Unresolvable("No file extension") + return PlayMedia( + ( + f"/immich/{identifier.unique_id}/{identifier.asset_id}/{identifier.file_name}/fullsize" + ), + mime_type, + ) + + +class ImmichMediaView(HomeAssistantView): + """Immich Media Finder View.""" + + url = "/immich/{source_dir_id}/{location:.*}" + name = "immich" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the media view.""" + self.hass = hass + + async def get( + self, request: Request, source_dir_id: str, location: str + ) -> Response: + """Start a GET request.""" + if not self.hass.config_entries.async_loaded_entries(DOMAIN): + raise HTTPNotFound + asset_id, file_name, size = location.split("/") + + mime_type, _ = mimetypes.guess_type(file_name) + if not isinstance(mime_type, str): + raise HTTPNotFound + + entry: ImmichConfigEntry | None = ( + self.hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, source_dir_id + ) + ) + assert entry + immich_api = entry.runtime_data.api + + try: + image = await immich_api.assets.async_view_asset(asset_id, size) + except ImmichError as exc: + raise HTTPNotFound from exc + return Response(body=image, content_type=mime_type) diff --git a/tests/components/immich/conftest.py b/tests/components/immich/conftest.py index 2c9483c3955..d26eddfd55e 100644 --- a/tests/components/immich/conftest.py +++ b/tests/components/immich/conftest.py @@ -4,7 +4,7 @@ from collections.abc import AsyncGenerator, Generator from datetime import datetime from unittest.mock import AsyncMock, patch -from aioimmich import ImmichServer, ImmichUsers +from aioimmich import ImmichAlbums, ImmichAssests, ImmichServer, ImmichUsers from aioimmich.server.models import ( ImmichServerAbout, ImmichServerStatistics, @@ -21,6 +21,10 @@ from homeassistant.const import ( CONF_SSL, CONF_VERIFY_SSL, ) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .const import MOCK_ALBUM_WITH_ASSETS, MOCK_ALBUM_WITHOUT_ASSETS from tests.common import MockConfigEntry @@ -51,6 +55,23 @@ def mock_config_entry() -> MockConfigEntry: ) +@pytest.fixture +def mock_immich_albums() -> AsyncMock: + """Mock the Immich server.""" + 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 + return mock + + +@pytest.fixture +def mock_immich_assets() -> AsyncMock: + """Mock the Immich server.""" + mock = AsyncMock(spec=ImmichAssests) + mock.async_view_asset.return_value = b"xxxx" + return mock + + @pytest.fixture def mock_immich_server() -> AsyncMock: """Mock the Immich server.""" @@ -116,7 +137,10 @@ def mock_immich_user() -> AsyncMock: @pytest.fixture async def mock_immich( - mock_immich_server: AsyncMock, mock_immich_user: AsyncMock + mock_immich_albums: AsyncMock, + mock_immich_assets: AsyncMock, + mock_immich_server: AsyncMock, + mock_immich_user: AsyncMock, ) -> AsyncGenerator[AsyncMock]: """Mock the Immich API.""" with ( @@ -124,6 +148,8 @@ async def mock_immich( patch("homeassistant.components.immich.config_flow.Immich", new=mock_immich), ): client = mock_immich.return_value + client.albums = mock_immich_albums + client.assets = mock_immich_assets client.server = mock_immich_server client.users = mock_immich_user yield client @@ -134,3 +160,9 @@ async def mock_non_admin_immich(mock_immich: AsyncMock) -> AsyncMock: """Mock the Immich API.""" mock_immich.users.async_get_my_user.return_value.is_admin = False return mock_immich + + +@pytest.fixture +async def setup_media_source(hass: HomeAssistant) -> None: + """Set up media source.""" + assert await async_setup_component(hass, "media_source", {}) diff --git a/tests/components/immich/const.py b/tests/components/immich/const.py index 2779a02be55..aeec4764732 100644 --- a/tests/components/immich/const.py +++ b/tests/components/immich/const.py @@ -1,5 +1,8 @@ """Constants for the Immich integration tests.""" +from aioimmich.albums.models import ImmichAlbum +from aioimmich.assets.models import ImmichAsset + from homeassistant.const import ( CONF_API_KEY, CONF_HOST, @@ -22,3 +25,21 @@ MOCK_CONFIG_ENTRY_DATA = { CONF_SSL: False, CONF_VERIFY_SSL: False, } + +MOCK_ALBUM_WITHOUT_ASSETS = ImmichAlbum( + "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + "My Album", + "This is my first great album", + "0d03a7ad-ddc7-45a6-adee-68d322a6d2f5", + 1, + [], +) + +MOCK_ALBUM_WITH_ASSETS = ImmichAlbum( + "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + "My Album", + "This is my first great album", + "0d03a7ad-ddc7-45a6-adee-68d322a6d2f5", + 1, + [ImmichAsset("2e94c203-50aa-4ad2-8e29-56dd74e0eff4", "filename.jpg", "image/jpeg")], +) diff --git a/tests/components/immich/test_media_source.py b/tests/components/immich/test_media_source.py new file mode 100644 index 00000000000..772f0535f02 --- /dev/null +++ b/tests/components/immich/test_media_source.py @@ -0,0 +1,336 @@ +"""Tests for Immich media source.""" + +from pathlib import Path +import tempfile +from unittest.mock import Mock, patch + +from aiohttp import web +from aioimmich.exceptions import ImmichError +import pytest + +from homeassistant.components.immich.const import DOMAIN +from homeassistant.components.immich.media_source import ( + ImmichMediaSource, + ImmichMediaView, + async_get_media_source, +) +from homeassistant.components.media_player import MediaClass +from homeassistant.components.media_source import ( + BrowseError, + BrowseMedia, + MediaSourceItem, + Unresolvable, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util.aiohttp import MockRequest + +from . import setup_integration +from .const import MOCK_ALBUM_WITHOUT_ASSETS + +from tests.common import MockConfigEntry + + +async def test_get_media_source(hass: HomeAssistant) -> None: + """Test the async_get_media_source.""" + assert await async_setup_component(hass, "media_source", {}) + + source = await async_get_media_source(hass) + assert isinstance(source, ImmichMediaSource) + assert source.domain == DOMAIN + + +@pytest.mark.parametrize( + ("identifier", "exception_msg"), + [ + ("unique_id", "No file name"), + ("unique_id/album_id", "No file name"), + ("unique_id/album_id/asset_id/filename", "No file extension"), + ], +) +async def test_resolve_media_bad_identifier( + hass: HomeAssistant, identifier: str, exception_msg: str +) -> None: + """Test resolve_media with bad identifiers.""" + assert await async_setup_component(hass, "media_source", {}) + + source = await async_get_media_source(hass) + item = MediaSourceItem(hass, DOMAIN, identifier, None) + with pytest.raises(Unresolvable, match=exception_msg): + await source.async_resolve_media(item) + + +@pytest.mark.parametrize( + ("identifier", "url", "mime_type"), + [ + ( + "unique_id/album_id/asset_id/filename.jpg", + "/immich/unique_id/asset_id/filename.jpg/fullsize", + "image/jpeg", + ), + ( + "unique_id/album_id/asset_id/filename.png", + "/immich/unique_id/asset_id/filename.png/fullsize", + "image/png", + ), + ], +) +async def test_resolve_media_success( + hass: HomeAssistant, identifier: str, url: str, mime_type: str +) -> None: + """Test successful resolving an item.""" + assert await async_setup_component(hass, "media_source", {}) + + source = await async_get_media_source(hass) + item = MediaSourceItem(hass, DOMAIN, identifier, None) + result = await source.async_resolve_media(item) + + assert result.url == url + assert result.mime_type == mime_type + + +async def test_browse_media_unconfigured(hass: HomeAssistant) -> None: + """Test browse_media without any devices being configured.""" + assert await async_setup_component(hass, "media_source", {}) + + source = await async_get_media_source(hass) + item = MediaSourceItem( + hass, DOMAIN, "unique_id/album_id/asset_id/filename.png", None + ) + with pytest.raises(BrowseError, match="Immich is not configured"): + await source.async_browse_media(item) + + +async def test_browse_media_album_error( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test browse_media with unknown album.""" + assert await async_setup_component(hass, "media_source", {}) + + with patch("homeassistant.components.immich.PLATFORMS", []): + await setup_integration(hass, mock_config_entry) + + # exception in get_albums() + mock_immich.albums.async_get_all_albums.side_effect = ImmichError( + { + "message": "Not found or no album.read access", + "error": "Bad Request", + "statusCode": 400, + "correlationId": "e0hlizyl", + } + ) + + source = await async_get_media_source(hass) + + item = MediaSourceItem(hass, DOMAIN, mock_config_entry.unique_id, None) + result = await source.async_browse_media(item) + + assert result + assert result.identifier is None + assert len(result.children) == 0 + + +async def test_browse_media_get_root( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test browse_media returning root media sources.""" + assert await async_setup_component(hass, "media_source", {}) + + with patch("homeassistant.components.immich.PLATFORMS", []): + await setup_integration(hass, mock_config_entry) + + source = await async_get_media_source(hass) + item = MediaSourceItem(hass, DOMAIN, "", None) + result = await source.async_browse_media(item) + + assert result + assert len(result.children) == 1 + media_file = result.children[0] + assert isinstance(media_file, BrowseMedia) + assert media_file.title == "Someone" + assert media_file.media_content_id == ( + "media-source://immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e" + ) + + +async def test_browse_media_get_albums( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test browse_media returning albums.""" + assert await async_setup_component(hass, "media_source", {}) + + with patch("homeassistant.components.immich.PLATFORMS", []): + await setup_integration(hass, mock_config_entry) + + source = await async_get_media_source(hass) + item = MediaSourceItem(hass, DOMAIN, "e7ef5713-9dab-4bd4-b899-715b0ca4379e", None) + result = await source.async_browse_media(item) + + assert result + assert len(result.children) == 1 + media_file = result.children[0] + assert isinstance(media_file, BrowseMedia) + assert media_file.title == "My Album" + assert media_file.media_content_id == ( + "media-source://immich/" + "e7ef5713-9dab-4bd4-b899-715b0ca4379e/" + "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6" + ) + + +async def test_browse_media_get_items_error( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test browse_media returning albums.""" + assert await async_setup_component(hass, "media_source", {}) + + with patch("homeassistant.components.immich.PLATFORMS", []): + await setup_integration(hass, mock_config_entry) + + source = await async_get_media_source(hass) + + # unknown album + mock_immich.albums.async_get_album_info.return_value = MOCK_ALBUM_WITHOUT_ASSETS + item = MediaSourceItem( + hass, + DOMAIN, + "e7ef5713-9dab-4bd4-b899-715b0ca4379e/721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + None, + ) + result = await source.async_browse_media(item) + + assert result + assert result.identifier is None + assert len(result.children) == 0 + + # exception in async_get_album_info() + mock_immich.albums.async_get_album_info.side_effect = ImmichError( + { + "message": "Not found or no album.read access", + "error": "Bad Request", + "statusCode": 400, + "correlationId": "e0hlizyl", + } + ) + item = MediaSourceItem( + hass, + DOMAIN, + "e7ef5713-9dab-4bd4-b899-715b0ca4379e/721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + None, + ) + result = await source.async_browse_media(item) + + assert result + assert result.identifier is None + assert len(result.children) == 0 + + +async def test_browse_media_get_items( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test browse_media returning albums.""" + assert await async_setup_component(hass, "media_source", {}) + + with patch("homeassistant.components.immich.PLATFORMS", []): + await setup_integration(hass, mock_config_entry) + + source = await async_get_media_source(hass) + + item = MediaSourceItem( + hass, + DOMAIN, + "e7ef5713-9dab-4bd4-b899-715b0ca4379e/721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + None, + ) + result = await source.async_browse_media(item) + + assert result + assert len(result.children) == 1 + media_file = result.children[0] + assert isinstance(media_file, BrowseMedia) + assert media_file.identifier == ( + "e7ef5713-9dab-4bd4-b899-715b0ca4379e/" + "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6/" + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/filename.jpg" + ) + assert media_file.title == "filename.jpg" + assert media_file.media_class == MediaClass.IMAGE + assert media_file.media_content_type == "image/jpeg" + assert media_file.can_play is False + assert not media_file.can_expand + assert media_file.thumbnail == ( + "/immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e/" + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/filename.jpg/thumbnail" + ) + + +async def test_media_view( + hass: HomeAssistant, + tmp_path: Path, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test SynologyDsmMediaView returning albums.""" + view = ImmichMediaView(hass) + request = MockRequest(b"", DOMAIN) + + # immich noch configured + with pytest.raises(web.HTTPNotFound): + await view.get(request, "", "") + + # setup immich + assert await async_setup_component(hass, "media_source", {}) + with patch("homeassistant.components.immich.PLATFORMS", []): + await setup_integration(hass, mock_config_entry) + + # wrong url (without file extension) + with pytest.raises(web.HTTPNotFound): + await view.get( + request, + "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/filename/thumbnail", + ) + + # exception in async_view_asset() + mock_immich.assets.async_view_asset.side_effect = ImmichError( + { + "message": "Not found or no asset.read access", + "error": "Bad Request", + "statusCode": 400, + "correlationId": "e0hlizyl", + } + ) + with pytest.raises(web.HTTPNotFound): + await view.get( + request, + "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/filename.jpg/thumbnail", + ) + + # success + mock_immich.assets.async_view_asset.side_effect = None + mock_immich.assets.async_view_asset.return_value = b"xxxx" + with patch.object(tempfile, "tempdir", tmp_path): + result = await view.get( + request, + "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/filename.jpg/thumbnail", + ) + assert isinstance(result, web.Response) + with patch.object(tempfile, "tempdir", tmp_path): + result = await view.get( + request, + "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/filename.jpg/fullsize", + ) + assert isinstance(result, web.Response)