Skip to content

Commit

Permalink
Add Google Photos upload support
Browse files Browse the repository at this point in the history
  • Loading branch information
allenporter committed Aug 31, 2024
1 parent 2628166 commit beaf916
Show file tree
Hide file tree
Showing 12 changed files with 504 additions and 9 deletions.
22 changes: 20 additions & 2 deletions homeassistant/components/google_photos/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,34 @@
from __future__ import annotations

from aiohttp import ClientError, ClientResponseError
import voluptuous as vol

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_FILENAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv

from . import api
from .const import DOMAIN
from .const import DOMAIN, UPLOAD_SCOPE
from .services import async_register_services

type GooglePhotosConfigEntry = ConfigEntry[api.AsyncConfigEntryAuth]

__all__ = [
"DOMAIN",
]

CONF_CONFIG_ENTRY_ID = "config_entry_id"

UPLOAD_SERVICE = "upload"
UPLOAD_SERVICE_SCHEMA = vol.Schema(
{
vol.Required(CONF_CONFIG_ENTRY_ID): cv.string,
vol.Required(CONF_FILENAME): vol.All(cv.ensure_list, [cv.string]),
}
)


async def async_setup_entry(
hass: HomeAssistant, entry: GooglePhotosConfigEntry
Expand All @@ -41,6 +54,11 @@ async def async_setup_entry(
except ClientError as err:
raise ConfigEntryNotReady from err
entry.runtime_data = auth

scopes = entry.data["token"]["scope"].split(" ")
if any(scope == UPLOAD_SCOPE for scope in scopes):
async_register_services(hass)

return True


Expand Down
49 changes: 48 additions & 1 deletion homeassistant/components/google_photos/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@
import logging
from typing import Any, cast

from aiohttp.client_exceptions import ClientError
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import Resource, build
from googleapiclient.errors import HttpError
from googleapiclient.http import BatchHttpRequest, HttpRequest

from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow

from .exceptions import GooglePhotosApiError

Expand All @@ -25,6 +26,7 @@
"id,baseUrl,mimeType,filename,mediaMetadata(width,height,photo,video)"
)
LIST_MEDIA_ITEM_FIELDS = f"nextPageToken,mediaItems({GET_MEDIA_ITEM_FIELDS})"
UPLOAD_API = "https://photoslibrary.googleapis.com/v1/uploads"


class AuthBase(ABC):
Expand Down Expand Up @@ -70,6 +72,41 @@ async def list_media_items(
)
return await self._execute(cmd)

async def upload_content(self, content: bytes, mime_type: str) -> str:
"""Upload media content to the API and return an upload token."""
token = await self.async_get_access_token()
session = aiohttp_client.async_get_clientsession(self._hass)
try:
result = await session.post(
UPLOAD_API, headers=_upload_headers(token, mime_type), data=content
)
result.raise_for_status()
return await result.text()
except ClientError as err:
raise GooglePhotosApiError(f"Failed to upload content: {err}") from err

async def create_media_items(self, upload_tokens: list[str]) -> list[str]:
"""Create a batch of media items and return the ids."""
service = await self._get_photos_service()
_LOGGER.debug("create_media_items=%s", upload_tokens)
cmd: HttpRequest = service.mediaItems().batchCreate(
body={
"newMediaItems": [
{
"simpleMediaItem": {
"uploadToken": upload_token,
}
for upload_token in upload_tokens
}
]
}
)
result = await self._execute(cmd)
return [
media_item["mediaItem"]["id"]
for media_item in result["newMediaItemResults"]
]

async def _get_photos_service(self) -> Resource:
"""Get current photos library API resource."""
token = await self.async_get_access_token()
Expand Down Expand Up @@ -141,3 +178,13 @@ def __init__(
async def async_get_access_token(self) -> str:
"""Return a valid access token."""
return self._token


def _upload_headers(token: str, mime_type: str) -> dict[str, Any]:
"""Create the upload headers."""
return {
"Authorization": f"Bearer {token}",
"Content-Type": "application/octet-stream",
"X-Goog-Upload-Content-Type": mime_type,
"X-Goog-Upload-Protocol": "raw",
}
9 changes: 8 additions & 1 deletion homeassistant/components/google_photos/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@

OAUTH2_AUTHORIZE = "https://accounts.google.com/o/oauth2/v2/auth"
OAUTH2_TOKEN = "https://oauth2.googleapis.com/token"
OAUTH2_SCOPES = [

UPLOAD_SCOPE = "https://www.googleapis.com/auth/photoslibrary.appendonly"
READ_SCOPES = [
"https://www.googleapis.com/auth/photoslibrary.readonly",
"https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata",
]
OAUTH2_SCOPES = [
*READ_SCOPES,
UPLOAD_SCOPE,
"https://www.googleapis.com/auth/userinfo.profile",
]
7 changes: 7 additions & 0 deletions homeassistant/components/google_photos/icons.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"services": {
"upload": {
"service": "mdi:cloud-upload"
}
}
}
13 changes: 11 additions & 2 deletions homeassistant/components/google_photos/media_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from homeassistant.core import HomeAssistant

from . import GooglePhotosConfigEntry
from .const import DOMAIN
from .const import DOMAIN, READ_SCOPES
from .exceptions import GooglePhotosApiError

_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -153,7 +153,7 @@ async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource:
children_media_class=MediaClass.DIRECTORY,
children=[
_build_account(entry, PhotosIdentifier(cast(str, entry.unique_id)))
for entry in self.hass.config_entries.async_loaded_entries(DOMAIN)
for entry in self._async_config_entries()
],
)

Expand Down Expand Up @@ -206,6 +206,15 @@ async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource:
]
return source

