From b0415588d733623abfcb6084db2cddecb72ae298 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 20 May 2025 20:40:22 +0200 Subject: [PATCH] Add support for videos in Immich media source (#145254) add support for videos --- .../components/immich/media_source.py | 45 +++++++++++++++-- homeassistant/util/aiohttp.py | 3 ++ tests/components/immich/__init__.py | 9 ++++ tests/components/immich/conftest.py | 2 + tests/components/immich/const.py | 9 +++- tests/components/immich/test_media_source.py | 49 ++++++++++++++++++- 6 files changed, 110 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/immich/media_source.py b/homeassistant/components/immich/media_source.py index f267433f233..201076f1295 100644 --- a/homeassistant/components/immich/media_source.py +++ b/homeassistant/components/immich/media_source.py @@ -5,7 +5,7 @@ from __future__ import annotations from logging import getLogger import mimetypes -from aiohttp.web import HTTPNotFound, Request, Response +from aiohttp.web import HTTPNotFound, Request, Response, StreamResponse from aioimmich.exceptions import ImmichError from homeassistant.components.http import HomeAssistantView @@ -20,6 +20,7 @@ from homeassistant.components.media_source import ( ) 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 @@ -136,7 +137,7 @@ class ImmichMediaSource(MediaSource): except ImmichError: return [] - return [ + ret = [ BrowseMediaSource( domain=DOMAIN, identifier=( @@ -156,6 +157,28 @@ class ImmichMediaSource(MediaSource): if asset.mime_type.startswith("image/") ] + ret.extend( + BrowseMediaSource( + domain=DOMAIN, + identifier=( + f"{identifier.unique_id}/" + f"{identifier.album_id}/" + f"{asset.asset_id}/" + f"{asset.file_name}" + ), + 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", + ) + for asset in album_info.assets + if asset.mime_type.startswith("video/") + ) + + return ret + async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve media to a url.""" identifier = ImmichMediaSourceIdentifier(item.identifier) @@ -184,12 +207,12 @@ class ImmichMediaView(HomeAssistantView): async def get( self, request: Request, source_dir_id: str, location: str - ) -> Response: + ) -> Response | StreamResponse: """Start a GET request.""" if not self.hass.config_entries.async_loaded_entries(DOMAIN): raise HTTPNotFound - asset_id, file_name, size = location.split("/") + asset_id, file_name, size = location.split("/") mime_type, _ = mimetypes.guess_type(file_name) if not isinstance(mime_type, str): raise HTTPNotFound @@ -202,6 +225,20 @@ class ImmichMediaView(HomeAssistantView): assert entry immich_api = entry.runtime_data.api + # stream response for videos + if mime_type.startswith("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: image = await immich_api.assets.async_view_asset(asset_id, size) except ImmichError as exc: diff --git a/homeassistant/util/aiohttp.py b/homeassistant/util/aiohttp.py index 5571861f417..aad9771d963 100644 --- a/homeassistant/util/aiohttp.py +++ b/homeassistant/util/aiohttp.py @@ -37,6 +37,9 @@ class MockPayloadWriter: async def write_headers(self, *args: Any, **kwargs: Any) -> None: """Write headers.""" + async def write(self, *args: Any, **kwargs: Any) -> None: + """Write payload.""" + _MOCK_PAYLOAD_WRITER = MockPayloadWriter() diff --git a/tests/components/immich/__init__.py b/tests/components/immich/__init__.py index 604ab84d68d..3a48c2cd725 100644 --- a/tests/components/immich/__init__.py +++ b/tests/components/immich/__init__.py @@ -1,10 +1,19 @@ """Tests for the Immich integration.""" from homeassistant.core import HomeAssistant +from homeassistant.util.aiohttp import MockStreamReader from tests.common import MockConfigEntry +class MockStreamReaderChunked(MockStreamReader): + """Mock a stream reader with simulated chunked data.""" + + async def readchunk(self) -> tuple[bytes, bool]: + """Read bytes.""" + return (self._content.read(), False) + + async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: """Fixture for setting up the component.""" config_entry.add_to_hass(hass) diff --git a/tests/components/immich/conftest.py b/tests/components/immich/conftest.py index d26eddfd55e..5a957870f07 100644 --- a/tests/components/immich/conftest.py +++ b/tests/components/immich/conftest.py @@ -24,6 +24,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from . import MockStreamReaderChunked from .const import MOCK_ALBUM_WITH_ASSETS, MOCK_ALBUM_WITHOUT_ASSETS from tests.common import MockConfigEntry @@ -69,6 +70,7 @@ def mock_immich_assets() -> AsyncMock: """Mock the Immich server.""" mock = AsyncMock(spec=ImmichAssests) mock.async_view_asset.return_value = b"xxxx" + mock.async_play_video_stream.return_value = MockStreamReaderChunked(b"xxxx") return mock diff --git a/tests/components/immich/const.py b/tests/components/immich/const.py index aeec4764732..ac0b221f721 100644 --- a/tests/components/immich/const.py +++ b/tests/components/immich/const.py @@ -41,5 +41,12 @@ MOCK_ALBUM_WITH_ASSETS = ImmichAlbum( "This is my first great album", "0d03a7ad-ddc7-45a6-adee-68d322a6d2f5", 1, - [ImmichAsset("2e94c203-50aa-4ad2-8e29-56dd74e0eff4", "filename.jpg", "image/jpeg")], + [ + ImmichAsset( + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4", "filename.jpg", "image/jpeg" + ), + ImmichAsset( + "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b", "filename.mp4", "video/mp4" + ), + ], ) diff --git a/tests/components/immich/test_media_source.py b/tests/components/immich/test_media_source.py index 772f0535f02..ae7201f5e70 100644 --- a/tests/components/immich/test_media_source.py +++ b/tests/components/immich/test_media_source.py @@ -25,7 +25,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.aiohttp import MockRequest -from . import setup_integration +from . import MockStreamReaderChunked, setup_integration from .const import MOCK_ALBUM_WITHOUT_ASSETS from tests.common import MockConfigEntry @@ -255,7 +255,7 @@ async def test_browse_media_get_items( result = await source.async_browse_media(item) assert result - assert len(result.children) == 1 + assert len(result.children) == 2 media_file = result.children[0] assert isinstance(media_file, BrowseMedia) assert media_file.identifier == ( @@ -273,6 +273,23 @@ async def test_browse_media_get_items( "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/filename.jpg/thumbnail" ) + media_file = result.children[1] + assert isinstance(media_file, BrowseMedia) + assert media_file.identifier == ( + "e7ef5713-9dab-4bd4-b899-715b0ca4379e/" + "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6/" + "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b/filename.mp4" + ) + 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.jpg/thumbnail" + ) + async def test_media_view( hass: HomeAssistant, @@ -317,6 +334,22 @@ async def test_media_view( "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/filename.jpg/thumbnail", ) + # exception in async_play_video_stream() + mock_immich.assets.async_play_video_stream.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", + "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b/filename.mp4/fullsize", + ) + # success mock_immich.assets.async_view_asset.side_effect = None mock_immich.assets.async_view_asset.return_value = b"xxxx" @@ -334,3 +367,15 @@ async def test_media_view( "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/filename.jpg/fullsize", ) 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" + ) + with patch.object(tempfile, "tempdir", tmp_path): + result = await view.get( + request, + "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b/filename.mp4/fullsize", + ) + assert isinstance(result, web.StreamResponse)