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 14 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
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -1409,6 +1409,8 @@ build.json @home-assistant/supervisor
/tests/components/yolink/ @matrixd2
/homeassistant/components/youless/ @gjong
/tests/components/youless/ @gjong
/homeassistant/components/youtube/ @joostlek
/tests/components/youtube/ @joostlek
/homeassistant/components/zamg/ @killer0071234
/tests/components/zamg/ @killer0071234
/homeassistant/components/zengge/ @emontnemery
Expand Down
55 changes: 55 additions & 0 deletions homeassistant/components/youtube/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Support for YouTube."""
from __future__ import annotations

from aiohttp.client_exceptions import ClientError, ClientResponseError

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
OAuth2Session,
async_get_config_entry_implementation,
)

from .api import AsyncConfigEntryAuth
from .const import AUTH, COORDINATOR, DOMAIN
from .coordinator import YouTubeDataUpdateCoordinator

PLATFORMS = [Platform.SENSOR]


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Google Mail 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(
"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)

await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
COORDINATOR: coordinator,
AUTH: auth,
}
await hass.config_entries.async_forward_entry_setups(entry, list(PLATFORMS))

return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""

if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
41 changes: 41 additions & 0 deletions homeassistant/components/youtube/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""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

from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.helpers import config_entry_oauth2_flow


class AsyncConfigEntryAuth(OAuthClientAuthHandler):
"""Provide Google authentication tied to an OAuth2 based config entry."""

def __init__(
self,
websession: ClientSession,
oauth2_session: config_entry_oauth2_flow.OAuth2Session,
) -> None:
"""Initialize YouTube Auth."""
self.oauth_session = oauth2_session
super().__init__(websession)

@property
def access_token(self) -> str:
"""Return the access token."""
return self.oauth_session.token[CONF_ACCESS_TOKEN]

async def check_and_refresh_token(self) -> str:
"""Check the token."""
await self.oauth_session.async_ensure_token_valid()
return self.access_token

async def get_resource(self) -> Resource:
"""Get current resource."""
try:
credentials = Credentials(await self.check_and_refresh_token())
except RefreshError as ex:
joostlek marked this conversation as resolved.
Show resolved Hide resolved
self.oauth_session.config_entry.async_start_reauth(self.oauth_session.hass)
raise ex
return build("youtube", "v3", credentials=credentials)
20 changes: 20 additions & 0 deletions homeassistant/components/youtube/application_credentials.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""application_credentials platform for YouTube."""
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant


async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
"""Return authorization server."""
return AuthorizationServer(
"https://accounts.google.com/o/oauth2/v2/auth",
"https://oauth2.googleapis.com/token",
)


async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]:
joostlek marked this conversation as resolved.
Show resolved Hide resolved
"""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",
}
199 changes: 199 additions & 0 deletions homeassistant/components/youtube/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
"""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.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 (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
)

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


class OAuth2FlowHandler(
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
):
"""Config flow to handle Google OAuth2 authentication."""

_data: 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."""
return logging.getLogger(__name__)

@property
def extra_authorize_data(self) -> dict[str, Any]:
"""Extra data that needs to be appended to the authorize url."""
return {
"scope": " ".join(DEFAULT_ACCESS),
# Add params to ensure we get back a refresh token
"access_type": "offline",
"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()
joostlek marked this conversation as resolved.
Show resolved Hide resolved

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)
joostlek marked this conversation as resolved.
Show resolved Hide resolved
user_id = response["items"][0]["id"]
self._data = data
joostlek marked this conversation as resolved.
Show resolved Hide resolved

if not self.reauth_entry:
await self.async_set_unique_id(user_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"]
},
)

async def async_step_channels(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Select which channels to track."""
service = build(
"youtube",
"v3",
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"],
data=self._data,
options=user_input,
)
# 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="channels",
data_schema=vol.Schema(
{
vol.Required(CONF_CHANNELS): SelectSelector(
SelectSelectorConfig(options=selectable_channels, multiple=True)
),
}
),
)


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,
),
)
15 changes: 15 additions & 0 deletions homeassistant/components/youtube/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""Constants for YouTube integration."""
import logging

DEFAULT_ACCESS = ["https://www.googleapis.com/auth/youtube.readonly"]
DOMAIN = "youtube"
MANUFACTURER = "Google, Inc."

CONF_CHANNELS = "channels"
CONF_ID = "id"
CONF_UPLOAD_PLAYLIST = "upload_playlist_id"
DATA_AUTH = "auth"
COORDINATOR = "coordinator"
AUTH = "auth"

LOGGER = logging.getLogger(__package__)