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 6 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
1 change: 1 addition & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -1408,6 +1408,7 @@ build.json @home-assistant/supervisor
/tests/components/yolink/ @matrixd2
/homeassistant/components/youless/ @gjong
/tests/components/youless/ @gjong
/homeassistant/components/youtube/ @joostlek
/homeassistant/components/zamg/ @killer0071234
/tests/components/zamg/ @killer0071234
/homeassistant/components/zengge/ @emontnemery
Expand Down
56 changes: 56 additions & 0 deletions homeassistant/components/youtube/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Support for Google Mail."""
joostlek marked this conversation as resolved.
Show resolved Hide resolved
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 await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return True
return False
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",
}
138 changes: 138 additions & 0 deletions homeassistant/components/youtube/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""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
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
)

from .const import CONF_CHANNELS, CONF_UPLOAD_PLAYLIST, DEFAULT_ACCESS, DOMAIN


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

_data: dict[str, Any] | None = None

DOMAIN = DOMAIN

reauth_entry: ConfigEntry | None = None

@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."""
if self.reauth_entry:
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")

if self._async_current_entries():
# Config entry already exists, only one allowed.
return self.async_abort(reason="single_instance_allowed")
self._data = data

return await self.async_step_channels()

async def async_step_channels(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Select which channels to track."""
if not self._data:
return self.async_abort(reason="no_data")
service = build(
"youtube",
"v3",
credentials=Credentials(self._data[CONF_TOKEN][CONF_ACCESS_TOKEN]),
)
if user_input:
# pylint: disable=no-member
channel_request: HttpRequest = service.channels().list(
part="contentDetails",
id=",".join(user_input[CONF_CHANNELS]),
maxResults=50,
)
response: dict = await self.hass.async_add_executor_job(
channel_request.execute
)
channels = {
channel["id"]: {
CONF_UPLOAD_PLAYLIST: channel["contentDetails"]["relatedPlaylists"][
"uploads"
]
}
for channel in response["items"]
}
return self.async_create_entry(
title="YouTube",
data=self._data,
options={
CONF_CHANNELS: channels,
},
)
# 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)
),
}
),
)
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__)
73 changes: 73 additions & 0 deletions homeassistant/components/youtube/coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""DataUpdateCoordinator for the YouTube integration."""
from __future__ import annotations

from datetime import timedelta
from typing import Any

from googleapiclient.http import HttpRequest

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator

from . import AsyncConfigEntryAuth
from .const import CONF_CHANNELS, CONF_UPLOAD_PLAYLIST, DOMAIN, LOGGER


class YouTubeDataUpdateCoordinator(DataUpdateCoordinator):
"""A YouTube Data Update Coordinator."""

def __init__(
self, hass: HomeAssistant, entry: ConfigEntry, auth: AsyncConfigEntryAuth
) -> None:
"""Initialize the Yale hub."""
self.entry = entry
joostlek marked this conversation as resolved.
Show resolved Hide resolved
self._auth = auth
super().__init__(
hass,
LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=30),
)

async def _async_update_data(self) -> dict[str, Any]:
data = {}
service = await self._auth.get_resource()
channels = self.entry.options[CONF_CHANNELS].keys()
channel_request: HttpRequest = service.channels().list(
part="snippet,statistics", id=",".join(channels), maxResults=50
)
response: dict = await self.hass.async_add_executor_job(channel_request.execute)
for channel in response["items"]:
data[channel["id"]] = {
"id": channel["id"],
"title": channel["snippet"]["title"],
"icon": channel["snippet"]["thumbnails"]["high"]["url"],
"upload_playlist_id": self.entry.options[CONF_CHANNELS][channel["id"]][
CONF_UPLOAD_PLAYLIST
],
"latest_video": await self._get_latest_video(
self.entry.options[CONF_CHANNELS][channel["id"]][
CONF_UPLOAD_PLAYLIST
]
),
"subscriber_count": channel["statistics"]["subscriberCount"],
}
return data

async def _get_latest_video(self, playlist_id: str) -> dict[str, Any]:
service = await self._auth.get_resource()
playlist_request: HttpRequest = service.playlistItems().list(
part="snippet,contentDetails", playlistId=playlist_id
joostlek marked this conversation as resolved.
Show resolved Hide resolved
)
response: dict = await self.hass.async_add_executor_job(
playlist_request.execute
)
video = response["items"][0]
return {
"published_at": video["snippet"]["publishedAt"],
"title": video["snippet"]["title"],
"description": video["snippet"]["description"],
"thumbnail": video["snippet"]["thumbnails"]["standard"]["url"],
"video_id": video["contentDetails"]["videoId"],
joostlek marked this conversation as resolved.
Show resolved Hide resolved
}
29 changes: 29 additions & 0 deletions homeassistant/components/youtube/entity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Entity representing a YouTube account."""
from __future__ import annotations

from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription

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


class YouTubeChannelEntity(Entity):
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
"""An HA implementation for YouTube entity."""

def __init__(
self,
auth: AsyncConfigEntryAuth,
description: EntityDescription,
channel_name: str,
) -> None:
"""Initialize a Google Mail entity."""
self.auth = auth
joostlek marked this conversation as resolved.
Show resolved Hide resolved
self.entity_description = description
self._attr_unique_id = f"{auth.oauth_session.config_entry.entry_id}_{channel_name}_{description.key}"
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, auth.oauth_session.config_entry.entry_id)},
manufacturer=MANUFACTURER,
name=channel_name,
)
11 changes: 11 additions & 0 deletions homeassistant/components/youtube/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"domain": "youtube",
"name": "YouTube",
"codeowners": ["@joostlek"],
"config_flow": true,
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/youtube",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["google-api-python-client==2.71.0"]
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
}