mirror of
https://github.com/home-assistant/core.git
synced 2026-09-08 14:41:38 +01:00
676 lines
23 KiB
Python
676 lines
23 KiB
Python
"""Expose iCloud photo albums as a media source."""
|
|
|
|
from base64 import b64decode, b64encode
|
|
import binascii
|
|
from collections import OrderedDict
|
|
from dataclasses import dataclass
|
|
import logging
|
|
import threading
|
|
from typing import override
|
|
import urllib.parse
|
|
|
|
from aiohttp import ClientTimeout, hdrs, web
|
|
from pyicloud.services.photos import (
|
|
AlbumContainer,
|
|
BasePhotoAlbum,
|
|
PhotoAlbumFolder,
|
|
PhotoAsset,
|
|
)
|
|
|
|
from homeassistant.components.http import HomeAssistantView
|
|
from homeassistant.components.http.static import CACHE_HEADERS
|
|
from homeassistant.components.media_player import BrowseError, MediaClass, MediaType
|
|
from homeassistant.components.media_source import (
|
|
BrowseMediaSource,
|
|
MediaSource,
|
|
MediaSourceItem,
|
|
PlayMedia,
|
|
Unresolvable,
|
|
)
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|
|
|
from .account import IcloudAccount
|
|
from .const import DOMAIN
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
MAX_PHOTO_CACHE_SIZE = 1000
|
|
|
|
|
|
def async_setup_mediasource(hass: HomeAssistant) -> None:
|
|
"""Set up the iCloud media source."""
|
|
hass.http.register_view(IcloudMediaSourceView(hass))
|
|
|
|
|
|
async def async_get_media_source(hass: HomeAssistant) -> IcloudMediaSource:
|
|
"""Set up iCloud media source."""
|
|
return IcloudMediaSource(hass)
|
|
|
|
|
|
def _get_icloud_account_and_title(
|
|
hass: HomeAssistant, identifier: IcloudMediaSourceIdentifier
|
|
) -> tuple[IcloudAccount, str]:
|
|
"""Get iCloud account from identifier. Also return the account title for display purposes."""
|
|
entry = hass.config_entries.async_entry_for_domain_unique_id(
|
|
DOMAIN, identifier.config_entry_id
|
|
)
|
|
if entry is None:
|
|
raise Unresolvable(
|
|
translation_domain=DOMAIN,
|
|
translation_key="config_entry_not_found",
|
|
translation_placeholders={"entry": identifier.config_entry_id},
|
|
)
|
|
if getattr(entry, "runtime_data", None) is None:
|
|
raise Unresolvable(
|
|
translation_domain=DOMAIN,
|
|
translation_key="account_not_initialized",
|
|
translation_placeholders={"entry": identifier.config_entry_id},
|
|
)
|
|
|
|
return entry.runtime_data, entry.title
|
|
|
|
|
|
async def async_setup_photo_cache(hass, account):
|
|
"""Set up the photo cache for the iCloud account."""
|
|
if account.photo_cache is None:
|
|
account.photo_cache = PhotoCache()
|
|
|
|
|
|
async def _get_photo_library(
|
|
hass: HomeAssistant,
|
|
icloud_account: IcloudAccount,
|
|
identifier: IcloudMediaSourceIdentifier,
|
|
) -> AlbumContainer:
|
|
"""Get photo library."""
|
|
|
|
def get_photo_library_sync() -> AlbumContainer:
|
|
"""Get photo library synchronously."""
|
|
if icloud_account.api is None or icloud_account.api.photos is None:
|
|
raise Unresolvable(
|
|
translation_domain=DOMAIN,
|
|
translation_key="account_not_initialized",
|
|
translation_placeholders={"entry": identifier.config_entry_id},
|
|
)
|
|
return (
|
|
icloud_account.api.photos.shared_streams
|
|
if identifier.shared_album is True
|
|
else icloud_account.api.photos.albums
|
|
)
|
|
|
|
return await hass.async_add_executor_job(get_photo_library_sync)
|
|
|
|
|
|
async def _get_photo_album(
|
|
hass: HomeAssistant,
|
|
icloud_account: IcloudAccount,
|
|
identifier: IcloudMediaSourceIdentifier,
|
|
) -> BasePhotoAlbum:
|
|
"""Get photo album from identifier."""
|
|
|
|
def _find_album_sync() -> BasePhotoAlbum | None:
|
|
"""Find album synchronously."""
|
|
album: BasePhotoAlbum | None = (
|
|
albums.get(identifier.album_id) if albums and identifier.album_id else None
|
|
)
|
|
if not album:
|
|
raise Unresolvable(
|
|
translation_domain=DOMAIN,
|
|
translation_key="album_not_found",
|
|
)
|
|
return album
|
|
|
|
albums: AlbumContainer | None = None
|
|
if icloud_account.api is None:
|
|
raise Unresolvable(
|
|
translation_domain=DOMAIN,
|
|
translation_key="account_not_initialized",
|
|
translation_placeholders={"entry": identifier.config_entry_id},
|
|
)
|
|
|
|
albums = await _get_photo_library(hass, icloud_account, identifier)
|
|
|
|
return await hass.async_add_executor_job(_find_album_sync)
|
|
|
|
|
|
async def _get_photo_asset(
|
|
hass: HomeAssistant, identifier: IcloudMediaSourceIdentifier
|
|
) -> PhotoAsset:
|
|
"""Get photo asset asynchronously."""
|
|
|
|
def _get_photo_asset_sync(album: BasePhotoAlbum) -> PhotoAsset | None:
|
|
"""Get photo asset synchronously."""
|
|
for item in album.photos:
|
|
if item.id == identifier.photo_id and identifier.photo_id is not None:
|
|
PhotoCache.instance(icloud_account).set(identifier.photo_id, item)
|
|
return item
|
|
return None
|
|
|
|
icloud_account, _ = _get_icloud_account_and_title(hass, identifier)
|
|
|
|
if identifier.album_id is None or identifier.photo_id is None:
|
|
raise Unresolvable(
|
|
translation_domain=DOMAIN,
|
|
translation_key="incomplete_media_source_identifier",
|
|
)
|
|
|
|
photo: PhotoAsset | None = await hass.async_add_executor_job(
|
|
PhotoCache.instance(icloud_account).get, identifier.photo_id
|
|
)
|
|
if photo is None:
|
|
album: BasePhotoAlbum = await _get_photo_album(hass, icloud_account, identifier)
|
|
photo = await hass.async_add_executor_job(_get_photo_asset_sync, album)
|
|
|
|
if photo is None:
|
|
raise Unresolvable(
|
|
translation_domain=DOMAIN,
|
|
translation_key="photo_not_found",
|
|
)
|
|
return photo
|
|
|
|
|
|
async def _get_media_mime_type(
|
|
hass: HomeAssistant, identifier: IcloudMediaSourceIdentifier
|
|
) -> str:
|
|
"""Get media MIME type asynchronously."""
|
|
photo: PhotoAsset = await _get_photo_asset(hass, identifier)
|
|
|
|
match photo.item_type:
|
|
case "image":
|
|
if photo.filename.lower().endswith(".png"):
|
|
return "image/png"
|
|
if photo.filename.lower().endswith(".heic"):
|
|
return "image/heic"
|
|
return "image/jpeg"
|
|
case "movie":
|
|
return "video/mp4"
|
|
case _:
|
|
raise Unresolvable(
|
|
translation_domain=DOMAIN,
|
|
translation_key="unsupported_media_type",
|
|
)
|
|
|
|
|
|
class PhotoCache:
|
|
"""Simple in-memory cache for PhotoAsset objects."""
|
|
|
|
@classmethod
|
|
def instance(cls, icloud_account: IcloudAccount) -> PhotoCache:
|
|
"""Get the account instance of the photo cache."""
|
|
if icloud_account.photo_cache is None:
|
|
raise Unresolvable(
|
|
translation_domain=DOMAIN,
|
|
translation_key="config_entry_not_loaded",
|
|
)
|
|
return icloud_account.photo_cache
|
|
|
|
def __init__(self, max_size: int = MAX_PHOTO_CACHE_SIZE) -> None:
|
|
"""Initialize the photo cache."""
|
|
self._cache: OrderedDict[str, PhotoAsset] = OrderedDict()
|
|
self._max_size = max_size
|
|
self._lock = threading.RLock()
|
|
|
|
def get(self, photo_id: str) -> PhotoAsset | None:
|
|
"""Get a photo from the cache."""
|
|
with self._lock:
|
|
photo = self._cache.get(photo_id)
|
|
if photo is not None:
|
|
# Move the accessed item to the end to show that it was recently used
|
|
self._cache.move_to_end(photo_id)
|
|
return photo
|
|
|
|
def set(self, photo_id: str, photo: PhotoAsset) -> None:
|
|
"""Set a photo in the cache."""
|
|
with self._lock:
|
|
self._cache[photo_id] = photo
|
|
if len(self._cache) > self._max_size:
|
|
self._cache.popitem(last=False)
|
|
|
|
|
|
@dataclass(kw_only=True)
|
|
class IcloudMediaSourceIdentifier:
|
|
"""Parse and represent an iCloud media source identifier.
|
|
|
|
Example identifier format: config_entry_id/album/album_id
|
|
Example identifier format: config_entry_id/shared/shared_album_id
|
|
Example identifier format: config_entry_id/album/album_id/photo_id
|
|
Example identifier format: config_entry_id/shared/shared_album_id/photo_id
|
|
|
|
"""
|
|
|
|
config_entry_id: str
|
|
shared_album: bool | None = None
|
|
album_id: str | None = None
|
|
photo_id: str | None = None
|
|
|
|
@staticmethod
|
|
def from_identifier(identifier: str) -> IcloudMediaSourceIdentifier:
|
|
"""Initialize iCloud media source identifier."""
|
|
config_entry_id: str = ""
|
|
shared_album: bool | None = None
|
|
album_id: str | None = None
|
|
photo_id: str | None = None
|
|
parts: list[str] = identifier.split("/") if identifier else []
|
|
|
|
for idx, part in enumerate(parts):
|
|
if idx == 0:
|
|
config_entry_id = part
|
|
elif idx == 1:
|
|
if part.lower() not in ("shared", "album"):
|
|
raise Unresolvable(
|
|
translation_domain=DOMAIN,
|
|
translation_key="invalid_view_type",
|
|
)
|
|
shared_album = part.lower() == "shared"
|
|
elif idx == 2:
|
|
album_id = part
|
|
elif idx == 3:
|
|
photo_id = part
|
|
|
|
if not config_entry_id:
|
|
raise Unresolvable(
|
|
translation_domain=DOMAIN,
|
|
translation_key="incomplete_media_source_identifier",
|
|
)
|
|
|
|
return IcloudMediaSourceIdentifier(
|
|
config_entry_id=config_entry_id,
|
|
shared_album=shared_album,
|
|
album_id=album_id,
|
|
photo_id=photo_id,
|
|
)
|
|
|
|
@override
|
|
def __str__(self) -> str:
|
|
"""Return string representation of the identifier."""
|
|
parts = [self.config_entry_id]
|
|
if self.shared_album is not None:
|
|
parts.append("shared" if self.shared_album else "album")
|
|
if self.album_id is not None:
|
|
parts.append(self.album_id)
|
|
if self.photo_id is not None:
|
|
parts.append(self.photo_id)
|
|
return "/".join(parts)
|
|
|
|
|
|
class IcloudMediaSource(MediaSource):
|
|
"""Provide iCloud media source."""
|
|
|
|
name = "iCloud"
|
|
|
|
def __init__(self, hass: HomeAssistant) -> None:
|
|
"""Initialize iCloud media source."""
|
|
super().__init__(DOMAIN)
|
|
self._hass = hass
|
|
|
|
@override
|
|
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
|
|
"""Resolve a media item to a playable object."""
|
|
if not self._hass.config_entries.async_loaded_entries(DOMAIN):
|
|
raise BrowseError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="config_entry_not_loaded",
|
|
)
|
|
|
|
identifier = IcloudMediaSourceIdentifier.from_identifier(item.identifier)
|
|
mime_type = await _get_media_mime_type(self._hass, identifier)
|
|
|
|
return PlayMedia(
|
|
f"/api/icloud/media_source/serve/original/{b64encode(str(item.identifier).encode()).decode()}",
|
|
mime_type,
|
|
)
|
|
|
|
def _get_config_entries(self) -> list[ConfigEntry]:
|
|
"""Get iCloud config entries."""
|
|
return self._hass.config_entries.async_entries(
|
|
DOMAIN, include_disabled=False, include_ignore=False
|
|
)
|
|
|
|
async def _build_title_for_identifier(
|
|
self,
|
|
identifier: IcloudMediaSourceIdentifier | None,
|
|
) -> str:
|
|
"""Build title for media source identifier."""
|
|
title_parts = ["iCloud Media"]
|
|
icloud_account = None
|
|
|
|
if identifier and identifier.config_entry_id is not None:
|
|
icloud_account, title = _get_icloud_account_and_title(
|
|
self._hass, identifier
|
|
)
|
|
title_parts.append(title)
|
|
|
|
if identifier and identifier.shared_album is True:
|
|
title_parts.append("Shared Streams")
|
|
elif identifier and identifier.shared_album is False:
|
|
title_parts.append("Albums")
|
|
|
|
if icloud_account and identifier and identifier.album_id is not None:
|
|
album = await _get_photo_album(self._hass, icloud_account, identifier)
|
|
title_parts.append(album.title)
|
|
|
|
return " / ".join(title_parts)
|
|
|
|
@override
|
|
async def async_browse_media(
|
|
self,
|
|
item: MediaSourceItem,
|
|
) -> BrowseMediaSource:
|
|
"""Return media."""
|
|
if not self._hass.config_entries.async_loaded_entries(DOMAIN):
|
|
raise BrowseError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="config_entry_not_loaded",
|
|
)
|
|
|
|
if not item.identifier:
|
|
return await self._async_build_icloud_accounts()
|
|
|
|
identifier = IcloudMediaSourceIdentifier.from_identifier(item.identifier)
|
|
|
|
if identifier.shared_album is None:
|
|
return await self._async_build_album_types(identifier)
|
|
|
|
icloud_account, _ = _get_icloud_account_and_title(self._hass, identifier)
|
|
|
|
if identifier.album_id is None:
|
|
return await self._async_build_albums(identifier, icloud_account)
|
|
|
|
if identifier.photo_id is None:
|
|
return await self._async_build_photos(identifier, icloud_account)
|
|
|
|
raise BrowseError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="unknown_media_item",
|
|
)
|
|
|
|
async def _async_build_icloud_accounts(
|
|
self,
|
|
) -> BrowseMediaSource:
|
|
"""Handle browsing of different iCloud accounts."""
|
|
return BrowseMediaSource(
|
|
domain=DOMAIN,
|
|
identifier=None,
|
|
media_class=MediaClass.DIRECTORY,
|
|
media_content_type=MediaType.ALBUM,
|
|
title=await self._build_title_for_identifier(None),
|
|
can_play=False,
|
|
can_expand=True,
|
|
children_media_class=MediaClass.DIRECTORY,
|
|
children=[
|
|
BrowseMediaSource(
|
|
domain=DOMAIN,
|
|
identifier=str(
|
|
IcloudMediaSourceIdentifier(config_entry_id=entry.unique_id)
|
|
),
|
|
media_class=MediaClass.DIRECTORY,
|
|
media_content_type=MediaType.ALBUM,
|
|
title=entry.title,
|
|
can_play=False,
|
|
can_expand=True,
|
|
)
|
|
for entry in self._get_config_entries()
|
|
if entry.unique_id is not None
|
|
],
|
|
)
|
|
|
|
async def _async_build_album_types(
|
|
self,
|
|
identifier: IcloudMediaSourceIdentifier,
|
|
) -> BrowseMediaSource:
|
|
"""Handle browsing of album types (albums vs shared albums)."""
|
|
return BrowseMediaSource(
|
|
domain=DOMAIN,
|
|
identifier=None,
|
|
media_class=MediaClass.DIRECTORY,
|
|
media_content_type=MediaType.ALBUM,
|
|
title=await self._build_title_for_identifier(identifier),
|
|
can_play=False,
|
|
can_expand=True,
|
|
children_media_class=MediaClass.DIRECTORY,
|
|
children=[
|
|
BrowseMediaSource(
|
|
domain=DOMAIN,
|
|
identifier=str(
|
|
IcloudMediaSourceIdentifier(
|
|
config_entry_id=identifier.config_entry_id,
|
|
shared_album=False,
|
|
)
|
|
),
|
|
media_class=MediaClass.DIRECTORY,
|
|
media_content_type=MediaType.ALBUM,
|
|
can_play=False,
|
|
can_expand=True,
|
|
title="Albums",
|
|
),
|
|
BrowseMediaSource(
|
|
domain=DOMAIN,
|
|
identifier=str(
|
|
IcloudMediaSourceIdentifier(
|
|
config_entry_id=identifier.config_entry_id,
|
|
shared_album=True,
|
|
)
|
|
),
|
|
media_class=MediaClass.DIRECTORY,
|
|
media_content_type=MediaType.ALBUM,
|
|
can_play=False,
|
|
can_expand=True,
|
|
title="Shared Streams",
|
|
),
|
|
],
|
|
)
|
|
|
|
async def _async_build_albums(
|
|
self,
|
|
identifier: IcloudMediaSourceIdentifier,
|
|
icloud_account: IcloudAccount,
|
|
) -> BrowseMediaSource:
|
|
"""Handle browsing of albums."""
|
|
return BrowseMediaSource(
|
|
domain=DOMAIN,
|
|
identifier=None,
|
|
media_class=MediaClass.DIRECTORY,
|
|
media_content_type=MediaType.ALBUM,
|
|
title=await self._build_title_for_identifier(identifier),
|
|
can_play=False,
|
|
can_expand=True,
|
|
children_media_class=MediaClass.DIRECTORY,
|
|
children=await self._browse_albums(identifier, icloud_account),
|
|
)
|
|
|
|
async def _async_build_photos(
|
|
self,
|
|
identifier: IcloudMediaSourceIdentifier,
|
|
icloud_account: IcloudAccount,
|
|
) -> BrowseMediaSource:
|
|
"""Handle browsing of photos in an album."""
|
|
|
|
return BrowseMediaSource(
|
|
domain=DOMAIN,
|
|
identifier=None,
|
|
media_class=MediaClass.DIRECTORY,
|
|
media_content_type=MediaType.ALBUM,
|
|
title=await self._build_title_for_identifier(identifier),
|
|
can_play=False,
|
|
can_expand=True,
|
|
children_media_class=MediaClass.DIRECTORY,
|
|
children=await self._get_photo_list(identifier, icloud_account),
|
|
)
|
|
|
|
async def _browse_albums(
|
|
self,
|
|
identifier: IcloudMediaSourceIdentifier,
|
|
icloud_account: IcloudAccount,
|
|
) -> list[BrowseMediaSource]:
|
|
"""Browse albums asynchronously."""
|
|
|
|
albums: AlbumContainer | None = None
|
|
if icloud_account.api is None:
|
|
raise BrowseError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="account_not_initialized",
|
|
translation_placeholders={"entry": identifier.config_entry_id},
|
|
)
|
|
|
|
albums = await _get_photo_library(self._hass, icloud_account, identifier)
|
|
|
|
children: list[BrowseMediaSource] = []
|
|
if albums is not None:
|
|
for album in albums:
|
|
if isinstance(album, PhotoAlbumFolder):
|
|
continue
|
|
children.append(
|
|
BrowseMediaSource(
|
|
domain=DOMAIN,
|
|
identifier=str(
|
|
IcloudMediaSourceIdentifier(
|
|
config_entry_id=identifier.config_entry_id,
|
|
shared_album=identifier.shared_album,
|
|
album_id=album.id,
|
|
)
|
|
),
|
|
media_class=MediaClass.DIRECTORY,
|
|
media_content_type=MediaType.ALBUM,
|
|
can_play=False,
|
|
can_expand=True,
|
|
title=album.title,
|
|
)
|
|
)
|
|
return children
|
|
|
|
async def _get_photo_list(
|
|
self,
|
|
identifier: IcloudMediaSourceIdentifier,
|
|
icloud_account: IcloudAccount,
|
|
) -> list[BrowseMediaSource]:
|
|
"""Get list of photos asynchronously."""
|
|
|
|
def _get_photo_list_sync(album: BasePhotoAlbum) -> list[BrowseMediaSource]:
|
|
"""Get list of photos synchronously."""
|
|
items: list[BrowseMediaSource] = []
|
|
for photo in album.photos:
|
|
PhotoCache.instance(icloud_account).set(photo.id, photo)
|
|
photo_id = IcloudMediaSourceIdentifier(
|
|
config_entry_id=identifier.config_entry_id,
|
|
shared_album=identifier.shared_album,
|
|
album_id=identifier.album_id,
|
|
photo_id=photo.id,
|
|
)
|
|
|
|
item = BrowseMediaSource(
|
|
domain=DOMAIN,
|
|
identifier=str(photo_id),
|
|
media_class=(
|
|
MediaClass.IMAGE
|
|
if photo.item_type == "image"
|
|
else MediaClass.VIDEO
|
|
),
|
|
media_content_type=(
|
|
MediaType.IMAGE
|
|
if photo.item_type == "image"
|
|
else MediaType.VIDEO
|
|
),
|
|
can_play=True,
|
|
can_expand=False,
|
|
title=photo.filename,
|
|
thumbnail=f"/api/icloud/media_source/serve/thumb{'' if photo.item_type == 'image' else '_image'}/{b64encode(str(photo_id).encode()).decode()}",
|
|
)
|
|
items.append(item)
|
|
return items
|
|
|
|
album: BasePhotoAlbum = await _get_photo_album(
|
|
self._hass, icloud_account, identifier
|
|
)
|
|
return await self._hass.async_add_executor_job(_get_photo_list_sync, album)
|
|
|
|
|
|
class IcloudMediaSourceView(HomeAssistantView):
|
|
"""Handle media serving via HTTP view."""
|
|
|
|
url = "/api/icloud/media_source/serve/{version}/{image_id}"
|
|
name = "api:icloud:media_source:serve"
|
|
requires_auth = True
|
|
|
|
def __init__(self, hass: HomeAssistant) -> None:
|
|
"""Initialize iCloud media source view."""
|
|
super().__init__()
|
|
self._hass = hass
|
|
self.session = async_get_clientsession(hass)
|
|
|
|
async def get(
|
|
self,
|
|
request: web.Request,
|
|
version: str,
|
|
image_id: str,
|
|
) -> web.StreamResponse:
|
|
"""Get the image from iCloud."""
|
|
|
|
try:
|
|
identifier = IcloudMediaSourceIdentifier.from_identifier(
|
|
b64decode(image_id, validate=True).decode()
|
|
)
|
|
except (Unresolvable, binascii.Error, UnicodeDecodeError) as err:
|
|
_LOGGER.error("Error decoding iCloud media source identifier: %s", err)
|
|
raise web.HTTPBadRequest from err
|
|
|
|
try:
|
|
photo = await _get_photo_asset(self._hass, identifier)
|
|
except Unresolvable as err:
|
|
_LOGGER.error("Error resolving iCloud media source: %s", err)
|
|
raise web.HTTPNotFound from err
|
|
|
|
url = photo.versions.get(version, {}).get("url")
|
|
if url is None and version.startswith("thumb"):
|
|
# try the medium version for thumbnails if the requested version is not available, as some videos only have a medium version and no separate thumbnail version
|
|
url = photo.versions.get(version.replace("thumb", "medium"), {}).get("url")
|
|
if url is None:
|
|
raise web.HTTPNotFound
|
|
|
|
request_headers = {}
|
|
if hdrs.RANGE in request.headers:
|
|
request_headers[hdrs.RANGE] = request.headers[hdrs.RANGE]
|
|
|
|
icloud_response = await self.session.get(
|
|
url,
|
|
timeout=ClientTimeout(
|
|
connect=15, sock_connect=15, sock_read=30, total=None
|
|
),
|
|
headers=request_headers,
|
|
)
|
|
|
|
response_headers: dict[str, str] = {}
|
|
response_headers.update(CACHE_HEADERS)
|
|
response_headers[hdrs.CONTENT_DISPOSITION] = (
|
|
f'attachment;filename="{urllib.parse.quote(photo.filename, safe="")}"'
|
|
)
|
|
|
|
for header in (
|
|
hdrs.CONTENT_TYPE,
|
|
hdrs.LAST_MODIFIED,
|
|
hdrs.ACCEPT_RANGES,
|
|
hdrs.CONTENT_RANGE,
|
|
):
|
|
if header in icloud_response.headers:
|
|
response_headers[header] = icloud_response.headers[header]
|
|
|
|
response = web.StreamResponse(
|
|
status=icloud_response.status,
|
|
reason=icloud_response.reason,
|
|
headers=response_headers,
|
|
)
|
|
await response.prepare(request)
|
|
|
|
try:
|
|
async for chunk in icloud_response.content.iter_chunked(65536):
|
|
await response.write(chunk)
|
|
except TimeoutError:
|
|
_LOGGER.warning(
|
|
"Timeout while reading iCloud, writing EOF",
|
|
)
|
|
finally:
|
|
icloud_response.release()
|
|
|
|
await response.write_eof()
|
|
return response
|