Skip to content

Commit

Permalink
feat: Expose media count per album
Browse files Browse the repository at this point in the history
feat: Centralized coordinators for managing albums
change: Entity for display current item is now called media
  • Loading branch information
Daanoz committed Feb 18, 2023
1 parent 377ca27 commit 4b2d368
Show file tree
Hide file tree
Showing 7 changed files with 201 additions and 103 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,11 @@ data:

## Notes / Remarks / Limitations

- Currently the album media is cached for 30 minutes.
- Currently the album media list is cached for 50 minutes.

## Future plans

- Reduce start-up time integration (currently loads entire album which can be slow)
- Support for videos
- Support loading media using [content categories](https://developers.google.com/photos/library/guides/apply-filters#content-categories)
- Support loading media filtered by date/time
Expand Down
10 changes: 7 additions & 3 deletions custom_components/google_photos/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@
async_get_config_entry_implementation,
)

from custom_components.google_photos.coordinator import Coordinator, CoordinatorManager

from .api import AsyncConfigEntryAuth
from .const import DATA_AUTH, DOMAIN
from .const import DOMAIN

PLATFORMS = [Platform.CAMERA]
PLATFORMS = [Platform.CAMERA, Platform.SENSOR]


async def async_migrate_entry(hass, config_entry: ConfigEntry):
Expand Down Expand Up @@ -50,7 +52,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
raise ConfigEntryNotReady from err
except ClientError as err:
raise ConfigEntryNotReady from err
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = auth
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = dict(
{"auth": auth, "coordinator_manager": CoordinatorManager(hass, entry, auth)}
)

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))
Expand Down
8 changes: 4 additions & 4 deletions custom_components/google_photos/api_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ class Album(TypedDict):

id: str
title: str
productUrl: str
productUrl: str | None
isWriteable: bool
mediaItemsCount: str
coverPhotoBaseUrl: str
coverPhotoMediaItemId: str
mediaItemsCount: str | None
coverPhotoBaseUrl: str | None
coverPhotoMediaItemId: str | None
106 changes: 22 additions & 84 deletions custom_components/google_photos/camera.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""Support for Google Photos Albums."""
from __future__ import annotations
from typing import List
import logging

import voluptuous as vol
Expand All @@ -12,21 +11,16 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_platform
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback

from .api import AsyncConfigEntryAuth
from .api_types import Album
from .const import (
DOMAIN,
MANUFACTURER,
MODE_OPTIONS,
CONF_WRITEMETADATA,
WRITEMETADATA_DEFAULT_OPTION,
CONF_ALBUM_ID,
CONF_ALBUM_ID_FAVORITES,
)
from .coordinator import Coordinator
from .coordinator import Coordinator, CoordinatorManager

SERVICE_NEXT_MEDIA = "next_media"
ATTR_MODE = "mode"
Expand All @@ -43,29 +37,14 @@ async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the Google Photos camera."""
auth: AsyncConfigEntryAuth = hass.data[DOMAIN][entry.entry_id]
service = await auth.get_resource(hass)
album_ids = entry.options[CONF_ALBUM_ID]
entry_data = hass.data[DOMAIN][entry.entry_id]
coordinator_manager: CoordinatorManager = entry_data.get("coordinator_manager")

def _get_albums() -> List[GooglePhotosBaseCamera]:
album_list = []
for album_id in album_ids:
coordinator = Coordinator(hass, auth, entry)
if album_id == CONF_ALBUM_ID_FAVORITES:
album_list.append(
GooglePhotosFavoritesCamera(entry.entry_id, coordinator)
)
else:
album = service.albums().get(albumId=album_id).execute()
album_list.append(
GooglePhotosAlbumCamera(entry.entry_id, coordinator, album)
)
return album_list

entities = await hass.async_add_executor_job(_get_albums)

for entity in entities:
await entity.coordinator.async_config_entry_first_refresh()
album_ids = entry.options[CONF_ALBUM_ID]
entities = []
for album_id in album_ids:
coordinator = await coordinator_manager.get_coordinator(album_id)
entities.append(GooglePhotosAlbumCamera(coordinator))

platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
Expand All @@ -84,18 +63,13 @@ class GooglePhotosBaseCamera(Camera):
"""Base class Google Photos Camera class. Implements methods from CoordinatorEntity"""

