mirror of
https://github.com/home-assistant/core.git
synced 2025-07-28 15:47:12 +00:00
Add support for videos in Immich media source (#145254)
add support for videos
This commit is contained in:
parent
abcf925b79
commit
b0415588d7
@ -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:
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
@ -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"
|
||||||
|
),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
@ -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)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user