Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add YouTube integration #92988

Merged
merged 37 commits into from
May 27, 2023
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
c32b8cb
Add YouTube stub
joostlek May 10, 2023
7c42a9f
Add YouTube stub
joostlek May 10, 2023
f2459ae
Add YouTube stub
joostlek May 12, 2023
da14632
Add YouTube stub
joostlek May 12, 2023
58ff17a
Add Youtube stub
joostlek May 12, 2023
1e1330c
Add Youtube stub
joostlek May 12, 2023
d5071cd
Merge branch 'dev' into youtube
joostlek May 12, 2023
91e5816
Add tests
joostlek May 12, 2023
324dc1c
Merge branch 'dev' into youtube
joostlek May 12, 2023
6634d9b
Add tests
joostlek May 12, 2023
40c62e7
Add tests
joostlek May 12, 2023
c04e580
Clean up
joostlek May 12, 2023
6036302
Add test for options flow
joostlek May 12, 2023
f1f80cb
Merge branch 'dev' into youtube
joostlek May 12, 2023
9a4b433
Fix feedback
joostlek May 13, 2023
9075af9
Fix feedback
joostlek May 13, 2023
7b0306c
Merge remote-tracking branch 'origin/youtube' into youtube
joostlek May 13, 2023
318050c
Remove obsolete request
joostlek May 14, 2023
f295e89
Catch exceptions
joostlek May 14, 2023
e1af227
Merge branch 'dev' into youtube
joostlek May 14, 2023
f593514
Parallelize latest video calls
joostlek May 14, 2023
29b7946
Apply suggestions from code review
joostlek May 15, 2023
3779dc8
Add youtube to google brands
joostlek May 15, 2023
55b4bff
Fix feedback
joostlek May 16, 2023
a7d60b1
Fix feedback
joostlek May 16, 2023
08918fd
Merge branch 'dev' into youtube
joostlek May 16, 2023
b34f9a9
Fix test
joostlek May 16, 2023
e410f5a
Fix test
joostlek May 16, 2023
5d5417e
Add unit test for http error
joostlek May 16, 2023
f9538db
Merge branch 'dev' into youtube
joostlek May 16, 2023
01278aa
Update homeassistant/components/youtube/coordinator.py
joostlek May 16, 2023
83bea6f
Fix black
joostlek May 16, 2023
b3e8112
Merge branch 'dev' into youtube
joostlek May 20, 2023
e3bacdc
Fix feedback
joostlek May 22, 2023
3683313
Fix feedback
joostlek May 22, 2023
a9ca45e
Fix tests
joostlek May 22, 2023
1544e25
Merge branch 'dev' into youtube
joostlek May 22, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 2 additions & 1 deletion homeassistant/brands/google.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"google",
"nest",
"cast",
"dialogflow"
"dialogflow",
"youtube"
]
}
8 changes: 4 additions & 4 deletions homeassistant/components/youtube/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
OAuth2Session,
Expand All @@ -21,21 +21,21 @@


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Google Mail from a config entry."""
"""Set up YouTube from a config entry."""
implementation = await async_get_config_entry_implementation(hass, entry)
session = OAuth2Session(hass, entry, implementation)
auth = AsyncConfigEntryAuth(async_get_clientsession(hass), session)
try:
await auth.check_and_refresh_token()
except ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed(
raise ConfigEntryNotReady(
"OAuth session is not valid, reauth required"
) from err
raise ConfigEntryNotReady from err
except ClientError as err:
raise ConfigEntryNotReady from err
coordinator = YouTubeDataUpdateCoordinator(hass, entry, auth)
coordinator = YouTubeDataUpdateCoordinator(hass, auth)

await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
Expand Down
12 changes: 5 additions & 7 deletions homeassistant/components/youtube/api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""API for YouTube bound to Home Assistant OAuth."""
from aiohttp import ClientSession
from google.auth.exceptions import RefreshError
from google.oauth2.credentials import Credentials
from google.oauth2.utils import OAuthClientAuthHandler
from googleapiclient.discovery import Resource, build
Expand Down Expand Up @@ -33,9 +32,8 @@ async def check_and_refresh_token(self) -> str:

async def get_resource(self) -> Resource:
"""Get current resource."""
try:
credentials = Credentials(await self.check_and_refresh_token())
except RefreshError as ex:
self.oauth_session.config_entry.async_start_reauth(self.oauth_session.hass)
raise ex
return build("youtube", "v3", credentials=credentials)
return build(
"youtube",
"v3",
credentials=Credentials(await self.check_and_refresh_token()),
)
9 changes: 0 additions & 9 deletions homeassistant/components/youtube/application_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,3 @@ async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationSe
"https://accounts.google.com/o/oauth2/v2/auth",
"https://oauth2.googleapis.com/token",
)


async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]:
"""Return description placeholders for the credentials dialog."""
return {
"oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent",
"more_info_url": "https://www.home-assistant.io/integrations/youtube/",
"oauth_creds_url": "https://console.cloud.google.com/apis/credentials",
}
142 changes: 31 additions & 111 deletions homeassistant/components/youtube/config_flow.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
"""Config flow for YouTube integration."""
from __future__ import annotations

from collections.abc import Mapping
import logging
from typing import Any

from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
joostlek marked this conversation as resolved.
Show resolved Hide resolved
from googleapiclient.errors import HttpError
from googleapiclient.http import HttpRequest
import voluptuous as vol
joostlek marked this conversation as resolved.
Show resolved Hide resolved

from homeassistant.config_entries import ConfigEntry, OptionsFlowWithConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers.selector import (
Expand All @@ -21,8 +19,7 @@
SelectSelectorConfig,
)