coordinator: Coordinator
coordinator_contxt: dict[str, str | int]
_attr_has_entity_name = True
_attr_icon = "mdi:image"

def __init__(
self, coordinator: Coordinator, album_context: dict[str, str | int]
) -> None:
def __init__(self, coordinator: Coordinator) -> None:
"""Initialize a Google Photos Base Camera class."""
super().__init__()
coordinator.set_context(album_context)
self.coordinator = coordinator
self.coordinator_context = album_context
self.entity_description = CAMERA_TYPE
self._attr_native_value = "Cover photo"
self._attr_frame_interval = 10
Expand All @@ -108,9 +82,7 @@ async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
self.async_on_remove(
self.coordinator.async_add_listener(
self._handle_coordinator_update, self.coordinator_context
)
self.coordinator.async_add_listener(self._handle_coordinator_update)
)

async def async_update(self) -> None:
Expand Down Expand Up @@ -171,53 +143,19 @@ async def async_camera_image(
class GooglePhotosAlbumCamera(GooglePhotosBaseCamera):
"""Representation of a Google Photos Album camera."""

album: Album

def __init__(self, entry_id: str, coordinator: Coordinator, album: Album) -> None:
def __init__(self, coordinator: Coordinator) -> None:
"""Initialize a Google Photos album."""
super().__init__(coordinator, dict(albumId=album["id"]))
self.album = album
self._attr_name = album["title"]
self._attr_unique_id = album["id"]
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry_id, album["id"])},
manufacturer=MANUFACTURER,
name="Google Photos - " + album["title"],
configuration_url=album["productUrl"],
)
super().__init__(coordinator)
self._attr_name = "Media"
album_id = self.coordinator.album["id"]
self._attr_unique_id = f"{album_id}-media"
self._attr_device_info = self.coordinator.get_device_info()

async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
await self.coordinator.set_current_media_with_id(
self.album["coverPhotoMediaItemId"]
)


class GooglePhotosFavoritesCamera(GooglePhotosBaseCamera):
"""Representation of a Google Photos Favorites camera."""

def __init__(
self,
entry_id: str,
coordinator: Coordinator,
) -> None:
"""Initialize a Google Photos album."""
filters = {"featureFilter": {"includedFeatures": ["FAVORITES"]}}
super().__init__(coordinator, dict(filters=filters))
self._attr_name = "Favorites"
self._attr_unique_id = "library_favorites"
self._attr_device_info = DeviceInfo(
identifiers={
(
DOMAIN,
entry_id,
CONF_ALBUM_ID_FAVORITES,
)
},
manufacturer=MANUFACTURER,
name="Google Photos - Favorites",
)

async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
self.next_media()
if self.coordinator.album.get("coverPhotoMediaItemId") is None:
self.next_media()
else:
await self.coordinator.set_current_media_with_id(
self.coordinator.album.get("coverPhotoMediaItemId")
)
2 changes: 1 addition & 1 deletion custom_components/google_photos/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from collections.abc import Mapping
import logging
from typing import Any, List, cast
from typing import Any, List
import voluptuous as vol

from google.oauth2.credentials import Credentials
Expand Down
100 changes: 90 additions & 10 deletions custom_components/google_photos/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,17 @@
from homeassistant.helpers.aiohttp_client import (
async_get_clientsession,
)
from homeassistant.helpers.entity import DeviceInfo
from .api import AsyncConfigEntryAuth
from .api_types import MediaItem
from .api_types import Album, MediaItem
from .const import (
CONF_ALBUM_ID_FAVORITES,
CONF_INTERVAL,
CONF_MODE,
DOMAIN,
INTERVAL_DEFAULT_OPTION,
INTERVAL_OPTION_NONE,
MANUFACTURER,
MODE_DEFAULT_OPTION,
MODE_OPTION_ALBUM_ORDER,
)
Expand All @@ -32,26 +35,67 @@
FIFTY_MINUTES = 60 * 50


class CoordinatorManager:
"""Manages all coordinators used by integration (one per album)"""

hass: HomeAssistant
_config: ConfigEntry
_auth: AsyncConfigEntryAuth
coordinators: dict[str, Coordinator] = dict()
coordinator_first_refresh: dict[str, asyncio.Task] = dict()

def __init__(
self,
hass: HomeAssistant,
config: ConfigEntry,
auth: AsyncConfigEntryAuth,
) -> None:
self.hass = hass
self._config = config
self._auth = auth

