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 logging import getLogger
from aiohttp.web import HTTPNotFound, Request, Response, StreamResponse from aiohttp.web import HTTPNotFound, Request, Response, StreamResponse
from aioimmich.assets.models import ImmichAsset
from aioimmich.exceptions import ImmichError from aioimmich.exceptions import ImmichError
from homeassistant.components.http import HomeAssistantView from homeassistant.components.http import HomeAssistantView
@ -83,6 +84,10 @@ class ImmichMediaSource(MediaSource):
self, item: MediaSourceItem, entries: list[ConfigEntry] self, item: MediaSourceItem, entries: list[ConfigEntry]
) -> list[BrowseMediaSource]: ) -> list[BrowseMediaSource]:
"""Handle browsing different immich instances.""" """Handle browsing different immich instances."""
# --------------------------------------------------------
# root level, render immich instances
# --------------------------------------------------------
if not item.identifier: if not item.identifier:
LOGGER.debug("Render all Immich instances") LOGGER.debug("Render all Immich instances")
return [ return [
@ -97,6 +102,10 @@ class ImmichMediaSource(MediaSource):
) )
for entry in entries for entry in entries
] ]
# --------------------------------------------------------
# 1st level, render collections overview
# --------------------------------------------------------
identifier = ImmichMediaSourceIdentifier(item.identifier) identifier = ImmichMediaSourceIdentifier(item.identifier)
entry: ImmichConfigEntry | None = ( entry: ImmichConfigEntry | None = (
self.hass.config_entries.async_entry_for_domain_unique_id( self.hass.config_entries.async_entry_for_domain_unique_id(
@ -111,50 +120,127 @@ class ImmichMediaSource(MediaSource):
return [ return [
BrowseMediaSource( BrowseMediaSource(
domain=DOMAIN, domain=DOMAIN,
identifier=f"{identifier.unique_id}|albums", identifier=f"{identifier.unique_id}|{collection}",
media_class=MediaClass.DIRECTORY, media_class=MediaClass.DIRECTORY,
media_content_type=MediaClass.IMAGE, media_content_type=MediaClass.IMAGE,
title="albums", title=collection,
can_play=False, can_play=False,
can_expand=True, can_expand=True,
) )
for collection in ("albums", "people", "tags")
] ]
# --------------------------------------------------------
# 2nd level, render collection
# --------------------------------------------------------
if identifier.collection_id is None: 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: 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: except ImmichError:
return [] return []
return [ elif identifier.collection == "tags":
BrowseMediaSource( LOGGER.debug(
domain=DOMAIN, "Render all assets with tag %s",
identifier=f"{identifier.unique_id}|albums|{album.album_id}", identifier.collection_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
) )
except ImmichError: try:
return [] 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] = [] 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( if not (mime_type := asset.original_mime_type) or not mime_type.startswith(
("image/", "video/") ("image/", "video/")
): ):
@ -173,7 +259,8 @@ class ImmichMediaSource(MediaSource):
BrowseMediaSource( BrowseMediaSource(
domain=DOMAIN, domain=DOMAIN,
identifier=( identifier=(
f"{identifier.unique_id}|albums|" f"{identifier.unique_id}|"
f"{identifier.collection}|"
f"{identifier.collection_id}|" f"{identifier.collection_id}|"
f"{asset.asset_id}|" f"{asset.asset_id}|"
f"{asset.original_file_name}|" f"{asset.original_file_name}|"
@ -257,7 +344,10 @@ class ImmichMediaView(HomeAssistantView):
# web response for images # web response for images
try: 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: except ImmichError as exc:
raise HTTPNotFound from exc raise HTTPNotFound from exc
return Response(body=image, content_type=f"{mime_type_base}/{mime_type_format}") 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 pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch 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.albums.models import ImmichAddAssetsToAlbumResponse
from aioimmich.assets.models import ImmichAssetUploadResponse from aioimmich.assets.models import ImmichAssetUploadResponse
from aioimmich.people.models import ImmichPerson
from aioimmich.server.models import ( from aioimmich.server.models import (
ImmichServerAbout, ImmichServerAbout,
ImmichServerStatistics, ImmichServerStatistics,
ImmichServerStorage, ImmichServerStorage,
ImmichServerVersionCheck, ImmichServerVersionCheck,
) )
from aioimmich.tags.models import ImmichTag
from aioimmich.users.models import ImmichUserObject from aioimmich.users.models import ImmichUserObject
import pytest import pytest
@ -29,7 +39,12 @@ from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.util.aiohttp import MockStreamReaderChunked 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 from tests.common import MockConfigEntry
@ -87,6 +102,58 @@ def mock_immich_assets() -> AsyncMock:
return mock 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 @pytest.fixture
def mock_immich_server() -> AsyncMock: def mock_immich_server() -> AsyncMock:
"""Mock the Immich server.""" """Mock the Immich server."""
@ -153,6 +220,33 @@ def mock_immich_server() -> AsyncMock:
return mock 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 @pytest.fixture
def mock_immich_user() -> AsyncMock: def mock_immich_user() -> AsyncMock:
"""Mock the Immich server.""" """Mock the Immich server."""
@ -185,7 +279,10 @@ def mock_immich_user() -> AsyncMock:
async def mock_immich( async def mock_immich(
mock_immich_albums: AsyncMock, mock_immich_albums: AsyncMock,
mock_immich_assets: AsyncMock, mock_immich_assets: AsyncMock,
mock_immich_people: AsyncMock,
mock_immich_search: AsyncMock,
mock_immich_server: AsyncMock, mock_immich_server: AsyncMock,
mock_immich_tags: AsyncMock,
mock_immich_user: AsyncMock, mock_immich_user: AsyncMock,
) -> AsyncGenerator[AsyncMock]: ) -> AsyncGenerator[AsyncMock]:
"""Mock the Immich API.""" """Mock the Immich API."""
@ -196,7 +293,10 @@ async def mock_immich(
client = mock_immich.return_value client = mock_immich.return_value
client.albums = mock_immich_albums client.albums = mock_immich_albums
client.assets = mock_immich_assets client.assets = mock_immich_assets
client.people = mock_immich_people
client.search = mock_immich_search
client.server = mock_immich_server client.server = mock_immich_server
client.tags = mock_immich_tags
client.users = mock_immich_user client.users = mock_immich_user
yield client yield client

View File

@ -1,6 +1,7 @@
"""Constants for the Immich integration tests.""" """Constants for the Immich integration tests."""
from aioimmich.albums.models import ImmichAlbum 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,
@ -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 homeassistant.util.aiohttp import MockRequest, MockStreamReaderChunked
from . import setup_integration from . import setup_integration
from .const import MOCK_ALBUM_WITHOUT_ASSETS
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@ -143,7 +142,8 @@ async def test_browse_media_get_root(
result = await source.async_browse_media(item) result = await source.async_browse_media(item)
assert result assert result
assert len(result.children) == 1 assert len(result.children) == 3
media_file = result.children[0] media_file = result.children[0]
assert isinstance(media_file, BrowseMedia) assert isinstance(media_file, BrowseMedia)
assert media_file.title == "albums" 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" "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] media_file = result.children[1]
assert isinstance(media_file, BrowseMedia) assert isinstance(media_file, BrowseMedia)
assert media_file.identifier == ( assert media_file.title == "people"
"e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|" assert media_file.media_content_id == (
"721e1a4b-aa12-441e-8d3b-5ac7ab283bb6|" "media-source://immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e|people"
"2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b|filename.mp4|video/mp4"
) )
assert media_file.title == "filename.mp4"
assert media_file.media_class == MediaClass.VIDEO media_file = result.children[2]
assert media_file.media_content_type == "video/mp4" assert isinstance(media_file, BrowseMedia)
assert media_file.can_play is True assert media_file.title == "tags"
assert not media_file.can_expand assert media_file.media_content_id == (
assert media_file.thumbnail == ( "media-source://immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e|tags"
"/immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e/"
"2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b/thumbnail/image/jpeg"
) )
@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( async def test_media_view(
hass: HomeAssistant, hass: HomeAssistant,
tmp_path: Path, tmp_path: Path,
@ -362,6 +477,22 @@ async def test_media_view(
"2e94c203-50aa-4ad2-8e29-56dd74e0eff4/thumbnail/image/jpeg", "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() # exception in async_play_video_stream()
mock_immich.assets.async_play_video_stream.side_effect = ImmichError( mock_immich.assets.async_play_video_stream.side_effect = ImmichError(
{ {
@ -396,6 +527,24 @@ async def test_media_view(
) )
assert isinstance(result, web.Response) 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.side_effect = None
mock_immich.assets.async_play_video_stream.return_value = MockStreamReaderChunked( mock_immich.assets.async_play_video_stream.return_value = MockStreamReaderChunked(
b"xxxx" b"xxxx"