def _async_config_entries(self) -> list[GooglePhotosConfigEntry]:
"""Return all config entries that support photo library reads."""
entries = []
for entry in self.hass.config_entries.async_loaded_entries(DOMAIN):
scopes = entry.data["token"]["scope"].split(" ")
if any(scope in scopes for scope in READ_SCOPES):
entries.append(entry)
return entries

def _async_config_entry(self, config_entry_id: str) -> GooglePhotosConfigEntry:
"""Return a config entry with the specified id."""
entry = self.hass.config_entries.async_entry_for_domain_unique_id(
Expand Down
96 changes: 96 additions & 0 deletions homeassistant/components/google_photos/services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""Google Photos services."""

from __future__ import annotations

import asyncio
import mimetypes
from pathlib import Path

import voluptuous as vol

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_FILENAME
from homeassistant.core import (
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
)
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv

from . import api
from .const import DOMAIN

type GooglePhotosConfigEntry = ConfigEntry[api.AsyncConfigEntryAuth]

__all__ = [
"DOMAIN",
]

CONF_CONFIG_ENTRY_ID = "config_entry_id"

UPLOAD_SERVICE = "upload"
UPLOAD_SERVICE_SCHEMA = vol.Schema(
{
vol.Required(CONF_CONFIG_ENTRY_ID): cv.string,
vol.Required(CONF_FILENAME): vol.All(cv.ensure_list, [cv.string]),
}
)


def async_register_services(hass: HomeAssistant) -> None:
"""Register Google Photos services."""

async def async_handle_upload(call: ServiceCall) -> ServiceResponse:
"""Generate content from text and optionally images."""
config_entry: GooglePhotosConfigEntry | None = (
hass.config_entries.async_get_entry(call.data[CONF_CONFIG_ENTRY_ID])
)
if not config_entry:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="integration_not_found",
translation_placeholders={"target": DOMAIN},
)
client_api = config_entry.runtime_data
upload_tasks = []
for filename in call.data[CONF_FILENAME]:
if not hass.config.is_allowed_path(filename):
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="no_access_to_path",
translation_placeholders={"filename": filename},
)
if not Path(filename).exists():
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="filename_does_not_exist",
translation_placeholders={"filename": filename},
)
mime_type, _ = mimetypes.guess_type(filename)
if mime_type is None or not (mime_type.startswith(("image", "video"))):
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="filename_is_not_image",
translation_placeholders={"filename": filename},
)
filename_path = Path(filename)
content = await hass.async_add_executor_job(filename_path.read_bytes)
upload_tasks.append(client_api.upload_content(content, mime_type))
upload_tokens = await asyncio.gather(*upload_tasks)
media_ids = await client_api.create_media_items(upload_tokens)
if call.return_response:
return {
"media_items": [{"media_item_id": media_id for media_id in media_ids}]
}
return None

if not hass.services.has_service(DOMAIN, UPLOAD_SERVICE):
hass.services.async_register(
DOMAIN,
UPLOAD_SERVICE,
async_handle_upload,
schema=UPLOAD_SERVICE_SCHEMA,
supports_response=SupportsResponse.OPTIONAL,
)
11 changes: 11 additions & 0 deletions homeassistant/components/google_photos/services.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
upload:
fields:
config_entry_id:
required: true
selector:
config_entry:
integration: google_photos
filename:
required: false
selector:
text:
34 changes: 34 additions & 0 deletions homeassistant/components/google_photos/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,39 @@
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
}
},
"exceptions": {
"integration_not_found": {
"message": "Integration \"{target}\" not found in registry."
},
"not_loaded": {
"message": "{target} is not loaded."
},
"no_access_to_path": {
"message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
},
"filename_does_not_exist": {
"message": "`{filename}` does not exist"
},
"filename_is_not_image": {
"message": "`{filename}` is not an image"
}
},
"services": {
"upload": {
"name": "Upload media",
"description": "Upload images or videos to Google Photos.",
"fields": {
"config_entry_id": {
"name": "Integration Id",
"description": "The Google Photos integration id."
},
"filename": {
"name": "Filename",
"description": "Path to the image or video to upload.",
"example": "/config/www/image.jpg"
}
}
}
}
}
10 changes: 8 additions & 2 deletions tests/components/google_photos/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,19 @@ def mock_expires_at() -> int:
return time.time() + EXPIRES_IN


@pytest.fixture(name="scopes")
def mock_scopes() -> list[str]:
"""Fixture to set scopes used during the config entry."""
return OAUTH2_SCOPES


@pytest.fixture(name="token_entry")
def mock_token_entry(expires_at: int) -> dict[str, Any]:
def mock_token_entry(expires_at: int, scopes: list[str]) -> dict[str, Any]:
"""Fixture for OAuth 'token' data for a ConfigEntry."""
return {
"access_token": FAKE_ACCESS_TOKEN,
"refresh_token": FAKE_REFRESH_TOKEN,
"scope": " ".join(OAUTH2_SCOPES),
"scope": " ".join(scopes),
"type": "Bearer",
"expires_at": expires_at,
"expires_in": EXPIRES_IN,
Expand Down
6 changes: 6 additions & 0 deletions tests/components/google_photos/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ async def test_full_flow(
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}"
"&scope=https://www.googleapis.com/auth/photoslibrary.readonly"
"+https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata"
"+https://www.googleapis.com/auth/photoslibrary.appendonly"
"+https://www.googleapis.com/auth/userinfo.profile"
"&access_type=offline&prompt=consent"
)
Expand Down Expand Up @@ -145,6 +147,8 @@ async def test_api_not_enabled(
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}"
"&scope=https://www.googleapis.com/auth/photoslibrary.readonly"
"+https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata"
"+https://www.googleapis.com/auth/photoslibrary.appendonly"
"+https://www.googleapis.com/auth/userinfo.profile"
"&access_type=offline&prompt=consent"
)
Expand Down Expand Up @@ -189,6 +193,8 @@ async def test_general_exception(
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}"
"&scope=https://www.googleapis.com/auth/photoslibrary.readonly"
"+https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata"
"+https://www.googleapis.com/auth/photoslibrary.appendonly"
"+https://www.googleapis.com/auth/userinfo.profile"
"&access_type=offline&prompt=consent"
)
Expand Down
Loading

0 comments on commit beaf916

Please sign in to comment.