diff --git a/homeassistant/components/immich/media_source.py b/homeassistant/components/immich/media_source.py index 9304039f297..a7c55f9c572 100644 --- a/homeassistant/components/immich/media_source.py +++ b/homeassistant/components/immich/media_source.py @@ -3,7 +3,6 @@ from __future__ import annotations from logging import getLogger -import mimetypes from aiohttp.web import HTTPNotFound, Request, Response, StreamResponse from aioimmich.exceptions import ImmichError @@ -39,13 +38,14 @@ class ImmichMediaSourceIdentifier: def __init__(self, identifier: str) -> None: """Split identifier into parts.""" - parts = identifier.split("/") - # config_entry.unique_id/collection/collection_id/asset_id/file_name + 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 ImmichMediaSource(MediaSource): @@ -111,7 +111,7 @@ class ImmichMediaSource(MediaSource): return [ BrowseMediaSource( domain=DOMAIN, - identifier=f"{identifier.unique_id}/albums", + identifier=f"{identifier.unique_id}|albums", media_class=MediaClass.DIRECTORY, media_content_type=MediaClass.IMAGE, title="albums", @@ -130,13 +130,13 @@ class ImmichMediaSource(MediaSource): return [ BrowseMediaSource( domain=DOMAIN, - identifier=f"{identifier.unique_id}/albums/{album.album_id}", + identifier=f"{identifier.unique_id}|albums|{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", + thumbnail=f"/immich/{identifier.unique_id}/{album.thumbnail_asset_id}/thumbnail/image/jpg", ) for album in albums ] @@ -157,17 +157,18 @@ class ImmichMediaSource(MediaSource): BrowseMediaSource( domain=DOMAIN, identifier=( - f"{identifier.unique_id}/albums/" - f"{identifier.collection_id}/" - f"{asset.asset_id}/" - f"{asset.file_name}" + f"{identifier.unique_id}|albums|" + f"{identifier.collection_id}|" + f"{asset.asset_id}|" + f"{asset.file_name}|" + f"{asset.mime_type}" ), 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", + thumbnail=f"/immich/{identifier.unique_id}/{asset.asset_id}/thumbnail/{asset.mime_type}", ) for asset in album_info.assets if asset.mime_type.startswith("image/") @@ -177,17 +178,18 @@ class ImmichMediaSource(MediaSource): BrowseMediaSource( domain=DOMAIN, identifier=( - f"{identifier.unique_id}/albums/" - f"{identifier.collection_id}/" - f"{asset.asset_id}/" - f"{asset.file_name}" + f"{identifier.unique_id}|albums|" + f"{identifier.collection_id}|" + f"{asset.asset_id}|" + f"{asset.file_name}|" + f"{asset.mime_type}" ), media_class=MediaClass.VIDEO, media_content_type=asset.mime_type, title=asset.file_name, can_play=True, can_expand=False, - thumbnail=f"/immich/{identifier.unique_id}/{asset.asset_id}/thumbnail.jpg/thumbnail", + thumbnail=f"/immich/{identifier.unique_id}/{asset.asset_id}/thumbnail/image/jpeg", ) for asset in album_info.assets if asset.mime_type.startswith("video/") @@ -197,17 +199,23 @@ class ImmichMediaSource(MediaSource): 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") + try: + identifier = ImmichMediaSourceIdentifier(item.identifier) + except IndexError as err: + raise Unresolvable( + f"Could not parse identifier: {item.identifier}" + ) from err + + if identifier.mime_type is None: + raise Unresolvable( + f"Could not resolve identifier that has no mime-type: {item.identifier}" + ) + return PlayMedia( ( - f"/immich/{identifier.unique_id}/{identifier.asset_id}/{identifier.file_name}/fullsize" + f"/immich/{identifier.unique_id}/{identifier.asset_id}/fullsize/{identifier.mime_type}" ), - mime_type, + identifier.mime_type, ) @@ -228,10 +236,10 @@ class ImmichMediaView(HomeAssistantView): 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 + 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( @@ -242,7 +250,7 @@ class ImmichMediaView(HomeAssistantView): immich_api = entry.runtime_data.api # stream response for videos - if mime_type.startswith("video/"): + if mime_type_base == "video": try: resp = await immich_api.assets.async_play_video_stream(asset_id) except ImmichError as exc: @@ -259,4 +267,4 @@ class ImmichMediaView(HomeAssistantView): 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) + return Response(body=image, content_type=f"{mime_type_base}/{mime_type_format}") diff --git a/tests/components/immich/test_media_source.py b/tests/components/immich/test_media_source.py index 0f448fbf23d..5b396a780cc 100644 --- a/tests/components/immich/test_media_source.py +++ b/tests/components/immich/test_media_source.py @@ -43,9 +43,15 @@ async def test_get_media_source(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("identifier", "exception_msg"), [ - ("unique_id", "No file name"), - ("unique_id/albums/album_id", "No file name"), - ("unique_id/albums/album_id/asset_id/filename", "No file extension"), + ("unique_id", "Could not resolve identifier that has no mime-type"), + ( + "unique_id|albums|album_id", + "Could not resolve identifier that has no mime-type", + ), + ( + "unique_id|albums|album_id|asset_id|filename", + "Could not parse identifier", + ), ], ) async def test_resolve_media_bad_identifier( @@ -64,15 +70,20 @@ async def test_resolve_media_bad_identifier( ("identifier", "url", "mime_type"), [ ( - "unique_id/albums/album_id/asset_id/filename.jpg", - "/immich/unique_id/asset_id/filename.jpg/fullsize", + "unique_id|albums|album_id|asset_id|filename.jpg|image/jpeg", + "/immich/unique_id/asset_id/fullsize/image/jpeg", "image/jpeg", ), ( - "unique_id/albums/album_id/asset_id/filename.png", - "/immich/unique_id/asset_id/filename.png/fullsize", + "unique_id|albums|album_id|asset_id|filename.png|image/png", + "/immich/unique_id/asset_id/fullsize/image/png", "image/png", ), + ( + "unique_id|albums|album_id|asset_id|filename.mp4|video/mp4", + "/immich/unique_id/asset_id/fullsize/video/mp4", + "video/mp4", + ), ], ) async def test_resolve_media_success( @@ -137,7 +148,7 @@ async def test_browse_media_get_root( assert isinstance(media_file, BrowseMedia) assert media_file.title == "albums" assert media_file.media_content_id == ( - "media-source://immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e/albums" + "media-source://immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums" ) @@ -154,7 +165,7 @@ async def test_browse_media_get_albums( source = await async_get_media_source(hass) item = MediaSourceItem( - hass, DOMAIN, "e7ef5713-9dab-4bd4-b899-715b0ca4379e/albums", None + hass, DOMAIN, "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums", None ) result = await source.async_browse_media(item) @@ -165,7 +176,7 @@ async def test_browse_media_get_albums( assert media_file.title == "My Album" assert media_file.media_content_id == ( "media-source://immich/" - "e7ef5713-9dab-4bd4-b899-715b0ca4379e/albums/" + "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|" "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6" ) @@ -193,7 +204,7 @@ async def test_browse_media_get_albums_error( source = await async_get_media_source(hass) - item = MediaSourceItem(hass, DOMAIN, f"{mock_config_entry.unique_id}/albums", None) + item = MediaSourceItem(hass, DOMAIN, f"{mock_config_entry.unique_id}|albums", None) result = await source.async_browse_media(item) assert result @@ -219,7 +230,7 @@ async def test_browse_media_get_album_items_error( item = MediaSourceItem( hass, DOMAIN, - "e7ef5713-9dab-4bd4-b899-715b0ca4379e/albums/721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", None, ) result = await source.async_browse_media(item) @@ -240,7 +251,7 @@ async def test_browse_media_get_album_items_error( item = MediaSourceItem( hass, DOMAIN, - "e7ef5713-9dab-4bd4-b899-715b0ca4379e/albums/721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", None, ) result = await source.async_browse_media(item) @@ -266,7 +277,7 @@ async def test_browse_media_get_album_items( item = MediaSourceItem( hass, DOMAIN, - "e7ef5713-9dab-4bd4-b899-715b0ca4379e/albums/721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", None, ) result = await source.async_browse_media(item) @@ -276,9 +287,9 @@ async def test_browse_media_get_album_items( 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" + "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 @@ -287,15 +298,15 @@ async def test_browse_media_get_album_items( assert not media_file.can_expand assert media_file.thumbnail == ( "/immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e/" - "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/filename.jpg/thumbnail" + "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" + "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|" + "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6|" + "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b|filename.mp4|video/mp4" ) assert media_file.title == "filename.mp4" assert media_file.media_class == MediaClass.VIDEO @@ -304,7 +315,7 @@ async def test_browse_media_get_album_items( assert not media_file.can_expand assert media_file.thumbnail == ( "/immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e/" - "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b/thumbnail.jpg/thumbnail" + "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b/thumbnail/image/jpeg" ) @@ -327,12 +338,12 @@ async def test_media_view( with patch("homeassistant.components.immich.PLATFORMS", []): await setup_integration(hass, mock_config_entry) - # wrong url (without file extension) + # wrong url (without mime type) with pytest.raises(web.HTTPNotFound): await view.get( request, "e7ef5713-9dab-4bd4-b899-715b0ca4379e", - "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/filename/thumbnail", + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/thumbnail", ) # exception in async_view_asset() @@ -348,7 +359,7 @@ async def test_media_view( await view.get( request, "e7ef5713-9dab-4bd4-b899-715b0ca4379e", - "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/filename.jpg/thumbnail", + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/thumbnail/image/jpeg", ) # exception in async_play_video_stream() @@ -364,7 +375,7 @@ async def test_media_view( await view.get( request, "e7ef5713-9dab-4bd4-b899-715b0ca4379e", - "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b/filename.mp4/fullsize", + "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b/fullsize/video/mp4", ) # success @@ -374,14 +385,14 @@ async def test_media_view( result = await view.get( request, "e7ef5713-9dab-4bd4-b899-715b0ca4379e", - "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/filename.jpg/thumbnail", + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/thumbnail/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/filename.jpg/fullsize", + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/fullsize/image/jpeg", ) assert isinstance(result, web.Response) @@ -393,6 +404,6 @@ async def test_media_view( result = await view.get( request, "e7ef5713-9dab-4bd4-b899-715b0ca4379e", - "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b/filename.mp4/fullsize", + "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b/fullsize/video/mp4", ) assert isinstance(result, web.StreamResponse)