Add media_source platform to Immich integration (#145159)

* add media_source platform

* fix error messages

* use mime-type from asset info, instead of guessing it

* add dependency for http

* add tests

* use direct imports and set can_play=False for images

* fix tests
This commit is contained in:
Michael 2025-05-19 22:39:04 +02:00 committed by GitHub
parent d580f8a8a2
commit e76bd1bbb9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 601 additions and 2 deletions

View File

@ -3,6 +3,7 @@
"name": "Immich", "name": "Immich",
"codeowners": ["@mib1185"], "codeowners": ["@mib1185"],
"config_flow": true, "config_flow": true,
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/immich", "documentation": "https://www.home-assistant.io/integrations/immich",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["aioimmich"], "loggers": ["aioimmich"],

View File

@ -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)

View File

@ -4,7 +4,7 @@ from collections.abc import AsyncGenerator, Generator
from datetime import datetime from datetime import datetime
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from aioimmich import ImmichServer, ImmichUsers from aioimmich import ImmichAlbums, ImmichAssests, ImmichServer, ImmichUsers
from aioimmich.server.models import ( from aioimmich.server.models import (
ImmichServerAbout, ImmichServerAbout,
ImmichServerStatistics, ImmichServerStatistics,
@ -21,6 +21,10 @@ from homeassistant.const import (
CONF_SSL, CONF_SSL,
CONF_VERIFY_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 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 @pytest.fixture
def mock_immich_server() -> AsyncMock: def mock_immich_server() -> AsyncMock:
"""Mock the Immich server.""" """Mock the Immich server."""
@ -116,7 +137,10 @@ def mock_immich_user() -> AsyncMock:
@pytest.fixture @pytest.fixture
async def mock_immich( 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]: ) -> AsyncGenerator[AsyncMock]:
"""Mock the Immich API.""" """Mock the Immich API."""
with ( with (
@ -124,6 +148,8 @@ async def mock_immich(
patch("homeassistant.components.immich.config_flow.Immich", new=mock_immich), patch("homeassistant.components.immich.config_flow.Immich", new=mock_immich),
): ):
client = mock_immich.return_value client = mock_immich.return_value
client.albums = mock_immich_albums
client.assets = mock_immich_assets
client.server = mock_immich_server client.server = mock_immich_server
client.users = mock_immich_user client.users = mock_immich_user
yield client yield client
@ -134,3 +160,9 @@ async def mock_non_admin_immich(mock_immich: AsyncMock) -> AsyncMock:
"""Mock the Immich API.""" """Mock the Immich API."""
mock_immich.users.async_get_my_user.return_value.is_admin = False mock_immich.users.async_get_my_user.return_value.is_admin = False
return mock_immich 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", {})

View File

@ -1,5 +1,8 @@
"""Constants for the Immich integration tests.""" """Constants for the Immich integration tests."""
from aioimmich.albums.models import ImmichAlbum
from aioimmich.assets.models import ImmichAsset
from homeassistant.const import ( from homeassistant.const import (
CONF_API_KEY, CONF_API_KEY,
CONF_HOST, CONF_HOST,
@ -22,3 +25,21 @@ MOCK_CONFIG_ENTRY_DATA = {
CONF_SSL: False, CONF_SSL: False,
CONF_VERIFY_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")],
)

View File

@ -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)