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
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:

View File

@ -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()

View File

@ -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)

View File

@ -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

View File

@ -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"
),
],
)

View File

@ -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)