async def get_coordinator(self, album_id: str) -> Coordinator:
"""Get a unique coordinator for specific album_id"""
if album_id in self.coordinators:
await self.coordinator_first_refresh.get(album_id)
return self.coordinators.get(album_id)
self.coordinators[album_id] = Coordinator(
self.hass, self._auth, self._config, album_id
)
first_refresh = asyncio.create_task(
self.coordinators[album_id].async_config_entry_first_refresh()
)
self.coordinator_first_refresh[album_id] = first_refresh
await first_refresh
return self.coordinators[album_id]


class Coordinator(DataUpdateCoordinator):
"""Coordinates data retrieval and selection from Google Photos"""

_auth: AsyncConfigEntryAuth
_config: ConfigEntry
_context: dict[str, str | int] = dict()
album: Album = None
album_id: str
album_list: List[MediaItem] = []
current_media: MediaItem | None = None

current_media_cache: Dict[str, bytes] = {}

# Timestamop when these items where loaded
album_list_timestamp: datetime | None = None
album_list_timestamp = datetime.fromtimestamp(0)
# Media selection timestamp, when was this image selected to be shown, used to calculate when to move to the next one
current_media_selected_timestamp = datetime.now()
current_media_selected_timestamp = datetime.fromtimestamp(0)
# Age of the media object, because the data links are only valid for 60 mins,this is used to check if a new instance needs to be retrieved
current_media_data_timestamp = datetime.now()
current_media_data_timestamp = datetime.fromtimestamp(0)

def __init__(
self, hass: HomeAssistant, auth: AsyncConfigEntryAuth, config: ConfigEntry
self,
hass: HomeAssistant,
auth: AsyncConfigEntryAuth,
config: ConfigEntry,
album_id: str,
) -> None:
super().__init__(
hass,
Expand All @@ -63,6 +107,29 @@ def __init__(
)
self._auth = auth
self._config = config
self.album_id = album_id

if self.album_id == CONF_ALBUM_ID_FAVORITES:
filters = {"featureFilter": {"includedFeatures": ["FAVORITES"]}}
self.set_context(dict(filters=filters))
self.album = Album(id=self.album_id, title="Favorites", isWriteable=False)
else:
self.set_context(dict(albumId=self.album_id))

def get_device_info(self) -> DeviceInfo:
"""Fetches device info for coordinator instance"""
return DeviceInfo(
identifiers={
(
DOMAIN,
self._config.entry_id,
self.album_id,
)
},
manufacturer=MANUFACTURER,
name="Google Photos - " + self.album.get("title"),
configuration_url=self.album.get("productUrl"),
)

def set_context(self, context: dict[str, str | int]):
"""Set coordinator context"""
Expand Down Expand Up @@ -100,8 +167,12 @@ async def set_current_media_with_id(self, media_id: str):
"""Sets current selected media using only the id"""
try:
service = await self._auth.get_resource(self.hass)

def _get_media_item() -> MediaItem:
return service.mediaItems().get(mediaItemId=media_id).execute()

self.set_current_media(
service.mediaItems().get(mediaItemId=media_id).execute()
await self.hass.async_add_executor_job(_get_media_item)
)
except aiohttp.ClientError as err:
_LOGGER.error("Error getting image from %s: %s", self._context, err)
Expand Down Expand Up @@ -232,17 +303,26 @@ def sync_get_album_list() -> List[MediaItem]:
_LOGGER.error("Error getting media lise from %s: %s", self._context, err)

async def _refresh_album_list(self) -> bool:
if self.album_list_timestamp is not None:
cache_delta = (datetime.now() - self.album_list_timestamp).total_seconds()
if cache_delta < FIFTY_MINUTES:
return False
cache_delta = (datetime.now() - self.album_list_timestamp).total_seconds()
if cache_delta < FIFTY_MINUTES:
return False
await self._get_album_list()
return True

async def _async_update_data(self):
"""Fetch album data"""

try:
async with async_timeout.timeout(30):
if self.album is None:
service = await self._auth.get_resource(self.hass)

def _get_album(album_id: str) -> Album:
return service.albums().get(albumId=album_id).execute()

self.album = await self.hass.async_add_executor_job(
_get_album, self.album_id
)
await self.update_data()
except Exception as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
Loading

0 comments on commit 4b2d368

Please sign in to comment.