diff --git a/homeassistant/components/synology_dsm/common.py b/homeassistant/components/synology_dsm/common.py index 9c96cfc4296..b5a2c7bfad5 100644 --- a/homeassistant/components/synology_dsm/common.py +++ b/homeassistant/components/synology_dsm/common.py @@ -12,6 +12,7 @@ from synology_dsm.api.core.upgrade import SynoCoreUpgrade from synology_dsm.api.core.utilization import SynoCoreUtilization from synology_dsm.api.dsm.information import SynoDSMInformation from synology_dsm.api.dsm.network import SynoDSMNetwork +from synology_dsm.api.photos import SynoPhotos from synology_dsm.api.storage.storage import SynoStorage from synology_dsm.api.surveillance_station import SynoSurveillanceStation from synology_dsm.exceptions import ( @@ -56,6 +57,7 @@ class SynoApi: self.network: SynoDSMNetwork = None self.security: SynoCoreSecurity = None self.storage: SynoStorage = None + self.photos: SynoPhotos = None self.surveillance_station: SynoSurveillanceStation = None self.system: SynoCoreSystem = None self.upgrade: SynoCoreUpgrade = None @@ -66,6 +68,7 @@ class SynoApi: self._with_information = True self._with_security = True self._with_storage = True + self._with_photos = True self._with_surveillance_station = True self._with_system = True self._with_upgrade = True @@ -163,6 +166,7 @@ class SynoApi: self._fetching_entities.get(SynoCoreSecurity.API_KEY) ) self._with_storage = bool(self._fetching_entities.get(SynoStorage.API_KEY)) + self._with_photos = bool(self._fetching_entities.get(SynoStorage.API_KEY)) self._with_upgrade = bool(self._fetching_entities.get(SynoCoreUpgrade.API_KEY)) self._with_utilisation = bool( self._fetching_entities.get(SynoCoreUtilization.API_KEY) @@ -180,6 +184,13 @@ class SynoApi: self.dsm.reset(self.security) self.security = None + if not self._with_photos: + LOGGER.debug( + "Disable photos api from being updated or '%s'", self._entry.unique_id + ) + self.dsm.reset(self.photos) + self.photos = None + if not self._with_storage: LOGGER.debug( "Disable storage api from being updatedf or '%s'", self._entry.unique_id @@ -219,6 +230,10 @@ class SynoApi: LOGGER.debug("Enable security api updates for '%s'", self._entry.unique_id) self.security = self.dsm.security + if self._with_photos: + LOGGER.debug("Enable photos api updates for '%s'", self._entry.unique_id) + self.photos = self.dsm.photos + if self._with_storage: LOGGER.debug("Enable storage api updates for '%s'", self._entry.unique_id) self.storage = self.dsm.storage diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index 1149012cbb2..8060bce5c9b 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -3,6 +3,7 @@ "name": "Synology DSM", "codeowners": ["@hacf-fr", "@Quentame", "@mib1185"], "config_flow": true, + "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/synology_dsm", "iot_class": "local_polling", "loggers": ["synology_dsm"], diff --git a/homeassistant/components/synology_dsm/media_source.py b/homeassistant/components/synology_dsm/media_source.py new file mode 100644 index 00000000000..16db365f708 --- /dev/null +++ b/homeassistant/components/synology_dsm/media_source.py @@ -0,0 +1,231 @@ +"""Expose Synology DSM as a media source.""" +from __future__ import annotations + +import mimetypes + +from aiohttp import web +from synology_dsm.api.photos import SynoPhotosAlbum, SynoPhotosItem +from synology_dsm.exceptions import SynologyDSMException + +from homeassistant.components import http +from homeassistant.components.media_player import MediaClass +from homeassistant.components.media_source import ( + BrowseError, + BrowseMediaSource, + MediaSource, + MediaSourceItem, + PlayMedia, + Unresolvable, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .models import SynologyDSMData + + +async def async_get_media_source(hass: HomeAssistant) -> MediaSource: + """Set up Synology media source.""" + entries = hass.config_entries.async_entries(DOMAIN) + hass.http.register_view(SynologyDsmMediaView(hass)) + return SynologyPhotosMediaSource(hass, entries) + + +class SynologyPhotosMediaSourceIdentifier: + """Synology Photos item identifier.""" + + def __init__(self, identifier: str) -> None: + """Split identifier into parts.""" + parts = identifier.split("/") + + self.unique_id = None + self.album_id = None + self.cache_key = None + self.file_name = None + + if parts: + self.unique_id = parts[0] + if len(parts) > 1: + self.album_id = parts[1] + if len(parts) > 2: + self.cache_key = parts[2] + if len(parts) > 3: + self.file_name = parts[3] + + +class SynologyPhotosMediaSource(MediaSource): + """Provide Synology Photos as media sources.""" + + name = "Synology Photos" + + def __init__(self, hass: HomeAssistant, entries: list[ConfigEntry]) -> None: + """Initialize Synology source.""" + super().__init__(DOMAIN) + self.hass = hass + self.entries = entries + + async def async_browse_media( + self, + item: MediaSourceItem, + ) -> BrowseMediaSource: + """Return media.""" + if not self.hass.data.get(DOMAIN): + raise BrowseError("Diskstation not initialized") + return BrowseMediaSource( + domain=DOMAIN, + identifier=None, + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title="Synology Photos", + can_play=False, + can_expand=True, + children_media_class=MediaClass.DIRECTORY, + children=[ + *await self._async_build_diskstations(item), + ], + ) + + async def _async_build_diskstations( + self, item: MediaSourceItem + ) -> list[BrowseMediaSource]: + """Handle browsing different diskstations.""" + if not item.identifier: + ret = [] + for entry in self.entries: + ret.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=entry.unique_id, + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title=f"{entry.title} - {entry.unique_id}", + can_play=False, + can_expand=True, + ) + ) + return ret + identifier = SynologyPhotosMediaSourceIdentifier(item.identifier) + diskstation: SynologyDSMData = self.hass.data[DOMAIN][identifier.unique_id] + + if identifier.album_id is None: + # Get Albums + try: + albums = await diskstation.api.photos.get_albums() + except SynologyDSMException: + return [] + + ret = [ + BrowseMediaSource( + domain=DOMAIN, + identifier=f"{item.identifier}/0", + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title="All images", + can_play=False, + can_expand=True, + ) + ] + for album in albums: + ret.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=f"{item.identifier}/{album.album_id}", + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title=album.name, + can_play=False, + can_expand=True, + ) + ) + + return ret + + # Request items of album + # Get Items + album = SynoPhotosAlbum(int(identifier.album_id), "", 0) + try: + album_items = await diskstation.api.photos.get_items_from_album( + album, 0, 1000 + ) + except SynologyDSMException: + return [] + + ret = [] + for album_item in album_items: + mime_type, _ = mimetypes.guess_type(album_item.file_name) + assert isinstance(mime_type, str) + if mime_type.startswith("image/"): + # Force small small thumbnails + album_item.thumbnail_size = "sm" + ret.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=f"{identifier.unique_id}/{identifier.album_id}/{album_item.thumbnail_cache_key}/{album_item.file_name}", + media_class=MediaClass.IMAGE, + media_content_type=mime_type, + title=album_item.file_name, + can_play=True, + can_expand=False, + thumbnail=await self.async_get_thumbnail( + album_item, diskstation + ), + ) + ) + return ret + + async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: + """Resolve media to a url.""" + identifier = SynologyPhotosMediaSourceIdentifier(item.identifier) + if identifier.album_id is None: + raise Unresolvable("No album id") + 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") + return PlayMedia( + f"/synology_dsm/{identifier.unique_id}/{identifier.cache_key}/{identifier.file_name}", + mime_type, + ) + + async def async_get_thumbnail( + self, item: SynoPhotosItem, diskstation: SynologyDSMData + ) -> str | None: + """Get thumbnail.""" + try: + thumbnail = await diskstation.api.photos.get_item_thumbnail_url(item) + except SynologyDSMException: + return None + return str(thumbnail) + + +class SynologyDsmMediaView(http.HomeAssistantView): + """Synology Media Finder View.""" + + url = "/synology_dsm/{source_dir_id}/{location:.*}" + name = "synology_dsm" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the media view.""" + self.hass = hass + + async def get( + self, request: web.Request, source_dir_id: str, location: str + ) -> web.Response: + """Start a GET request.""" + if not self.hass.data.get(DOMAIN): + raise web.HTTPNotFound() + # location: {cache_key}/{filename} + cache_key, file_name = location.split("/") + image_id = cache_key.split("_")[0] + mime_type, _ = mimetypes.guess_type(file_name) + if not isinstance(mime_type, str): + raise web.HTTPNotFound() + diskstation: SynologyDSMData = self.hass.data[DOMAIN][source_dir_id] + + item = SynoPhotosItem(image_id, "", "", "", cache_key, "") + try: + image = await diskstation.api.photos.download_item(item) + except SynologyDSMException as exc: + raise web.HTTPNotFound() from exc + return web.Response(body=image, content_type=mime_type) diff --git a/tests/components/synology_dsm/conftest.py b/tests/components/synology_dsm/conftest.py index d6f754c390b..77ef1b61e9e 100644 --- a/tests/components/synology_dsm/conftest.py +++ b/tests/components/synology_dsm/conftest.py @@ -4,6 +4,9 @@ from unittest.mock import AsyncMock, patch import pytest +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: @@ -14,6 +17,12 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: yield mock_setup +@pytest.fixture +async def setup_media_source(hass: HomeAssistant) -> None: + """Set up media source.""" + assert await async_setup_component(hass, "media_source", {}) + + @pytest.fixture(name="mock_dsm") def fixture_dsm(): """Set up SynologyDSM API fixture.""" diff --git a/tests/components/synology_dsm/test_media_source.py b/tests/components/synology_dsm/test_media_source.py new file mode 100644 index 00000000000..e0aadf9260c --- /dev/null +++ b/tests/components/synology_dsm/test_media_source.py @@ -0,0 +1,412 @@ +"""Tests for Synology DSM Media Source.""" + +from pathlib import Path +import tempfile +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from synology_dsm.api.photos import SynoPhotosAlbum, SynoPhotosItem +from synology_dsm.exceptions import SynologyDSMException + +from homeassistant.components.media_player import MediaClass +from homeassistant.components.media_source import ( + BrowseError, + BrowseMedia, + MediaSourceItem, + Unresolvable, +) +from homeassistant.components.synology_dsm.const import DOMAIN +from homeassistant.components.synology_dsm.media_source import ( + SynologyDsmMediaView, + SynologyPhotosMediaSource, + async_get_media_source, +) +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.util.aiohttp import MockRequest, web + +from .consts import HOST, MACS, PASSWORD, PORT, USE_SSL, USERNAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def dsm_with_photos() -> MagicMock: + """Set up SynologyDSM API fixture.""" + dsm = MagicMock() + dsm.login = AsyncMock(return_value=True) + dsm.update = AsyncMock(return_value=True) + dsm.network.update = AsyncMock(return_value=True) + dsm.surveillance_station.update = AsyncMock(return_value=True) + dsm.upgrade.update = AsyncMock(return_value=True) + + dsm.photos.get_albums = AsyncMock(return_value=[SynoPhotosAlbum(1, "Album 1", 10)]) + dsm.photos.get_items_from_album = AsyncMock( + return_value=[SynoPhotosItem(10, "", "filename.jpg", 12345, "10_1298753", "sm")] + ) + dsm.photos.get_item_thumbnail_url = AsyncMock( + return_value="http://my.thumbnail.url" + ) + return dsm + + +@pytest.mark.usefixtures("setup_media_source") +async def test_get_media_source(hass: HomeAssistant) -> None: + """Test the async_get_media_source function and SynologyPhotosMediaSource constructor.""" + + source = await async_get_media_source(hass) + assert isinstance(source, SynologyPhotosMediaSource) + assert source.domain == DOMAIN + + +@pytest.mark.usefixtures("setup_media_source") +@pytest.mark.parametrize( + ("identifier", "exception_msg"), + [ + ("unique_id", "No album id"), + ("unique_id/1", "No file name"), + ("unique_id/1/cache_key", "No file name"), + ("unique_id/1/cache_key/filename", "No file extension"), + ], +) +async def test_resolve_media_bad_identifier( + hass: HomeAssistant, identifier: str, exception_msg: str +) -> None: + """Test resolve_media with bad identifiers.""" + source = await async_get_media_source(hass) + item = MediaSourceItem(hass, DOMAIN, identifier, None) + with pytest.raises(Unresolvable, match=exception_msg): + await source.async_resolve_media(item) + + +@pytest.mark.usefixtures("setup_media_source") +@pytest.mark.parametrize( + ("identifier", "url", "mime_type"), + [ + ( + "ABC012345/10/27643_876876/filename.jpg", + "/synology_dsm/ABC012345/27643_876876/filename.jpg", + "image/jpeg", + ), + ( + "ABC012345/12/12631_47189/filename.png", + "/synology_dsm/ABC012345/12631_47189/filename.png", + "image/png", + ), + ], +) +async def test_resolve_media_success( + hass: HomeAssistant, identifier: str, url: str, mime_type: str +) -> None: + """Test successful resolving an item.""" + source = await async_get_media_source(hass) + item = MediaSourceItem(hass, DOMAIN, identifier, None) + result = await source.async_resolve_media(item) + + assert result.url == url + assert result.mime_type == mime_type + + +@pytest.mark.usefixtures("setup_media_source") +async def test_browse_media_unconfigured(hass: HomeAssistant) -> None: + """Test browse_media without any devices being configured.""" + source = await async_get_media_source(hass) + item = MediaSourceItem( + hass, DOMAIN, "unique_id/album_id/cache_key/filename.jpg", None + ) + with pytest.raises(BrowseError, match="Diskstation not initialized"): + await source.async_browse_media(item) + + +@pytest.mark.usefixtures("setup_media_source") +async def test_browse_media_album_error( + hass: HomeAssistant, dsm_with_photos: MagicMock +) -> None: + """Test browse_media with unknown album.""" + with patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=dsm_with_photos, + ), patch("homeassistant.components.synology_dsm.PLATFORMS", return_value=[]): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + unique_id="mocked_syno_dsm_entry", + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + # exception in get_albums() + dsm_with_photos.photos.get_albums = AsyncMock( + side_effect=SynologyDSMException("", None) + ) + + source = await async_get_media_source(hass) + + item = MediaSourceItem(hass, DOMAIN, entry.unique_id, None) + result = await source.async_browse_media(item) + + assert result + assert result.identifier is None + assert len(result.children) == 0 + + +@pytest.mark.usefixtures("setup_media_source") +async def test_browse_media_get_root( + hass: HomeAssistant, dsm_with_photos: MagicMock +) -> None: + """Test browse_media returning root media sources.""" + with patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=dsm_with_photos, + ), patch("homeassistant.components.synology_dsm.PLATFORMS", return_value=[]): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + unique_id="mocked_syno_dsm_entry", + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + source = await async_get_media_source(hass) + item = MediaSourceItem(hass, DOMAIN, "", None) + result = await source.async_browse_media(item) + + assert result + assert len(result.children) == 1 + assert isinstance(result.children[0], BrowseMedia) + assert result.children[0].identifier == "mocked_syno_dsm_entry" + + +@pytest.mark.usefixtures("setup_media_source") +async def test_browse_media_get_albums( + hass: HomeAssistant, dsm_with_photos: MagicMock +) -> None: + """Test browse_media returning albums.""" + with patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=dsm_with_photos, + ), patch("homeassistant.components.synology_dsm.PLATFORMS", return_value=[]): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + unique_id="mocked_syno_dsm_entry", + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + source = await async_get_media_source(hass) + item = MediaSourceItem(hass, DOMAIN, "mocked_syno_dsm_entry", None) + result = await source.async_browse_media(item) + + assert result + assert len(result.children) == 2 + assert isinstance(result.children[0], BrowseMedia) + assert result.children[0].identifier == "mocked_syno_dsm_entry/0" + assert result.children[0].title == "All images" + assert isinstance(result.children[1], BrowseMedia) + assert result.children[1].identifier == "mocked_syno_dsm_entry/1" + assert result.children[1].title == "Album 1" + + +@pytest.mark.usefixtures("setup_media_source") +async def test_browse_media_get_items_error( + hass: HomeAssistant, dsm_with_photos: MagicMock +) -> None: + """Test browse_media returning albums.""" + with patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=dsm_with_photos, + ), patch("homeassistant.components.synology_dsm.PLATFORMS", return_value=[]): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + unique_id="mocked_syno_dsm_entry", + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + source = await async_get_media_source(hass) + + # unknown album + dsm_with_photos.photos.get_items_from_album = AsyncMock(return_value=[]) + item = MediaSourceItem(hass, DOMAIN, "mocked_syno_dsm_entry/1", None) + result = await source.async_browse_media(item) + + assert result + assert result.identifier is None + assert len(result.children) == 0 + + # exception in get_items_from_album() + dsm_with_photos.photos.get_items_from_album = AsyncMock( + side_effect=SynologyDSMException("", None) + ) + item = MediaSourceItem(hass, DOMAIN, "mocked_syno_dsm_entry/1", None) + result = await source.async_browse_media(item) + + assert result + assert result.identifier is None + assert len(result.children) == 0 + + +@pytest.mark.usefixtures("setup_media_source") +async def test_browse_media_get_items_thumbnail_error( + hass: HomeAssistant, dsm_with_photos: MagicMock +) -> None: + """Test browse_media returning albums.""" + with patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=dsm_with_photos, + ), patch("homeassistant.components.synology_dsm.PLATFORMS", return_value=[]): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + unique_id="mocked_syno_dsm_entry", + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + source = await async_get_media_source(hass) + + dsm_with_photos.photos.get_item_thumbnail_url = AsyncMock( + side_effect=SynologyDSMException("", None) + ) + item = MediaSourceItem(hass, DOMAIN, "mocked_syno_dsm_entry/1", None) + result = await source.async_browse_media(item) + + assert result + assert len(result.children) == 1 + item = result.children[0] + assert isinstance(item, BrowseMedia) + assert item.thumbnail is None + + +@pytest.mark.usefixtures("setup_media_source") +async def test_browse_media_get_items( + hass: HomeAssistant, dsm_with_photos: MagicMock +) -> None: + """Test browse_media returning albums.""" + with patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=dsm_with_photos, + ), patch("homeassistant.components.synology_dsm.PLATFORMS", return_value=[]): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + unique_id="mocked_syno_dsm_entry", + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + source = await async_get_media_source(hass) + + item = MediaSourceItem(hass, DOMAIN, "mocked_syno_dsm_entry/1", None) + result = await source.async_browse_media(item) + + assert result + assert len(result.children) == 1 + item = result.children[0] + assert isinstance(item, BrowseMedia) + assert item.identifier == "mocked_syno_dsm_entry/1/10_1298753/filename.jpg" + assert item.title == "filename.jpg" + assert item.media_class == MediaClass.IMAGE + assert item.media_content_type == "image/jpeg" + assert item.can_play + assert not item.can_expand + assert item.thumbnail == "http://my.thumbnail.url" + + +@pytest.mark.usefixtures("setup_media_source") +async def test_media_view( + hass: HomeAssistant, tmp_path: Path, dsm_with_photos: MagicMock +) -> None: + """Test SynologyDsmMediaView returning albums.""" + view = SynologyDsmMediaView(hass) + request = MockRequest(b"", DOMAIN) + + # diskation not set uped + with pytest.raises(web.HTTPNotFound): + await view.get(request, "", "") + + with patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=dsm_with_photos, + ), patch("homeassistant.components.synology_dsm.PLATFORMS", return_value=[]): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + unique_id="mocked_syno_dsm_entry", + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + + with pytest.raises(web.HTTPNotFound): + await view.get(request, "", "10_1298753/filename") + + # exception in download_item() + dsm_with_photos.photos.download_item = AsyncMock( + side_effect=SynologyDSMException("", None) + ) + with pytest.raises(web.HTTPNotFound): + await view.get(request, "mocked_syno_dsm_entry", "10_1298753/filename.jpg") + + # success + dsm_with_photos.photos.download_item = AsyncMock(return_value=b"xxxx") + tempfile.tempdir = tmp_path + result = await view.get(request, "mocked_syno_dsm_entry", "10_1298753/filename.jpg") + assert isinstance(result, web.Response)