from . import AsyncConfigEntryAuth
from .const import AUTH, CONF_CHANNELS, DEFAULT_ACCESS, DOMAIN
from .const import CONF_CHANNELS, DEFAULT_ACCESS, DOMAIN, LOGGER


class OAuth2FlowHandler(
Expand All @@ -31,19 +28,10 @@ class OAuth2FlowHandler(
"""Config flow to handle Google OAuth2 authentication."""

_data: dict[str, Any] = {}
_own_channel: dict[str, Any] = {}

DOMAIN = DOMAIN

reauth_entry: ConfigEntry | None = None

@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
) -> YouTubeOptionsFlowHandler:
"""Get the options flow for this handler."""
return YouTubeOptionsFlowHandler(config_entry)

@property
def logger(self) -> logging.Logger:
"""Return logger."""
Expand All @@ -59,54 +47,38 @@ def extra_authorize_data(self) -> dict[str, Any]:
"prompt": "consent",
}

async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
"""Perform reauth upon an API authentication error."""
self.reauth_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
return await self.async_step_reauth_confirm()

async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Confirm reauth dialog."""
if user_input is None:
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user()

async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult:
"""Create an entry for the flow, or update existing entry."""

service = build(
"youtube",
"v3",
credentials=Credentials(data[CONF_TOKEN][CONF_ACCESS_TOKEN]),
)
# pylint: disable=no-member
own_channel_request: HttpRequest = service.channels().list(
part="snippet", mine=True
)
response = await self.hass.async_add_executor_job(own_channel_request.execute)
user_id = response["items"][0]["id"]
try:
service = build(
joostlek marked this conversation as resolved.
Show resolved Hide resolved
"youtube",
"v3",
credentials=Credentials(data[CONF_TOKEN][CONF_ACCESS_TOKEN]),
)
# pylint: disable=no-member
own_channel_request: HttpRequest = service.channels().list(
part="snippet", mine=True
)
response = await self.hass.async_add_executor_job(
own_channel_request.execute
)
own_channel = response["items"][0]
except HttpError as ex:
error = ex.reason
return self.async_abort(
reason="access_not_configured",
description_placeholders={"message": error},
)
except Exception as ex: # pylint: disable=broad-except
LOGGER.error("Unknown error occurred: %s", ex.args)
return self.async_abort(reason="unknown")
self._own_channel = own_channel
joostlek marked this conversation as resolved.
Show resolved Hide resolved
self._data = data

if not self.reauth_entry:
await self.async_set_unique_id(user_id)
self._abort_if_unique_id_configured()
await self.async_set_unique_id(own_channel["id"])
self._abort_if_unique_id_configured()

return await self.async_step_channels()

if self.reauth_entry.unique_id == user_id:
self.hass.config_entries.async_update_entry(self.reauth_entry, data=data)
await self.hass.config_entries.async_reload(self.reauth_entry.entry_id)
return self.async_abort(reason="reauth_successful")

return self.async_abort(
reason="wrong_account",
description_placeholders={
"title": response["items"][0]["snippet"]["title"]
},
)
return await self.async_step_channels()

async def async_step_channels(
self, user_input: dict[str, Any] | None = None
Expand All @@ -118,15 +90,8 @@ async def async_step_channels(
credentials=Credentials(self._data[CONF_TOKEN][CONF_ACCESS_TOKEN]),
)
if user_input:
# pylint: disable=no-member
own_channel_request: HttpRequest = service.channels().list(
part="snippet", mine=True
)
response = await self.hass.async_add_executor_job(
own_channel_request.execute
)
return self.async_create_entry(
title=response["items"][0]["snippet"]["title"],
title=self._own_channel["snippet"]["title"],
data=self._data,
options=user_input,
)
Expand All @@ -152,48 +117,3 @@ async def async_step_channels(
}
),
)


class YouTubeOptionsFlowHandler(OptionsFlowWithConfigEntry):
"""YouTube Options flow handler."""

async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Initialize form."""
if user_input is not None:
return self.async_create_entry(
title=self.config_entry.title,
data=user_input,
)
auth: AsyncConfigEntryAuth = self.hass.data[DOMAIN][self.config_entry.entry_id][
AUTH
]
service = await auth.get_resource()
# pylint: disable=no-member
subscription_request: HttpRequest = service.subscriptions().list(
part="snippet", mine=True, maxResults=50
)
response = await self.hass.async_add_executor_job(subscription_request.execute)
selectable_channels = [
SelectOptionDict(
value=subscription["snippet"]["resourceId"]["channelId"],
label=subscription["snippet"]["title"],
)
for subscription in response["items"]
]
return self.async_show_form(
step_id="init",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(
{
vol.Required(CONF_CHANNELS): SelectSelector(
SelectSelectorConfig(
options=selectable_channels, multiple=True
)
),
}
),
self.options,
),
)
9 changes: 8 additions & 1 deletion homeassistant/components/youtube/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,15 @@
CONF_CHANNELS = "channels"
CONF_ID = "id"
CONF_UPLOAD_PLAYLIST = "upload_playlist_id"
DATA_AUTH = "auth"
COORDINATOR = "coordinator"
AUTH = "auth"

LOGGER = logging.getLogger(__package__)

ATTR_TITLE = "title"
ATTR_LATEST_VIDEO = "latest_video"
ATTR_SUBSCRIBER_COUNT = "subscriber_count"
ATTR_DESCRIPTION = "description"
ATTR_THUMBNAIL = "thumbnail"
ATTR_VIDEO_ID = "video_id"
ATTR_PUBLISHED_AT = "published_at"