Add support for videos in Immich media source (#145254)

add support for videos
This commit is contained in:
Michael 2025-05-20 20:40:22 +02:00 committed by GitHub
parent abcf925b79
commit b0415588d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 110 additions and 7 deletions

View File

@ -5,7 +5,7 @@ from __future__ import annotations
from logging import getLogger from logging import getLogger
import mimetypes import mimetypes
from aiohttp.web import HTTPNotFound, Request, Response from aiohttp.web import HTTPNotFound, Request, Response, StreamResponse
from aioimmich.exceptions import ImmichError from aioimmich.exceptions import ImmichError
from homeassistant.components.http import HomeAssistantView from homeassistant.components.http import HomeAssistantView
@ -20,6 +20,7 @@ from homeassistant.components.media_source import (
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator
from .const import DOMAIN from .const import DOMAIN
from .coordinator import ImmichConfigEntry from .coordinator import ImmichConfigEntry
@ -136,7 +137,7 @@ class ImmichMediaSource(MediaSource):
except ImmichError: except ImmichError:
return [] return []
return [ ret = [
BrowseMediaSource( BrowseMediaSource(
domain=DOMAIN, domain=DOMAIN,
identifier=( identifier=(
@ -156,6 +157,28 @@ class ImmichMediaSource(MediaSource):
if asset.mime_type.startswith("image/") 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: async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
"""Resolve media to a url.""" """Resolve media to a url."""
identifier = ImmichMediaSourceIdentifier(item.identifier) identifier = ImmichMediaSourceIdentifier(item.identifier)
@ -184,12 +207,12 @@ class ImmichMediaView(HomeAssistantView):
async def get( async def get(
self, request: Request, source_dir_id: str, location: str self, request: Request, source_dir_id: str, location: str
) -> Response: ) -> Response | StreamResponse:
"""Start a GET request.""" """Start a GET request."""
if not self.hass.config_entries.async_loaded_entries(DOMAIN): if not self.hass.config_entries.async_loaded_entries(DOMAIN):
raise HTTPNotFound raise HTTPNotFound
asset_id, file_name, size = location.split("/")
asset_id, file_name, size = location.split("/")
mime_type, _ = mimetypes.guess_type(file_name) mime_type, _ = mimetypes.guess_type(file_name)
if not isinstance(mime_type, str): if not isinstance(mime_type, str):
raise HTTPNotFound raise HTTPNotFound
@ -202,6 +225,20 @@ class ImmichMediaView(HomeAssistantView):
assert entry assert entry
immich_api = entry.runtime_data.api 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: try:
image = await immich_api.assets.async_view_asset(asset_id, size) image = await immich_api.assets.async_view_asset(asset_id, size)
except ImmichError as exc: except ImmichError as exc:

View File

@ -37,6 +37,9 @@ class MockPayloadWriter:
async def write_headers(self, *args: Any, **kwargs: Any) -> None: async def write_headers(self, *args: Any, **kwargs: Any) -> None:
"""Write headers.""" """Write headers."""
async def write(self, *args: Any, **kwargs: Any) -> None:
"""Write payload."""
_MOCK_PAYLOAD_WRITER = MockPayloadWriter() _MOCK_PAYLOAD_WRITER = MockPayloadWriter()

View File

@ -1,10 +1,19 @@
"""Tests for the Immich integration.""" """Tests for the Immich integration."""
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.util.aiohttp import MockStreamReader
from tests.common import MockConfigEntry 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: async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Fixture for setting up the component.""" """Fixture for setting up the component."""
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)

View File

@ -24,6 +24,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from . import MockStreamReaderChunked
from .const import MOCK_ALBUM_WITH_ASSETS, MOCK_ALBUM_WITHOUT_ASSETS from .const import MOCK_ALBUM_WITH_ASSETS, MOCK_ALBUM_WITHOUT_ASSETS
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@ -69,6 +70,7 @@ def mock_immich_assets() -> AsyncMock:
"""Mock the Immich server.""" """Mock the Immich server."""
mock = AsyncMock(spec=ImmichAssests) mock = AsyncMock(spec=ImmichAssests)
mock.async_view_asset.return_value = b"xxxx" mock.async_view_asset.return_value = b"xxxx"
mock.async_play_video_stream.return_value = MockStreamReaderChunked(b"xxxx")
return mock return mock

View File

@ -41,5 +41,12 @@ MOCK_ALBUM_WITH_ASSETS = ImmichAlbum(
"This is my first great album", "This is my first great album",
"0d03a7ad-ddc7-45a6-adee-68d322a6d2f5", "0d03a7ad-ddc7-45a6-adee-68d322a6d2f5",
1, 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"
),
],
) )

View File

@ -25,7 +25,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.util.aiohttp import MockRequest from homeassistant.util.aiohttp import MockRequest
from . import setup_integration from . import MockStreamReaderChunked, setup_integration
from .const import MOCK_ALBUM_WITHOUT_ASSETS from .const import MOCK_ALBUM_WITHOUT_ASSETS
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@ -255,7 +255,7 @@ async def test_browse_media_get_items(
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) == 2
media_file = result.children[0] media_file = result.children[0]
assert isinstance(media_file, BrowseMedia) assert isinstance(media_file, BrowseMedia)
assert media_file.identifier == ( assert media_file.identifier == (
@ -273,6 +273,23 @@ async def test_browse_media_get_items(
"2e94c203-50aa-4ad2-8e29-56dd74e0eff4/filename.jpg/thumbnail" "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( async def test_media_view(
hass: HomeAssistant, hass: HomeAssistant,
@ -317,6 +334,22 @@ async def test_media_view(
"2e94c203-50aa-4ad2-8e29-56dd74e0eff4/filename.jpg/thumbnail", "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 # success
mock_immich.assets.async_view_asset.side_effect = None mock_immich.assets.async_view_asset.side_effect = None
mock_immich.assets.async_view_asset.return_value = b"xxxx" 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", "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/filename.jpg/fullsize",
) )
assert isinstance(result, web.Response) 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)