Files
core/homeassistant/components/immich/media_source.py
T

508 lines
18 KiB
Python

"""Immich as a media source."""
from logging import getLogger
from typing import TypedDict, override
from aiohttp.web import HTTPNotFound, Request, Response, StreamResponse
from aioimmich.assets.models import AssetType, ImmichAsset
from aioimmich.exceptions import ImmichError, ImmichForbiddenError
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.media_player import (
BrowseError,
MediaClass,
SearchMedia,
SearchMediaQuery,
)
from homeassistant.components.media_source import (
BrowseMediaSource,
MediaSource,
MediaSourceItem,
PlayMedia,
Unresolvable,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator
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."""
hass.http.register_view(ImmichMediaView(hass))
return ImmichMediaSource(hass)
class ImmichMediaSourceIdentifier:
"""Immich media item identifier."""
def __init__(self, identifier: str) -> None:
"""Split identifier into parts."""
parts = identifier.split("|")
# config_entry.unique_id|collection|collection_id|asset_id|file_name|mime_type
self.unique_id = parts[0]
self.collection = parts[1] if len(parts) > 1 else None
self.collection_id = parts[2] if len(parts) > 2 else None
self.asset_id = parts[3] if len(parts) > 3 else None
self.file_name = parts[4] if len(parts) > 3 else None
self.mime_type = parts[5] if len(parts) > 3 else None
class ImmichSmartSearchArgs(TypedDict, total=False):
"""Type for smart search arguments."""
query: str
page_size: int
max_pages: int
asset_type: AssetType
album_ids: list[str]
person_ids: list[str]
tag_ids: list[str]
is_favorite: bool
is_not_in_album: bool
MEDIA_CLASS_ASSET_TYPE_MAPPING = {
MediaClass.IMAGE: AssetType.IMAGE,
MediaClass.VIDEO: AssetType.VIDEO,
}
def _parse_assets(
assets: list[ImmichAsset], identifier: ImmichMediaSourceIdentifier
) -> list[BrowseMediaSource]:
"""Parse list of ImmichAsset to list of BrowseMediaSource."""
ret: list[BrowseMediaSource] = []
for asset in assets:
if not (mime_type := asset.original_mime_type) or not mime_type.startswith(
("image/", "video/")
):
continue
if mime_type.startswith("image/"):
media_class = MediaClass.IMAGE
can_play = False
thumb_mime_type = mime_type
else:
media_class = MediaClass.VIDEO
can_play = True
thumb_mime_type = "image/jpeg"
ret.append(
BrowseMediaSource(
domain=DOMAIN,
identifier=(
f"{identifier.unique_id}|"
f"{identifier.collection}|"
f"{identifier.collection_id}|"
f"{asset.asset_id}|"
f"{asset.original_file_name}|"
f"{mime_type}"
),
media_class=media_class,
media_content_type=mime_type,
title=asset.original_file_name,
can_play=can_play,
can_expand=False,
thumbnail=f"/immich/{identifier.unique_id}/{asset.asset_id}/thumbnail/{thumb_mime_type}",
)
)
return ret
class ImmichMediaSource(MediaSource):
"""Provide Immich as media sources."""
name = "Immich"
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize Immich media source."""
super().__init__(DOMAIN)
self.hass = hass
@override
async def async_browse_media(
self,
item: MediaSourceItem,
) -> BrowseMediaSource:
"""Return media."""
if not (entries := self.hass.config_entries.async_loaded_entries(DOMAIN)):
raise BrowseError(
translation_domain=DOMAIN, translation_key="not_configured"
)
can_search = False
if item.identifier:
can_search = bool(ImmichMediaSourceIdentifier(item.identifier).unique_id)
return BrowseMediaSource(
domain=DOMAIN,
identifier=item.identifier,
media_class=MediaClass.DIRECTORY,
media_content_type=MediaClass.IMAGE,
title="Immich",
can_play=False,
can_expand=True,
can_search=can_search,
search_media_classes=[MediaClass.IMAGE, MediaClass.VIDEO],
children_media_class=MediaClass.DIRECTORY,
children=[
*await self._async_build_immich(item, entries),
],
)
async def _async_build_immich(
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 [
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 entries
]
# --------------------------------------------------------
# 1st level, render collections overview
# --------------------------------------------------------
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.collection is None:
LOGGER.debug("Render all collections for %s", entry.title)
return [
BrowseMediaSource(
domain=DOMAIN,
identifier=f"{identifier.unique_id}|{collection}",
media_class=MediaClass.DIRECTORY,
media_content_type=MediaClass.IMAGE,
title=collection.split("|", maxsplit=1)[0],
can_play=False,
can_expand=True,
)
for collection in ("albums", "favorites|favorites", "people", "tags")
]
# --------------------------------------------------------
# 2nd level, render collection
# --------------------------------------------------------
if identifier.collection_id is None:
if identifier.collection == "albums":
LOGGER.debug("Render all albums for %s", entry.title)
try:
albums = await immich_api.albums.async_get_all_albums()
except ImmichForbiddenError as err:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="missing_api_permission",
translation_placeholders={"msg": str(err)},
) from err
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 ImmichForbiddenError as err:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="missing_api_permission",
translation_placeholders={"msg": str(err)},
) from err
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 ImmichForbiddenError as err:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="missing_api_permission",
translation_placeholders={"msg": str(err)},
) from err
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:
assets = await immich_api.search.async_get_all_by_album_ids(
[identifier.collection_id]
)
except ImmichForbiddenError as err:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="missing_api_permission",
translation_placeholders={"msg": str(err)},
) from err
except ImmichError:
return []
elif identifier.collection == "tags":
LOGGER.debug(
"Render all assets with tag %s",
identifier.collection_id,
)
try:
assets = await immich_api.search.async_get_all_by_tag_ids(
[identifier.collection_id]
)
except ImmichForbiddenError as err:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="missing_api_permission",
translation_placeholders={"msg": str(err)},
) from err
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 ImmichForbiddenError as err:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="missing_api_permission",
translation_placeholders={"msg": str(err)},
) from err
except ImmichError:
return []
elif identifier.collection == "favorites":
LOGGER.debug("Render all assets for favorites collection")
try:
assets = await immich_api.search.async_get_all_favorites()
except ImmichForbiddenError as err:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="missing_api_permission",
translation_placeholders={"msg": str(err)},
) from err
except ImmichError:
return []
return _parse_assets(assets, identifier)
@override
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
"""Resolve media to a url."""
try:
identifier = ImmichMediaSourceIdentifier(item.identifier)
except IndexError as err:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="identifier_unresolvable",
translation_placeholders={"identifier": item.identifier},
) from err
if identifier.mime_type is None:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="identifier_no_mime_type_unresolvable",
translation_placeholders={"identifier": item.identifier},
)
return PlayMedia(
(
f"/immich/{identifier.unique_id}/{identifier.asset_id}/fullsize/{identifier.mime_type}"
),
identifier.mime_type,
)
@override
async def async_search_media(
self, item: MediaSourceItem, query: SearchMediaQuery
) -> SearchMedia:
"""Search media."""
LOGGER.debug("search called with item:%s query:%s", item, query)
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
search_args: ImmichSmartSearchArgs = {
"query": query.search_query,
"page_size": 100,
"max_pages": 1,
}
if identifier.collection == "albums":
search_args["is_not_in_album"] = False
if album_id := identifier.collection_id:
search_args["album_ids"] = [album_id]
elif identifier.collection == "people" and (
person_id := identifier.collection_id
):
search_args["person_ids"] = [person_id]
elif identifier.collection == "tags" and (tag_id := identifier.collection_id):
search_args["tag_ids"] = [tag_id]
elif identifier.collection == "favorites":
search_args["is_favorite"] = True
if q_classes := query.media_filter_classes:
selected_supported_classes = list(
set(q_classes) & set(MEDIA_CLASS_ASSET_TYPE_MAPPING)
)
if len(selected_supported_classes) == 1:
search_args["asset_type"] = MEDIA_CLASS_ASSET_TYPE_MAPPING[
selected_supported_classes[0]
]
LOGGER.debug("search args:%s", search_args)
try:
results = await immich_api.search.async_smart_search(**search_args)
except ImmichForbiddenError as err:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="missing_api_permission",
translation_placeholders={"msg": str(err)},
) from err
except ImmichError:
return SearchMedia(result=[])
return SearchMedia(result=_parse_assets(results, identifier))
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 | StreamResponse:
"""Start a GET request."""
if not self.hass.config_entries.async_loaded_entries(DOMAIN):
raise HTTPNotFound
try:
asset_id, size, mime_type_base, mime_type_format = location.split("/")
except ValueError as err:
raise HTTPNotFound from err
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
# stream response for videos
if mime_type_base == "video":
try:
resp = await immich_api.assets.async_play_video_stream(asset_id)
except ImmichError as exc:
raise HTTPNotFound from exc
stream = ChunkAsyncStreamIterator(resp)
response = StreamResponse()
await response.prepare(request)
async for chunk in stream:
await response.write(chunk)
return response
# web response for images
try:
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}")