Files
core/homeassistant/components/icloud/media_source.py
T

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