Home Assistant Unofficial Reference 2024.12.1
media_source.py
Go to the documentation of this file.
1 """Media source for Google Photos."""
2 
3 from __future__ import annotations
4 
5 from dataclasses import dataclass
6 from enum import StrEnum
7 import logging
8 from typing import Self, cast
9 
10 from google_photos_library_api.exceptions import GooglePhotosApiError
11 from google_photos_library_api.model import Album, MediaItem
12 
13 from homeassistant.components.media_player import MediaClass, MediaType
15  BrowseError,
16  BrowseMediaSource,
17  MediaSource,
18  MediaSourceItem,
19  PlayMedia,
20 )
21 from homeassistant.core import HomeAssistant
22 
23 from . import GooglePhotosConfigEntry
24 from .const import DOMAIN, READ_SCOPE
25 
26 _LOGGER = logging.getLogger(__name__)
27 
28 MEDIA_ITEMS_PAGE_SIZE = 100
29 ALBUM_PAGE_SIZE = 50
30 
31 THUMBNAIL_SIZE = 256
32 LARGE_IMAGE_SIZE = 2160
33 
34 
35 # The PhotosIdentifier can be in the following forms:
36 # config-entry-id
37 # config-entry-id/a/album-media-id
38 # config-entry-id/p/photo-media-id
39 #
40 # The album-media-id can contain special reserved folder names for use by
41 # this integration for virtual folders like the `recent` album.
42 
43 
44 class PhotosIdentifierType(StrEnum):
45  """Type for a PhotosIdentifier."""
46 
47  PHOTO = "p"
48  ALBUM = "a"
49 
50  @classmethod
51  def of(cls, name: str) -> PhotosIdentifierType:
52  """Parse a PhotosIdentifierType by string value."""
53  for enum in PhotosIdentifierType:
54  if enum.value == name:
55  return enum
56  raise ValueError(f"Invalid PhotosIdentifierType: {name}")
57 
58 
59 @dataclass
61  """Google Photos item identifier in a media source URL."""
62 
63  config_entry_id: str
64  """Identifies the account for the media item."""
65 
66  id_type: PhotosIdentifierType | None = None
67  """Type of identifier"""
68 
69  media_id: str | None = None
70  """Identifies the album or photo contents to show."""
71 
72  def as_string(self) -> str:
73  """Serialize the identifier as a string."""
74  if self.id_type is None:
75  return self.config_entry_id
76  return f"{self.config_entry_id}/{self.id_type}/{self.media_id}"
77 
78  @classmethod
79  def of(cls, identifier: str) -> Self:
80  """Parse a PhotosIdentifier form a string."""
81  parts = identifier.split("/")
82  if len(parts) == 1:
83  return cls(parts[0])
84  if len(parts) != 3:
85  raise BrowseError(f"Invalid identifier: {identifier}")
86  return cls(parts[0], PhotosIdentifierType.of(parts[1]), parts[2])
87 
88  @classmethod
89  def album(cls, config_entry_id: str, media_id: str) -> Self:
90  """Create an album PhotosIdentifier."""
91  return cls(config_entry_id, PhotosIdentifierType.ALBUM, media_id)
92 
93  @classmethod
94  def photo(cls, config_entry_id: str, media_id: str) -> Self:
95  """Create an album PhotosIdentifier."""
96  return cls(config_entry_id, PhotosIdentifierType.PHOTO, media_id)
97 
98 
99 async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
100  """Set up Google Photos media source."""
101  return GooglePhotosMediaSource(hass)
102 
103 
104 class GooglePhotosMediaSource(MediaSource):
105  """Provide Google Photos as media sources."""
106 
107  name = "Google Photos"
108 
109  def __init__(self, hass: HomeAssistant) -> None:
110  """Initialize Google Photos source."""
111  super().__init__(DOMAIN)
112  self.hasshass = hass
113 
114  async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
115  """Resolve media identifier to a url.
116 
117  This will resolve a specific media item to a url for the full photo or video contents.
118  """
119  try:
120  identifier = PhotosIdentifier.of(item.identifier)
121  except ValueError as err:
122  raise BrowseError(f"Could not parse identifier: {item.identifier}") from err
123  if (
124  identifier.media_id is None
125  or identifier.id_type != PhotosIdentifierType.PHOTO
126  ):
127  raise BrowseError(
128  f"Could not resolve identiifer that is not a Photo: {identifier}"
129  )
130  entry = self._async_config_entry_async_config_entry(identifier.config_entry_id)
131  client = entry.runtime_data.client
132  media_item = await client.get_media_item(media_item_id=identifier.media_id)
133  if not media_item.mime_type:
134  raise BrowseError("Could not determine mime type of media item")
135  if media_item.media_metadata and (media_item.media_metadata.video is not None):
136  url = _video_url(media_item)
137  else:
138  url = _media_url(media_item, LARGE_IMAGE_SIZE)
139  return PlayMedia(
140  url=url,
141  mime_type=media_item.mime_type,
142  )
143 
144  async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource:
145  """Return details about the media source.
146 
147  This renders the multi-level album structure for an account, its albums,
148  or the contents of an album. This will return a BrowseMediaSource with a
149  single level of children at the next level of the hierarchy.
150  """
151  if not item.identifier:
152  # Top level view that lists all accounts.
153  return BrowseMediaSource(
154  domain=DOMAIN,
155  identifier=None,
156  media_class=MediaClass.DIRECTORY,
157  media_content_type=MediaClass.IMAGE,
158  title="Google Photos",
159  can_play=False,
160  can_expand=True,
161  children_media_class=MediaClass.DIRECTORY,
162  children=[
163  _build_account(entry, PhotosIdentifier(cast(str, entry.unique_id)))
164  for entry in self._async_config_entries_async_config_entries()
165  ],
166  )
167 
168  # Determine the configuration entry for this item
169  identifier = PhotosIdentifier.of(item.identifier)
170  entry = self._async_config_entry_async_config_entry(identifier.config_entry_id)
171  coordinator = entry.runtime_data
172  client = coordinator.client
173 
174  source = _build_account(entry, identifier)
175  if identifier.id_type is None:
176  albums = await coordinator.list_albums()
177  source.children = [
178  _build_album(
179  album.title,
180  PhotosIdentifier.album(
181  identifier.config_entry_id,
182  album.id,
183  ),
184  _cover_photo_url(album, THUMBNAIL_SIZE),
185  )
186  for album in albums
187  ]
188  return source
189 
190  if (
191  identifier.id_type != PhotosIdentifierType.ALBUM
192  or identifier.media_id is None
193  ):
194  raise BrowseError(f"Unsupported identifier: {identifier}")
195 
196  media_items: list[MediaItem] = []
197  try:
198  async for media_item_result in await client.list_media_items(
199  album_id=identifier.media_id, page_size=MEDIA_ITEMS_PAGE_SIZE
200  ):
201  media_items.extend(media_item_result.media_items)
202  except GooglePhotosApiError as err:
203  raise BrowseError(f"Error listing media items: {err}") from err
204 
205  source.children = [
207  PhotosIdentifier.photo(identifier.config_entry_id, media_item.id),
208  media_item,
209  )
210  for media_item in media_items
211  ]
212  return source
213 
214  def _async_config_entries(self) -> list[GooglePhotosConfigEntry]:
215  """Return all config entries that support photo library reads."""
216  entries = []
217  for entry in self.hasshass.config_entries.async_loaded_entries(DOMAIN):
218  scopes = entry.data["token"]["scope"].split(" ")
219  if READ_SCOPE in scopes:
220  entries.append(entry)
221  return entries
222 
223  def _async_config_entry(self, config_entry_id: str) -> GooglePhotosConfigEntry:
224  """Return a config entry with the specified id."""
225  entry = self.hasshass.config_entries.async_entry_for_domain_unique_id(
226  DOMAIN, config_entry_id
227  )
228  if not entry:
229  raise BrowseError(
230  f"Could not find config entry for identifier: {config_entry_id}"
231  )
232  return entry
233 
234 
236  config_entry: GooglePhotosConfigEntry,
237  identifier: PhotosIdentifier,
238 ) -> BrowseMediaSource:
239  """Build the root node for a Google Photos account for a config entry."""
240  return BrowseMediaSource(
241  domain=DOMAIN,
242  identifier=identifier.as_string(),
243  media_class=MediaClass.DIRECTORY,
244  media_content_type=MediaClass.IMAGE,
245  title=config_entry.title,
246  can_play=False,
247  can_expand=True,
248  )
249 
250 
252  title: str, identifier: PhotosIdentifier, thumbnail_url: str | None = None
253 ) -> BrowseMediaSource:
254  """Build an album node."""
255  return BrowseMediaSource(
256  domain=DOMAIN,
257  identifier=identifier.as_string(),
258  media_class=MediaClass.ALBUM,
259  media_content_type=MediaClass.ALBUM,
260  title=title,
261  can_play=False,
262  can_expand=True,
263  thumbnail=thumbnail_url,
264  )
265 
266 
268  identifier: PhotosIdentifier,
269  media_item: MediaItem,
270 ) -> BrowseMediaSource:
271  """Build the node for an individual photo or video."""
272  is_video = media_item.media_metadata and (
273  media_item.media_metadata.video is not None
274  )
275  return BrowseMediaSource(
276  domain=DOMAIN,
277  identifier=identifier.as_string(),
278  media_class=MediaClass.IMAGE if not is_video else MediaClass.VIDEO,
279  media_content_type=MediaType.IMAGE if not is_video else MediaType.VIDEO,
280  title=media_item.filename,
281  can_play=is_video,
282  can_expand=False,
283  thumbnail=_media_url(media_item, THUMBNAIL_SIZE),
284  )
285 
286 
287 def _media_url(media_item: MediaItem, max_size: int) -> str:
288  """Return a media item url with the specified max thumbnail size on the longest edge.
289 
290  See https://developers.google.com/photos/library/guides/access-media-items#base-urls
291  """
292  return f"{media_item.base_url}=h{max_size}"
293 
294 
295 def _video_url(media_item: MediaItem) -> str:
296  """Return a video url for the item.
297 
298  See https://developers.google.com/photos/library/guides/access-media-items#base-urls
299  """
300  return f"{media_item.base_url}=dv"
301 
302 
303 def _cover_photo_url(album: Album, max_size: int) -> str:
304  """Return a media item url for the cover photo of the album."""
305  return f"{album.cover_photo_base_url}=h{max_size}"
BrowseMediaSource async_browse_media(self, MediaSourceItem item)
GooglePhotosConfigEntry _async_config_entry(self, str config_entry_id)
Self album(cls, str config_entry_id, str media_id)
Definition: media_source.py:89
Self photo(cls, str config_entry_id, str media_id)
Definition: media_source.py:94
BrowseMediaSource _build_album(str title, PhotosIdentifier identifier, str|None thumbnail_url=None)
MediaSource async_get_media_source(HomeAssistant hass)
Definition: media_source.py:99
str _media_url(MediaItem media_item, int max_size)
BrowseMediaSource _build_media_item(PhotosIdentifier identifier, MediaItem media_item)
BrowseMediaSource _build_account(GooglePhotosConfigEntry config_entry, PhotosIdentifier identifier)
str _cover_photo_url(Album album, int max_size)