Add people and tags collections to Immich media source (#149340)

This commit is contained in:
Michael 2025-07-28 23:21:04 +02:00 committed by GitHub
parent cf05f1046d
commit 596f6cd216
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 665 additions and 197 deletions

View File

@ -5,6 +5,7 @@ from __future__ import annotations
from logging import getLogger
from aiohttp.web import HTTPNotFound, Request, Response, StreamResponse
from aioimmich.assets.models import ImmichAsset
from aioimmich.exceptions import ImmichError
from homeassistant.components.http import HomeAssistantView
@ -83,6 +84,10 @@ class ImmichMediaSource(MediaSource):
self, item: MediaSourceItem, entries: list[ConfigEntry]
) -> list[BrowseMediaSource]:
"""Handle browsing different immich instances."""
# --------------------------------------------------------
# root level, render immich instances
# --------------------------------------------------------
if not item.identifier:
LOGGER.debug("Render all Immich instances")
return [
@ -97,6 +102,10 @@ class ImmichMediaSource(MediaSource):
)
for entry in entries
]
# --------------------------------------------------------
# 1st level, render collections overview
# --------------------------------------------------------
identifier = ImmichMediaSourceIdentifier(item.identifier)
entry: ImmichConfigEntry | None = (
self.hass.config_entries.async_entry_for_domain_unique_id(
@ -111,50 +120,127 @@ class ImmichMediaSource(MediaSource):
return [
BrowseMediaSource(
domain=DOMAIN,
identifier=f"{identifier.unique_id}|albums",
identifier=f"{identifier.unique_id}|{collection}",
media_class=MediaClass.DIRECTORY,
media_content_type=MediaClass.IMAGE,
title="albums",
title=collection,
can_play=False,
can_expand=True,
)
for collection in ("albums", "people", "tags")
]
# --------------------------------------------------------
# 2nd level, render collection
# --------------------------------------------------------
if identifier.collection_id is None:
LOGGER.debug("Render all albums for %s", entry.title)
if identifier.collection == "albums":
LOGGER.debug("Render all albums for %s", entry.title)
try:
albums = await immich_api.albums.async_get_all_albums()
except ImmichError:
return []
return [
BrowseMediaSource(
domain=DOMAIN,
identifier=f"{identifier.unique_id}|albums|{album.album_id}",
media_class=MediaClass.DIRECTORY,
media_content_type=MediaClass.IMAGE,
title=album.album_name,
can_play=False,
can_expand=True,
thumbnail=f"/immich/{identifier.unique_id}/{album.album_thumbnail_asset_id}/thumbnail/image/jpg",
)
for album in albums
]
if identifier.collection == "tags":
LOGGER.debug("Render all tags for %s", entry.title)
try:
tags = await immich_api.tags.async_get_all_tags()
except ImmichError:
return []
return [
BrowseMediaSource(
domain=DOMAIN,
identifier=f"{identifier.unique_id}|tags|{tag.tag_id}",
media_class=MediaClass.DIRECTORY,
media_content_type=MediaClass.IMAGE,
title=tag.name,
can_play=False,
can_expand=True,
)
for tag in tags
]
if identifier.collection == "people":
LOGGER.debug("Render all people for %s", entry.title)
try:
people = await immich_api.people.async_get_all_people()
except ImmichError:
return []
return [
BrowseMediaSource(
domain=DOMAIN,
identifier=f"{identifier.unique_id}|people|{person.person_id}",
media_class=MediaClass.DIRECTORY,
media_content_type=MediaClass.IMAGE,
title=person.name,
can_play=False,
can_expand=True,
thumbnail=f"/immich/{identifier.unique_id}/{person.person_id}/person/image/jpg",
)
for person in people
]
# --------------------------------------------------------
# final level, render assets
# --------------------------------------------------------
assert identifier.collection_id is not None
assets: list[ImmichAsset] = []
if identifier.collection == "albums":
LOGGER.debug(
"Render all assets of album %s for %s",
identifier.collection_id,
entry.title,
)
try:
albums = await immich_api.albums.async_get_all_albums()
album_info = await immich_api.albums.async_get_album_info(
identifier.collection_id
)
assets = album_info.assets
except ImmichError:
return []
return [
BrowseMediaSource(
domain=DOMAIN,
identifier=f"{identifier.unique_id}|albums|{album.album_id}",
media_class=MediaClass.DIRECTORY,
media_content_type=MediaClass.IMAGE,
title=album.album_name,
can_play=False,
can_expand=True,
thumbnail=f"/immich/{identifier.unique_id}/{album.album_thumbnail_asset_id}/thumbnail/image/jpg",
)
for album in albums
]
LOGGER.debug(
"Render all assets of album %s for %s",
identifier.collection_id,
entry.title,
)
try:
album_info = await immich_api.albums.async_get_album_info(
identifier.collection_id
elif identifier.collection == "tags":
LOGGER.debug(
"Render all assets with tag %s",
identifier.collection_id,
)
except ImmichError:
return []
try:
assets = await immich_api.search.async_get_all_by_tag_ids(
[identifier.collection_id]
)
except ImmichError:
return []
elif identifier.collection == "people":
LOGGER.debug(
"Render all assets for person %s",
identifier.collection_id,
)
try:
assets = await immich_api.search.async_get_all_by_person_ids(
[identifier.collection_id]
)
except ImmichError:
return []
ret: list[BrowseMediaSource] = []
for asset in album_info.assets:
for asset in assets:
if not (mime_type := asset.original_mime_type) or not mime_type.startswith(
("image/", "video/")
):
@ -173,7 +259,8 @@ class ImmichMediaSource(MediaSource):
BrowseMediaSource(
domain=DOMAIN,
identifier=(
f"{identifier.unique_id}|albums|"
f"{identifier.unique_id}|"
f"{identifier.collection}|"
f"{identifier.collection_id}|"
f"{asset.asset_id}|"
f"{asset.original_file_name}|"
@ -257,7 +344,10 @@ class ImmichMediaView(HomeAssistantView):
# web response for images
try:
image = await immich_api.assets.async_view_asset(asset_id, size)
if size == "person":
image = await immich_api.people.async_get_person_thumbnail(asset_id)
else:
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=f"{mime_type_base}/{mime_type_format}")

View File

@ -4,15 +4,25 @@ from collections.abc import AsyncGenerator, Generator
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
from aioimmich import ImmichAlbums, ImmichAssests, ImmichServer, ImmichUsers
from aioimmich import (
ImmichAlbums,
ImmichAssests,
ImmichPeople,
ImmichSearch,
ImmichServer,
ImmichTags,
ImmichUsers,
)
from aioimmich.albums.models import ImmichAddAssetsToAlbumResponse
from aioimmich.assets.models import ImmichAssetUploadResponse
from aioimmich.people.models import ImmichPerson
from aioimmich.server.models import (
ImmichServerAbout,
ImmichServerStatistics,
ImmichServerStorage,
ImmichServerVersionCheck,
)
from aioimmich.tags.models import ImmichTag
from aioimmich.users.models import ImmichUserObject
import pytest
@ -29,7 +39,12 @@ from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from homeassistant.util.aiohttp import MockStreamReaderChunked
from .const import MOCK_ALBUM_WITH_ASSETS, MOCK_ALBUM_WITHOUT_ASSETS
from .const import (
MOCK_ALBUM_WITH_ASSETS,
MOCK_ALBUM_WITHOUT_ASSETS,
MOCK_PEOPLE_ASSETS,
MOCK_TAGS_ASSETS,
)
from tests.common import MockConfigEntry
@ -87,6 +102,58 @@ def mock_immich_assets() -> AsyncMock:
return mock
@pytest.fixture
def mock_immich_people() -> AsyncMock:
"""Mock the Immich server."""
mock = AsyncMock(spec=ImmichPeople)
mock.async_get_all_people.return_value = [
ImmichPerson.from_dict(
{
"id": "6176838a-ac5a-4d1f-9a35-91c591d962d8",
"name": "Me",
"birthDate": None,
"thumbnailPath": "upload/thumbs/e7ef5713-9dab-4bd4-b899-715b0ca4379e/61/76/6176838a-ac5a-4d1f-9a35-91c591d962d8.jpeg",
"isHidden": False,
"isFavorite": False,
"updatedAt": "2025-05-11T11:07:41.651Z",
}
),
ImmichPerson.from_dict(
{
"id": "3e66aa4a-a4a8-41a4-86fe-2ae5e490078f",
"name": "I",
"birthDate": None,
"thumbnailPath": "upload/thumbs/e7ef5713-9dab-4bd4-b899-715b0ca4379e/3e/66/3e66aa4a-a4a8-41a4-86fe-2ae5e490078f.jpeg",
"isHidden": False,
"isFavorite": False,
"updatedAt": "2025-05-19T22:10:21.953Z",
}
),
ImmichPerson.from_dict(
{
"id": "a3c83297-684a-4576-82dc-b07432e8a18f",
"name": "Myself",
"birthDate": None,
"thumbnailPath": "upload/thumbs/e7ef5713-9dab-4bd4-b899-715b0ca4379e/a3/c8/a3c83297-684a-4576-82dc-b07432e8a18f.jpeg",
"isHidden": False,
"isFavorite": False,
"updatedAt": "2025-05-12T21:07:04.044Z",
}
),
]
mock.async_get_person_thumbnail.return_value = b"yyyy"
return mock
@pytest.fixture
def mock_immich_search() -> AsyncMock:
"""Mock the Immich server."""
mock = AsyncMock(spec=ImmichSearch)
mock.async_get_all_by_person_ids.return_value = MOCK_PEOPLE_ASSETS
mock.async_get_all_by_tag_ids.return_value = MOCK_TAGS_ASSETS
return mock
@pytest.fixture
def mock_immich_server() -> AsyncMock:
"""Mock the Immich server."""
@ -153,6 +220,33 @@ def mock_immich_server() -> AsyncMock:
return mock
@pytest.fixture
def mock_immich_tags() -> AsyncMock:
"""Mock the Immich server."""
mock = AsyncMock(spec=ImmichTags)
mock.async_get_all_tags.return_value = [
ImmichTag.from_dict(
{
"id": "67301cb8-cb73-4e8a-99e9-475cb3f7e7b5",
"name": "Halloween",
"value": "Halloween",
"createdAt": "2025-05-12T20:00:45.220Z",
"updatedAt": "2025-05-12T20:00:47.224Z",
},
),
ImmichTag.from_dict(
{
"id": "69bd487f-dc1e-4420-94c6-656f0515773d",
"name": "Holidays",
"value": "Holidays",
"createdAt": "2025-05-12T20:00:49.967Z",
"updatedAt": "2025-05-12T20:00:55.575Z",
},
),
]
return mock
@pytest.fixture
def mock_immich_user() -> AsyncMock:
"""Mock the Immich server."""
@ -185,7 +279,10 @@ def mock_immich_user() -> AsyncMock:
async def mock_immich(
mock_immich_albums: AsyncMock,
mock_immich_assets: AsyncMock,
mock_immich_people: AsyncMock,
mock_immich_search: AsyncMock,
mock_immich_server: AsyncMock,
mock_immich_tags: AsyncMock,
mock_immich_user: AsyncMock,
) -> AsyncGenerator[AsyncMock]:
"""Mock the Immich API."""
@ -196,7 +293,10 @@ async def mock_immich(
client = mock_immich.return_value
client.albums = mock_immich_albums
client.assets = mock_immich_assets
client.people = mock_immich_people
client.search = mock_immich_search
client.server = mock_immich_server
client.tags = mock_immich_tags
client.users = mock_immich_user
yield client

View File

@ -1,6 +1,7 @@
"""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,
@ -113,3 +114,131 @@ MOCK_ALBUM_WITH_ASSETS = ImmichAlbum.from_dict(
],
}
)
MOCK_PEOPLE_ASSETS = [
ImmichAsset.from_dict(
{
"id": "2242eda3-94c2-49ee-86d4-e9e071b6fbf4",
"deviceAssetId": "1000092019",
"ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e",
"deviceId": "5933dd9394fc6bf0493a26b4e38acca1076f30ab246442976d2917f1d57d99a1",
"libraryId": None,
"type": "IMAGE",
"originalPath": "/usr/src/app/upload/upload/e7ef5713-9dab-4bd4-b899-715b0ca4379e/8e/a3/8ea31ee8-49c3-4be9-aa9d-b8ef26ba0abe.jpg",
"originalFileName": "20250714_201122.jpg",
"originalMimeType": "image/jpeg",
"thumbhash": "XRgGDILGeMlPaJaMWIeagJcJSA==",
"fileCreatedAt": "2025-07-14T18:11:22.648Z",
"fileModifiedAt": "2025-07-14T18:11:25.000Z",
"localDateTime": "2025-07-14T20:11:22.648Z",
"updatedAt": "2025-07-26T10:16:39.131Z",
"isFavorite": False,
"isArchived": False,
"isTrashed": False,
"visibility": "timeline",
"duration": "0:00:00.00000",
"livePhotoVideoId": None,
"people": [],
"unassignedFaces": [],
"checksum": "GcBJkDFoXx9d/wyl1xH89R4/NBQ=",
"isOffline": False,
"hasMetadata": True,
"duplicateId": None,
"resized": True,
}
),
ImmichAsset.from_dict(
{
"id": "046ac0d9-8acd-44d8-953f-ecb3c786358a",
"deviceAssetId": "1000092018",
"ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e",
"deviceId": "5933dd9394fc6bf0493a26b4e38acca1076f30ab246442976d2917f1d57d99a1",
"libraryId": None,
"type": "IMAGE",
"originalPath": "/usr/src/app/upload/upload/e7ef5713-9dab-4bd4-b899-715b0ca4379e/f5/b4/f5b4b200-47dd-45e8-98a4-4128df3f9189.jpg",
"originalFileName": "20250714_201121.jpg",
"originalMimeType": "image/jpeg",
"thumbhash": "XRgGDILHeMlPeJaMSJmKgJcIWQ==",
"fileCreatedAt": "2025-07-14T18:11:21.582Z",
"fileModifiedAt": "2025-07-14T18:11:24.000Z",
"localDateTime": "2025-07-14T20:11:21.582Z",
"updatedAt": "2025-07-26T10:16:39.131Z",
"isFavorite": False,
"isArchived": False,
"isTrashed": False,
"visibility": "timeline",
"duration": "0:00:00.00000",
"livePhotoVideoId": None,
"people": [],
"unassignedFaces": [],
"checksum": "X6kMpPulu/HJQnKmTqCoQYl3Sjc=",
"isOffline": False,
"hasMetadata": True,
"duplicateId": None,
"resized": True,
},
),
]
MOCK_TAGS_ASSETS = [
ImmichAsset.from_dict(
{
"id": "ae3d82fc-beb5-4abc-ae83-11fcfa5e7629",
"deviceAssetId": "2132393",
"ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e",
"deviceId": "CLI",
"libraryId": None,
"type": "IMAGE",
"originalPath": "/usr/src/app/upload/upload/e7ef5713-9dab-4bd4-b899-715b0ca4379e/07/d0/07d04d86-7188-4335-95ca-9bd9fd2b399d.JPG",
"originalFileName": "20110306_025024.jpg",
"originalMimeType": "image/jpeg",
"thumbhash": "WCgSFYRXaYdQiYineIiHd4SghQUY",
"fileCreatedAt": "2011-03-06T01:50:24.000Z",
"fileModifiedAt": "2011-03-06T01:50:24.000Z",
"localDateTime": "2011-03-06T02:50:24.000Z",
"updatedAt": "2025-07-26T10:16:39.477Z",
"isFavorite": False,
"isArchived": False,
"isTrashed": False,
"visibility": "timeline",
"duration": "0:00:00.00000",
"livePhotoVideoId": None,
"people": [],
"checksum": "eNwN0AN2hEYZJJkonl7ylGzJzko=",
"isOffline": False,
"hasMetadata": True,
"duplicateId": None,
"resized": True,
},
),
ImmichAsset.from_dict(
{
"id": "b71d0d08-6727-44ae-8bba-83c190f95df4",
"deviceAssetId": "2142137",
"ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e",
"deviceId": "CLI",
"libraryId": None,
"type": "IMAGE",
"originalPath": "/usr/src/app/upload/upload/e7ef5713-9dab-4bd4-b899-715b0ca4379e/4a/f4/4af42484-86f8-47a0-958a-f32da89ee03a.JPG",
"originalFileName": "20110306_024053.jpg",
"originalMimeType": "image/jpeg",
"thumbhash": "4AcKFYZPZnhSmGl5daaYeG859ytT",
"fileCreatedAt": "2011-03-06T01:40:53.000Z",
"fileModifiedAt": "2011-03-06T01:40:52.000Z",
"localDateTime": "2011-03-06T02:40:53.000Z",
"updatedAt": "2025-07-26T10:16:39.474Z",
"isFavorite": False,
"isArchived": False,
"isTrashed": False,
"visibility": "timeline",
"duration": "0:00:00.00000",
"livePhotoVideoId": None,
"people": [],
"checksum": "VtokCjIwKqnHBFzH3kHakIJiq5I=",
"isOffline": False,
"hasMetadata": True,
"duplicateId": None,
"resized": True,
},
),
]

View File

@ -26,7 +26,6 @@ from homeassistant.setup import async_setup_component
from homeassistant.util.aiohttp import MockRequest, MockStreamReaderChunked
from . import setup_integration
from .const import MOCK_ALBUM_WITHOUT_ASSETS
from tests.common import MockConfigEntry
@ -143,7 +142,8 @@ async def test_browse_media_get_root(
result = await source.async_browse_media(item)
assert result
assert len(result.children) == 1
assert len(result.children) == 3
media_file = result.children[0]
assert isinstance(media_file, BrowseMedia)
assert media_file.title == "albums"
@ -151,174 +151,289 @@ async def test_browse_media_get_root(
"media-source://immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums"
)
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|albums", 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|albums|"
"721e1a4b-aa12-441e-8d3b-5ac7ab283bb6"
)
async def test_browse_media_get_albums_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, f"{mock_config_entry.unique_id}|albums", 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_album_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|albums|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|albums|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_album_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|albums|721e1a4b-aa12-441e-8d3b-5ac7ab283bb6",
None,
)
result = await source.async_browse_media(item)
assert result
assert len(result.children) == 2
media_file = result.children[0]
assert isinstance(media_file, BrowseMedia)
assert media_file.identifier == (
"e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|"
"721e1a4b-aa12-441e-8d3b-5ac7ab283bb6|"
"2e94c203-50aa-4ad2-8e29-56dd74e0eff4|filename.jpg|image/jpeg"
)
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/thumbnail/image/jpeg"
)
media_file = result.children[1]
assert isinstance(media_file, BrowseMedia)
assert media_file.identifier == (
"e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|"
"721e1a4b-aa12-441e-8d3b-5ac7ab283bb6|"
"2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b|filename.mp4|video/mp4"
assert media_file.title == "people"
assert media_file.media_content_id == (
"media-source://immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e|people"
)
assert media_file.title == "filename.mp4"
assert media_file.media_class == MediaClass.VIDEO
assert media_file.media_content_type == "video/mp4"
assert media_file.can_play is True
assert not media_file.can_expand
assert media_file.thumbnail == (
"/immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e/"
"2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b/thumbnail/image/jpeg"
media_file = result.children[2]
assert isinstance(media_file, BrowseMedia)
assert media_file.title == "tags"
assert media_file.media_content_id == (
"media-source://immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e|tags"
)
@pytest.mark.parametrize(
("collection", "children"),
[
(
"albums",
[{"title": "My Album", "asset_id": "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6"}],
),
(
"people",
[
{"title": "Me", "asset_id": "6176838a-ac5a-4d1f-9a35-91c591d962d8"},
{"title": "I", "asset_id": "3e66aa4a-a4a8-41a4-86fe-2ae5e490078f"},
{"title": "Myself", "asset_id": "a3c83297-684a-4576-82dc-b07432e8a18f"},
],
),
(
"tags",
[
{
"title": "Halloween",
"asset_id": "67301cb8-cb73-4e8a-99e9-475cb3f7e7b5",
},
{
"title": "Holidays",
"asset_id": "69bd487f-dc1e-4420-94c6-656f0515773d",
},
],
),
],
)
async def test_browse_media_collections(
hass: HomeAssistant,
mock_immich: Mock,
mock_config_entry: MockConfigEntry,
collection: str,
children: list[dict],
) -> None:
"""Test browse through collections."""
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, f"{mock_config_entry.unique_id}|{collection}", None
)
result = await source.async_browse_media(item)
assert result
assert len(result.children) == len(children)
for idx, child in enumerate(children):
media_file = result.children[idx]
assert isinstance(media_file, BrowseMedia)
assert media_file.title == child["title"]
assert media_file.media_content_id == (
"media-source://immich/"
f"{mock_config_entry.unique_id}|{collection}|"
f"{child['asset_id']}"
)
@pytest.mark.parametrize(
("collection", "mocked_get_fn"),
[
("albums", ("albums", "async_get_all_albums")),
("people", ("people", "async_get_all_people")),
("tags", ("tags", "async_get_all_tags")),
],
)
async def test_browse_media_collections_error(
hass: HomeAssistant,
mock_immich: Mock,
mock_config_entry: MockConfigEntry,
collection: str,
mocked_get_fn: tuple[str, str],
) -> None:
"""Test browse_media with unknown collection."""
assert await async_setup_component(hass, "media_source", {})
with patch("homeassistant.components.immich.PLATFORMS", []):
await setup_integration(hass, mock_config_entry)
getattr(
getattr(mock_immich, mocked_get_fn[0]), mocked_get_fn[1]
).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, f"{mock_config_entry.unique_id}|{collection}", None
)
result = await source.async_browse_media(item)
assert result
assert result.identifier is None
assert len(result.children) == 0
@pytest.mark.parametrize(
("collection", "mocked_get_fn"),
[
("albums", ("albums", "async_get_album_info")),
("people", ("search", "async_get_all_by_person_ids")),
("tags", ("search", "async_get_all_by_tag_ids")),
],
)
async def test_browse_media_collection_items_error(
hass: HomeAssistant,
mock_immich: Mock,
mock_config_entry: MockConfigEntry,
collection: str,
mocked_get_fn: tuple[str, str],
) -> 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)
getattr(
getattr(mock_immich, mocked_get_fn[0]), mocked_get_fn[1]
).side_effect = ImmichError(
{
"message": "Not found or no album.read access",
"error": "Bad Request",
"statusCode": 400,
"correlationId": "e0hlizyl",
}
)
item = MediaSourceItem(
hass,
DOMAIN,
f"{mock_config_entry.unique_id}|{collection}|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
@pytest.mark.parametrize(
("collection", "collection_id", "children"),
[
(
"albums",
"721e1a4b-aa12-441e-8d3b-5ac7ab283bb6",
[
{
"original_file_name": "filename.jpg",
"asset_id": "2e94c203-50aa-4ad2-8e29-56dd74e0eff4",
"media_class": MediaClass.IMAGE,
"media_content_type": "image/jpeg",
"thumb_mime_type": "image/jpeg",
"can_play": False,
},
{
"original_file_name": "filename.mp4",
"asset_id": "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b",
"media_class": MediaClass.VIDEO,
"media_content_type": "video/mp4",
"thumb_mime_type": "image/jpeg",
"can_play": True,
},
],
),
(
"people",
"6176838a-ac5a-4d1f-9a35-91c591d962d8",
[
{
"original_file_name": "20250714_201122.jpg",
"asset_id": "2242eda3-94c2-49ee-86d4-e9e071b6fbf4",
"media_class": MediaClass.IMAGE,
"media_content_type": "image/jpeg",
"thumb_mime_type": "image/jpeg",
"can_play": False,
},
{
"original_file_name": "20250714_201121.jpg",
"asset_id": "046ac0d9-8acd-44d8-953f-ecb3c786358a",
"media_class": MediaClass.IMAGE,
"media_content_type": "image/jpeg",
"thumb_mime_type": "image/jpeg",
"can_play": False,
},
],
),
(
"tags",
"6176838a-ac5a-4d1f-9a35-91c591d962d8",
[
{
"original_file_name": "20110306_025024.jpg",
"asset_id": "ae3d82fc-beb5-4abc-ae83-11fcfa5e7629",
"media_class": MediaClass.IMAGE,
"media_content_type": "image/jpeg",
"thumb_mime_type": "image/jpeg",
"can_play": False,
},
{
"original_file_name": "20110306_024053.jpg",
"asset_id": "b71d0d08-6727-44ae-8bba-83c190f95df4",
"media_class": MediaClass.IMAGE,
"media_content_type": "image/jpeg",
"thumb_mime_type": "image/jpeg",
"can_play": False,
},
],
),
],
)
async def test_browse_media_collection_get_items(
hass: HomeAssistant,
mock_immich: Mock,
mock_config_entry: MockConfigEntry,
collection: str,
collection_id: str,
children: list[dict],
) -> 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,
f"{mock_config_entry.unique_id}|{collection}|{collection_id}",
None,
)
result = await source.async_browse_media(item)
assert result
assert len(result.children) == len(children)
for idx, child in enumerate(children):
media_file = result.children[idx]
assert isinstance(media_file, BrowseMedia)
assert media_file.identifier == (
f"{mock_config_entry.unique_id}|{collection}|{collection_id}|"
f"{child['asset_id']}|{child['original_file_name']}|{child['media_content_type']}"
)
assert media_file.title == child["original_file_name"]
assert media_file.media_class == child["media_class"]
assert media_file.media_content_type == child["media_content_type"]
assert media_file.can_play is child["can_play"]
assert not media_file.can_expand
assert media_file.thumbnail == (
f"/immich/{mock_config_entry.unique_id}/"
f"{child['asset_id']}/thumbnail/{child['thumb_mime_type']}"
)
async def test_media_view(
hass: HomeAssistant,
tmp_path: Path,
@ -362,6 +477,22 @@ async def test_media_view(
"2e94c203-50aa-4ad2-8e29-56dd74e0eff4/thumbnail/image/jpeg",
)
# exception in async_get_person_thumbnail()
mock_immich.people.async_get_person_thumbnail.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/person/image/jpeg",
)
# exception in async_play_video_stream()
mock_immich.assets.async_play_video_stream.side_effect = ImmichError(
{
@ -396,6 +527,24 @@ async def test_media_view(
)
assert isinstance(result, web.Response)
mock_immich.people.async_get_person_thumbnail.side_effect = None
mock_immich.people.async_get_person_thumbnail.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/person/image/jpeg",
)
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/fullsize/image/jpeg",
)
assert isinstance(result, web.Response)
mock_immich.assets.async_play_video_stream.side_effect = None
mock_immich.assets.async_play_video_stream.return_value = MockStreamReaderChunked(
b"xxxx"