Add YouTube integration (#92988)

* Add YouTube stub

* Add YouTube stub

* Add YouTube stub

* Add YouTube stub

* Add Youtube stub

* Add Youtube stub

* Add tests

* Add tests

* Add tests

* Clean up

* Add test for options flow

* Fix feedback

* Fix feedback

* Remove obsolete request

* Catch exceptions

* Parallelize latest video calls

* Apply suggestions from code review

Co-authored-by: Robert Hillis <tkdrob4390@yahoo.com>

* Add youtube to google brands

* Fix feedback

* Fix feedback

* Fix test

* Fix test

* Add unit test for http error

* Update homeassistant/components/youtube/coordinator.py

Co-authored-by: Robert Hillis <tkdrob4390@yahoo.com>

* Fix black

* Fix feedback

* Fix feedback

* Fix tests

---------

Co-authored-by: Robert Hillis <tkdrob4390@yahoo.com>
This commit is contained in:
Joost Lekkerkerker 2023-05-27 20:21:12 +02:00 committed by GitHub
parent bb170a2bbf
commit e4c51d43f0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1456 additions and 1 deletions

View File

@ -1418,6 +1418,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

View File

@ -17,6 +17,7 @@
"google",
"nest",
"cast",
"dialogflow"
"dialogflow",
"youtube"
]
}

View File

@ -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 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 YouTube from a config entry."""
implementation = await async_get_config_entry_implementation(hass, entry)
session = OAuth2Session(hass, entry, implementation)
auth = AsyncConfigEntryAuth(hass, async_get_clientsession(hass), session)
try:
await auth.check_and_refresh_token()
except ClientResponseError as err:
if 400 <= err.status < 500:
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, 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

View File

@ -0,0 +1,47 @@
"""API for YouTube bound to Home Assistant OAuth."""
from aiohttp import ClientSession
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.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
class AsyncConfigEntryAuth(OAuthClientAuthHandler):
"""Provide Google authentication tied to an OAuth2 based config entry."""
def __init__(
self,
hass: HomeAssistant,
websession: ClientSession,
oauth2_session: config_entry_oauth2_flow.OAuth2Session,
) -> None:
"""Initialize YouTube Auth."""
self.oauth_session = oauth2_session
self.hass = hass
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:
"""Create executor job to get current resource."""
credentials = Credentials(await self.check_and_refresh_token())
return await self.hass.async_add_executor_job(self._get_resource, credentials)
def _get_resource(self, credentials: Credentials) -> Resource:
"""Get current resource."""
return build(
"youtube",
"v3",
credentials=credentials,
)

View File

@ -0,0 +1,11 @@
"""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",
)

View File

@ -0,0 +1,121 @@
"""Config flow for YouTube integration."""
from __future__ import annotations
import logging
from typing import Any
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import Resource, build
from googleapiclient.errors import HttpError
from googleapiclient.http import HttpRequest
import voluptuous as vol
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, DEFAULT_ACCESS, DOMAIN, LOGGER
class OAuth2FlowHandler(
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
):
"""Config flow to handle Google OAuth2 authentication."""
_data: dict[str, Any] = {}
_title: str = ""
DOMAIN = DOMAIN
@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_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult:
"""Create an entry for the flow, or update existing entry."""
try:
service = await self._get_resource(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._title = own_channel["snippet"]["title"]
self._data = data
await self.async_set_unique_id(own_channel["id"])
self._abort_if_unique_id_configured()
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 user_input:
return self.async_create_entry(
title=self._title,
data=self._data,
options=user_input,
)
service = await self._get_resource(self._data[CONF_TOKEN][CONF_ACCESS_TOKEN])
# 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)
),
}
),
)
async def _get_resource(self, token: str) -> Resource:
def _build_resource() -> Resource:
return build(
"youtube",
"v3",
credentials=Credentials(token),
)
return await self.hass.async_add_executor_job(_build_resource)

View File

@ -0,0 +1,22 @@
"""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"
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"

View File

@ -0,0 +1,90 @@
"""DataUpdateCoordinator for the YouTube integration."""
from __future__ import annotations
import asyncio
from datetime import timedelta
from typing import Any
from googleapiclient.discovery import Resource
from googleapiclient.http import HttpRequest
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ICON, ATTR_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from . import AsyncConfigEntryAuth
from .const import (
ATTR_DESCRIPTION,
ATTR_LATEST_VIDEO,
ATTR_PUBLISHED_AT,
ATTR_SUBSCRIBER_COUNT,
ATTR_THUMBNAIL,
ATTR_TITLE,
ATTR_VIDEO_ID,
CONF_CHANNELS,
DOMAIN,
LOGGER,
)
def get_upload_playlist_id(channel_id: str) -> str:
"""Return the playlist id with the uploads of the channel.
Replacing the UC in the channel id (UCxxxxxxxxxxxx) with UU is the way to do it without extra request (UUxxxxxxxxxxxx).
"""
return channel_id.replace("UC", "UU", 1)
class YouTubeDataUpdateCoordinator(DataUpdateCoordinator):
"""A YouTube Data Update Coordinator."""
config_entry: ConfigEntry
def __init__(self, hass: HomeAssistant, auth: AsyncConfigEntryAuth) -> None:
"""Initialize the YouTube data coordinator."""
self._auth = auth
super().__init__(
hass,
LOGGER,
name=DOMAIN,
update_interval=timedelta(minutes=15),
)
async def _async_update_data(self) -> dict[str, Any]:
data = {}
service = await self._auth.get_resource()
channels = self.config_entry.options[CONF_CHANNELS]
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)
async def _compile_data(channel: dict[str, Any]) -> None:
data[channel["id"]] = {
ATTR_ID: channel["id"],
ATTR_TITLE: channel["snippet"]["title"],
ATTR_ICON: channel["snippet"]["thumbnails"]["high"]["url"],
ATTR_LATEST_VIDEO: await self._get_latest_video(service, channel["id"]),
ATTR_SUBSCRIBER_COUNT: int(channel["statistics"]["subscriberCount"]),
}
await asyncio.gather(*[_compile_data(channel) for channel in response["items"]])
return data
async def _get_latest_video(
self, service: Resource, channel_id: str
) -> dict[str, Any]:
playlist_id = get_upload_playlist_id(channel_id)
job: HttpRequest = service.playlistItems().list(
part="snippet,contentDetails", playlistId=playlist_id, maxResults=1
)
response: dict = await self.hass.async_add_executor_job(job.execute)
video = response["items"][0]
return {
ATTR_PUBLISHED_AT: video["snippet"]["publishedAt"],
ATTR_TITLE: video["snippet"]["title"],
ATTR_DESCRIPTION: video["snippet"]["description"],
ATTR_THUMBNAIL: video["snippet"]["thumbnails"]["standard"]["url"],
ATTR_VIDEO_ID: video["contentDetails"]["videoId"],
}

View File

@ -0,0 +1,38 @@
"""Entity representing a YouTube account."""
from __future__ import annotations
from typing import Any
from homeassistant.const import ATTR_ID
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription
from .const import ATTR_TITLE, DOMAIN, MANUFACTURER
from .coordinator import YouTubeDataUpdateCoordinator
class YouTubeChannelEntity(Entity):
"""An HA implementation for YouTube entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: YouTubeDataUpdateCoordinator,
description: EntityDescription,
channel: dict[str, Any],
) -> None:
"""Initialize a Google Mail entity."""
self.entity_description = description
self._attr_unique_id = (
f"{coordinator.config_entry.entry_id}_{channel[ATTR_ID]}_{description.key}"
)
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={
(DOMAIN, f"{coordinator.config_entry.entry_id}_{channel[ATTR_ID]}")
},
manufacturer=MANUFACTURER,
name=channel[ATTR_TITLE],
)
self._channel = channel

View File

@ -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"]
}

View File

@ -0,0 +1,86 @@
"""Support for YouTube Sensors."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ICON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import YouTubeDataUpdateCoordinator
from .const import (
ATTR_LATEST_VIDEO,
ATTR_SUBSCRIBER_COUNT,
ATTR_THUMBNAIL,
ATTR_TITLE,
COORDINATOR,
DOMAIN,
)
from .entity import YouTubeChannelEntity
@dataclass
class YouTubeMixin:
"""Mixin for required keys."""
value_fn: Callable[[Any], StateType]
entity_picture_fn: Callable[[Any], str]
@dataclass
class YouTubeSensorEntityDescription(SensorEntityDescription, YouTubeMixin):
"""Describes YouTube sensor entity."""
SENSOR_TYPES = [
YouTubeSensorEntityDescription(
key="latest_upload",
translation_key="latest_upload",
icon="mdi:youtube",
value_fn=lambda channel: channel[ATTR_LATEST_VIDEO][ATTR_TITLE],
entity_picture_fn=lambda channel: channel[ATTR_LATEST_VIDEO][ATTR_THUMBNAIL],
),
YouTubeSensorEntityDescription(
key="subscribers",
translation_key="subscribers",
icon="mdi:youtube-subscription",
native_unit_of_measurement="subscribers",
value_fn=lambda channel: channel[ATTR_SUBSCRIBER_COUNT],
entity_picture_fn=lambda channel: channel[ATTR_ICON],
),
]
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the YouTube sensor."""
coordinator: YouTubeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
COORDINATOR
]
async_add_entities(
YouTubeSensor(coordinator, sensor_type, channel)
for channel in coordinator.data.values()
for sensor_type in SENSOR_TYPES
)
class YouTubeSensor(YouTubeChannelEntity, SensorEntity):
"""Representation of a YouTube sensor."""
entity_description: YouTubeSensorEntityDescription
@property
def native_value(self) -> StateType:
"""Return the value reported by the sensor."""
return self.entity_description.value_fn(self._channel)
@property
def entity_picture(self) -> str:
"""Return the value reported by the sensor."""
return self.entity_description.entity_picture_fn(self._channel)

View File

@ -0,0 +1,42 @@
{
"config": {
"abort": {
"access_not_configured": "Please read the below message we got from Google:\n\n{message}",
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"error": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"step": {
"channels": {
"description": "Select the channels you want to add.",
"data": {
"channels": "YouTube channels"
}
}
}
},
"options": {
"step": {
"init": {
"description": "Select the channels you want to add.",
"data": {
"channels": "YouTube channels"
}
}
}
},
"entity": {
"sensor": {
"latest_upload": {
"name": "Latest upload"
},
"subscribers": {
"name": "Subscribers"
}
}
}
}

View File

@ -20,4 +20,5 @@ APPLICATION_CREDENTIALS = [
"withings",
"xbox",
"yolink",
"youtube",
]

View File

@ -522,6 +522,7 @@ FLOWS = {
"yeelight",
"yolink",
"youless",
"youtube",
"zamg",
"zerproc",
"zeversolar",

View File

@ -2116,6 +2116,12 @@
"config_flow": true,
"iot_class": "cloud_push",
"name": "Dialogflow"
},
"youtube": {
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_polling",
"name": "YouTube"
}
}
},

View File

@ -811,6 +811,7 @@ goalzero==0.2.1
goodwe==0.2.31
# homeassistant.components.google_mail
# homeassistant.components.youtube
google-api-python-client==2.71.0
# homeassistant.components.google_pubsub

View File

@ -633,6 +633,7 @@ goalzero==0.2.1
goodwe==0.2.31
# homeassistant.components.google_mail
# homeassistant.components.youtube
google-api-python-client==2.71.0
# homeassistant.components.google_pubsub

View File

@ -0,0 +1,68 @@
"""Tests for the YouTube integration."""
from dataclasses import dataclass
import json
from typing import Any
from tests.common import load_fixture
@dataclass
class MockRequest:
"""Mock object for a request."""
fixture: str
def execute(self) -> dict[str, Any]:
"""Return a fixture."""
return json.loads(load_fixture(self.fixture))
class MockChannels:
"""Mock object for channels."""
def list(
self,
part: str,
id: str | None = None,
mine: bool | None = None,
maxResults: int | None = None,
) -> MockRequest:
"""Return a fixture."""
return MockRequest(fixture="youtube/get_channel.json")
class MockPlaylistItems:
"""Mock object for playlist items."""
def list(
self,
part: str,
playlistId: str,
maxResults: int | None = None,
) -> MockRequest:
"""Return a fixture."""
return MockRequest(fixture="youtube/get_playlist_items.json")
class MockSubscriptions:
"""Mock object for subscriptions."""
def list(self, part: str, mine: bool, maxResults: int | None = None) -> MockRequest:
"""Return a fixture."""
return MockRequest(fixture="youtube/get_subscriptions.json")
class MockService:
"""Service which returns mock objects."""
def channels(self) -> MockChannels:
"""Return a mock object."""
return MockChannels()
def playlistItems(self) -> MockPlaylistItems:
"""Return a mock object."""
return MockPlaylistItems()
def subscriptions(self) -> MockSubscriptions:
"""Return a mock object."""
return MockSubscriptions()

View File

@ -0,0 +1,116 @@
"""Configure tests for the Google Mail integration."""
from collections.abc import Awaitable, Callable, Coroutine
import time
from typing import Any
from unittest.mock import patch
import pytest
from homeassistant.components.application_credentials import (
ClientCredential,
async_import_client_credential,
)
from homeassistant.components.youtube.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
from tests.components.youtube import MockService
from tests.test_util.aiohttp import AiohttpClientMocker
ComponentSetup = Callable[[], Awaitable[None]]
BUILD = "homeassistant.components.google_mail.api.build"
CLIENT_ID = "1234"
CLIENT_SECRET = "5678"
GOOGLE_AUTH_URI = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URI = "https://oauth2.googleapis.com/token"
SCOPES = [
"https://www.googleapis.com/auth/youtube.readonly",
]
SENSOR = "sensor.example_gmail_com_vacation_end_date"
TITLE = "Google for Developers"
TOKEN = "homeassistant.components.youtube.api.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid"
@pytest.fixture(name="scopes")
def mock_scopes() -> list[str]:
"""Fixture to set the scopes present in the OAuth token."""
return SCOPES
@pytest.fixture(autouse=True)
async def setup_credentials(hass: HomeAssistant) -> None:
"""Fixture to setup credentials."""
assert await async_setup_component(hass, "application_credentials", {})
await async_import_client_credential(
hass,
DOMAIN,
ClientCredential(CLIENT_ID, CLIENT_SECRET),
DOMAIN,
)
@pytest.fixture(name="expires_at")
def mock_expires_at() -> int:
"""Fixture to set the oauth token expiration time."""
return time.time() + 3600
@pytest.fixture(name="config_entry")
def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry:
"""Create Google Mail entry in Home Assistant."""
return MockConfigEntry(
domain=DOMAIN,
title=TITLE,
unique_id="UC_x5XG1OV2P6uZZ5FSM9Ttw",
data={
"auth_implementation": DOMAIN,
"token": {
"access_token": "mock-access-token",
"refresh_token": "mock-refresh-token",
"expires_at": expires_at,
"scope": " ".join(scopes),
},
},
options={"channels": ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]},
)
@pytest.fixture(autouse=True)
def mock_connection(aioclient_mock: AiohttpClientMocker) -> None:
"""Mock Google Mail connection."""
aioclient_mock.post(
GOOGLE_TOKEN_URI,
json={
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
},
)
@pytest.fixture(name="setup_integration")
async def mock_setup_integration(
hass: HomeAssistant, config_entry: MockConfigEntry
) -> Callable[[], Coroutine[Any, Any, None]]:
"""Fixture for setting up the component."""
config_entry.add_to_hass(hass)
assert await async_setup_component(hass, "application_credentials", {})
await async_import_client_credential(
hass,
DOMAIN,
ClientCredential(CLIENT_ID, CLIENT_SECRET),
DOMAIN,
)
async def func() -> None:
with patch(
"homeassistant.components.youtube.api.build", return_value=MockService()
):
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
return func

View File

@ -0,0 +1,55 @@
{
"kind": "youtube#channelListResponse",
"etag": "8HTiiXpKCq-GJvDVOd88e5o_KGc",
"pageInfo": {
"totalResults": 1,
"resultsPerPage": 5
},
"items": [
{
"kind": "youtube#channel",
"etag": "CG3vwkqpnD2Bj_MaPXmy9puO4Kc",
"id": "UC_x5XG1OV2P6uZZ5FSM9Ttw",
"snippet": {
"title": "Google for Developers",
"description": "Subscribe to join a community of creative developers and learn the latest in Google technology — from AI and cloud, to mobile and web.\n\nExplore more at developers.google.com\n\n",
"customUrl": "@googledevelopers",
"publishedAt": "2007-08-23T00:34:43Z",
"thumbnails": {
"default": {
"url": "https://yt3.ggpht.com/fca_HuJ99xUxflWdex0XViC3NfctBFreIl8y4i9z411asnGTWY-Ql3MeH_ybA4kNaOjY7kyA=s88-c-k-c0x00ffffff-no-rj",
"width": 88,
"height": 88
},
"medium": {
"url": "https://yt3.ggpht.com/fca_HuJ99xUxflWdex0XViC3NfctBFreIl8y4i9z411asnGTWY-Ql3MeH_ybA4kNaOjY7kyA=s240-c-k-c0x00ffffff-no-rj",
"width": 240,
"height": 240
},
"high": {
"url": "https://yt3.ggpht.com/fca_HuJ99xUxflWdex0XViC3NfctBFreIl8y4i9z411asnGTWY-Ql3MeH_ybA4kNaOjY7kyA=s800-c-k-c0x00ffffff-no-rj",
"width": 800,
"height": 800
}
},
"localized": {
"title": "Google for Developers",
"description": "Subscribe to join a community of creative developers and learn the latest in Google technology — from AI and cloud, to mobile and web.\n\nExplore more at developers.google.com\n\n"
},
"country": "US"
},
"contentDetails": {
"relatedPlaylists": {
"likes": "",
"uploads": "UU_x5XG1OV2P6uZZ5FSM9Ttw"
}
},
"statistics": {
"viewCount": "214141263",
"subscriberCount": "2290000",
"hiddenSubscriberCount": false,
"videoCount": "5798"
}
}
]
}

View File

@ -0,0 +1,42 @@
{
"kind": "youtube#SubscriptionListResponse",
"etag": "6C9iFE7CzKQqPrEoJlE0H2U27xI",
"nextPageToken": "CAEQAA",
"pageInfo": {
"totalResults": 525,
"resultsPerPage": 1
},
"items": [
{
"kind": "youtube#subscription",
"etag": "4Hr8w5f03mLak3fZID0aXypQRDg",
"id": "l6YW-siEBx2rtBlTJ_ip10UA2t_d09UYkgtJsqbYblE",
"snippet": {
"publishedAt": "2015-08-09T21:37:44Z",
"title": "Linus Tech Tips",
"description": "Linus Tech Tips is a passionate team of \"professionally curious\" experts in consumer technology and video production who aim to educate and entertain.",
"resourceId": {
"kind": "youtube#channel",
"channelId": "UCXuqSBlHAE6Xw-yeJA0Tunw"
},
"channelId": "UCXuqSBlHAE6Xw-yeJA0Tunw",
"thumbnails": {
"default": {
"url": "https://yt3.ggpht.com/Vy6KL7EM_apxPSxF0pPy5w_c87YDTOlBQo3MADDF0Wl51kwxmt9wmRotnt2xQXwlrcyO0Xe56w=s88-c-k-c0x00ffffff-no-rj"
},
"medium": {
"url": "https://yt3.ggpht.com/Vy6KL7EM_apxPSxF0pPy5w_c87YDTOlBQo3MADDF0Wl51kwxmt9wmRotnt2xQXwlrcyO0Xe56w=s240-c-k-c0x00ffffff-no-rj"
},
"high": {
"url": "https://yt3.ggpht.com/Vy6KL7EM_apxPSxF0pPy5w_c87YDTOlBQo3MADDF0Wl51kwxmt9wmRotnt2xQXwlrcyO0Xe56w=s800-c-k-c0x00ffffff-no-rj"
}
}
},
"contentDetails": {
"totalItemCount": 6178,
"newItemCount": 0,
"activityType": "all"
}
}
]
}

View File

@ -0,0 +1,266 @@
{
"kind": "youtube#playlistItemListResponse",
"etag": "O0Ah8Wd5pUD2Gsv-n0A42RDRcX8",
"nextPageToken": "EAAaBlBUOkNBVQ",
"items": [
{
"kind": "youtube#playlistItem",
"etag": "qgpoAJRNskzLhD99njC8e2kPB0M",
"id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3Lnd5c3VrRHJNZHFV",
"snippet": {
"publishedAt": "2023-05-11T00:20:46Z",
"channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw",
"title": "What's new in Google Home in less than 1 minute",
"description": "Discover how your connected devices can do more with Google Home using Matter and Automations at Google I/O 2023.\n\nTo learn more about what's new in Google Home, check out the keynote → https://goo.gle/IO23_homekey\n\nSubscribe to Google Developers → https://goo.gle/developers \n\n#GoogleIO #GoogleHome",
"thumbnails": {
"default": {
"url": "https://i.ytimg.com/vi/wysukDrMdqU/default.jpg",
"width": 120,
"height": 90
},
"medium": {
"url": "https://i.ytimg.com/vi/wysukDrMdqU/mqdefault.jpg",
"width": 320,
"height": 180
},
"high": {
"url": "https://i.ytimg.com/vi/wysukDrMdqU/hqdefault.jpg",
"width": 480,
"height": 360
},
"standard": {
"url": "https://i.ytimg.com/vi/wysukDrMdqU/sddefault.jpg",
"width": 640,
"height": 480
},
"maxres": {
"url": "https://i.ytimg.com/vi/wysukDrMdqU/maxresdefault.jpg",
"width": 1280,
"height": 720
}
},
"channelTitle": "Google for Developers",
"playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw",
"position": 0,
"resourceId": {
"kind": "youtube#video",
"videoId": "wysukDrMdqU"
},
"videoOwnerChannelTitle": "Google for Developers",
"videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw"
},
"contentDetails": {
"videoId": "wysukDrMdqU",
"videoPublishedAt": "2023-05-11T00:20:46Z"
}
},
{
"kind": "youtube#playlistItem",
"etag": "pU0v49jXONlQfIJEX7ldINttRYM",
"id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3LmhsZUxsY0h3UUxN",
"snippet": {
"publishedAt": "2023-05-10T22:30:48Z",
"channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw",
"title": "Google I/O 2023 Developer Keynote in 5 minutes",
"description": "Discover whats new from Google, including top takeaways and highlights announced at Google I/O 2023. From deep investments in the largest mobile platform, to breakthroughs in AI, learn about the latest capabilities in mobile, web, Cloud, AI, and more. \n\nCatch the full Developer Keynote →https://goo.gle/dev-keynote-23 \nWatch all the Keynotes from Google I/O 2023→ https://goo.gle/IO23_keynotes\nWatch all the Google I/O 2023 Sessions → https://goo.gle/IO23_all \n\n0:00 - Welcome\n0:25 - MakerSuite\n0:49 - Android Studio Bot\n1:38 - Large screens\n2:04 - Wear OS\n2:34 - WebGPU\n2:58 - Baseline\n3:27 - MediaPipe\n3:57 - Duet AI for Google Cloud\n4:59 - Closing\n\nSubscribe to Google Developers → https://goo.gle/developers\n\n#GoogleIO #developers",
"thumbnails": {
"default": {
"url": "https://i.ytimg.com/vi/hleLlcHwQLM/default.jpg",
"width": 120,
"height": 90
},
"medium": {
"url": "https://i.ytimg.com/vi/hleLlcHwQLM/mqdefault.jpg",
"width": 320,
"height": 180
},
"high": {
"url": "https://i.ytimg.com/vi/hleLlcHwQLM/hqdefault.jpg",
"width": 480,
"height": 360
},
"standard": {
"url": "https://i.ytimg.com/vi/hleLlcHwQLM/sddefault.jpg",
"width": 640,
"height": 480
},
"maxres": {
"url": "https://i.ytimg.com/vi/hleLlcHwQLM/maxresdefault.jpg",
"width": 1280,
"height": 720
}
},
"channelTitle": "Google for Developers",
"playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw",
"position": 1,
"resourceId": {
"kind": "youtube#video",
"videoId": "hleLlcHwQLM"
},
"videoOwnerChannelTitle": "Google for Developers",
"videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw"
},
"contentDetails": {
"videoId": "hleLlcHwQLM",
"videoPublishedAt": "2023-05-10T22:30:48Z"
}
},
{
"kind": "youtube#playlistItem",
"etag": "fht9mKDuIBXcO75k21ZB_gC_4vM",
"id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3LmxNS2p0U0Z1amN3",
"snippet": {
"publishedAt": "2023-05-10T21:25:47Z",
"channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw",
"title": "What's new in Google Pay and Wallet in less than 1 minute",
"description": "A quick recap on the latest updates to Google Pay and Wallet from Google I/O 2023.\n\nTo learn more about what's new in Google Pay and Wallet, check out the keynote → https://goo.gle/IO23_paywallet\n\nSubscribe to Google Developers → https://goo.gle/developers\n\n#GoogleIO",
"thumbnails": {
"default": {
"url": "https://i.ytimg.com/vi/lMKjtSFujcw/default.jpg",
"width": 120,
"height": 90
},
"medium": {
"url": "https://i.ytimg.com/vi/lMKjtSFujcw/mqdefault.jpg",
"width": 320,
"height": 180
},
"high": {
"url": "https://i.ytimg.com/vi/lMKjtSFujcw/hqdefault.jpg",
"width": 480,
"height": 360
},
"standard": {
"url": "https://i.ytimg.com/vi/lMKjtSFujcw/sddefault.jpg",
"width": 640,
"height": 480
},
"maxres": {
"url": "https://i.ytimg.com/vi/lMKjtSFujcw/maxresdefault.jpg",
"width": 1280,
"height": 720
}
},
"channelTitle": "Google for Developers",
"playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw",
"position": 2,
"resourceId": {
"kind": "youtube#video",
"videoId": "lMKjtSFujcw"
},
"videoOwnerChannelTitle": "Google for Developers",
"videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw"
},
"contentDetails": {
"videoId": "lMKjtSFujcw",
"videoPublishedAt": "2023-05-10T21:25:47Z"
}
},
{
"kind": "youtube#playlistItem",
"etag": "nYKXoKd8eePAZ_xFa3dL5ZmvM5c",
"id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3LmMwbXFCdVhQcnBB",
"snippet": {
"publishedAt": "2023-05-10T20:47:57Z",
"channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw",
"title": "Developers guide to BigQuery export for Google Analytics 4",
"description": "With Google Analytics 4 (GA4), anyone can set up export of granular measurement data to BigQuery.\n\nIn this session, you will learn how to use the BigQuery export for solving business problems, doing complex reporting, implementing advanced use cases with ML models, and creating custom audiences by joining with first-party data. You can use this framework for detailed or large-scale data analysis. We will also share some best practices to get you started.\n\nResources:\nDevelopers guide to BigQuery export for Google Analytics 4 → https://goo.gle/ga-io23\n\nSpeaker: Minhaz Kazi\n\nWatch more:\nWatch all the Technical Sessions from Google I/O 2023 → https://goo.gle/IO23_sessions\nWatch more Mobile Sessions → https://goo.gle/IO23_mobile\nWatch more Web Sessions → https://goo.gle/IO23_web\nAll Google I/O 2023 Sessions → https://goo.gle/IO23_all\n\nSubscribe to Google Developers → https://goo.gle/developers\n\n#GoogleIO",
"thumbnails": {
"default": {
"url": "https://i.ytimg.com/vi/c0mqBuXPrpA/default.jpg",
"width": 120,
"height": 90
},
"medium": {
"url": "https://i.ytimg.com/vi/c0mqBuXPrpA/mqdefault.jpg",
"width": 320,
"height": 180
},
"high": {
"url": "https://i.ytimg.com/vi/c0mqBuXPrpA/hqdefault.jpg",
"width": 480,
"height": 360
},
"standard": {
"url": "https://i.ytimg.com/vi/c0mqBuXPrpA/sddefault.jpg",
"width": 640,
"height": 480
},
"maxres": {
"url": "https://i.ytimg.com/vi/c0mqBuXPrpA/maxresdefault.jpg",
"width": 1280,
"height": 720
}
},
"channelTitle": "Google for Developers",
"playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw",
"position": 3,
"resourceId": {
"kind": "youtube#video",
"videoId": "c0mqBuXPrpA"
},
"videoOwnerChannelTitle": "Google for Developers",
"videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw"
},
"contentDetails": {
"videoId": "c0mqBuXPrpA",
"videoPublishedAt": "2023-05-10T20:47:57Z"
}
},
{
"kind": "youtube#playlistItem",
"etag": "--gb8pSHDwp9c-fyjhZ0K2DklLE",
"id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3Ll9uOXh3dVRPUmFz",
"snippet": {
"publishedAt": "2023-05-10T20:46:29Z",
"channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw",
"title": "What's new in Google Home - American Sign Language",
"description": "To watch this Session without American Sign Language (ASL) interpretation, please click here → https://goo.gle/IO23_homekey\n\nDiscover how your connected devices can do more with Google Home using Matter and Automations.\n\nResources:\nGoogle Home Developer Center → https://goo.gle/3KcD5xr\n\nDiscover how your connected devices can do more with Google Home using Matter and Automations\nGoogle Home APIs Developer Preview → https://goo.gle/3UakRl0\nAutomations Developer Preview → https://goo.gle/3KgEcMy\n\nSpeakers: Taylor Lehman, Indu Ramamurthi\n\nWatch more:\nWatch more Mobile Sessions → https://goo.gle/IO23_mobile\nAll Google I/O 2023 Sessions → https://goo.gle/IO23_all\n\nSubscribe to Google Developers → https://goo.gle/developers\n\n#GoogleIO",
"thumbnails": {
"default": {
"url": "https://i.ytimg.com/vi/_n9xwuTORas/default.jpg",
"width": 120,
"height": 90
},
"medium": {
"url": "https://i.ytimg.com/vi/_n9xwuTORas/mqdefault.jpg",
"width": 320,
"height": 180
},
"high": {
"url": "https://i.ytimg.com/vi/_n9xwuTORas/hqdefault.jpg",
"width": 480,
"height": 360
},
"standard": {
"url": "https://i.ytimg.com/vi/_n9xwuTORas/sddefault.jpg",
"width": 640,
"height": 480
},
"maxres": {
"url": "https://i.ytimg.com/vi/_n9xwuTORas/maxresdefault.jpg",
"width": 1280,
"height": 720
}
},
"channelTitle": "Google for Developers",
"playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw",
"position": 4,
"resourceId": {
"kind": "youtube#video",
"videoId": "_n9xwuTORas"
},
"videoOwnerChannelTitle": "Google for Developers",
"videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw"
},
"contentDetails": {
"videoId": "_n9xwuTORas",
"videoPublishedAt": "2023-05-10T20:46:29Z"
}
}
],
"pageInfo": {
"totalResults": 5798,
"resultsPerPage": 5
}
}

View File

@ -0,0 +1,37 @@
{
"kind": "youtube#SubscriptionListResponse",
"etag": "6C9iFE7CzKQqPrEoJlE0H2U27xI",
"nextPageToken": "CAEQAA",
"pageInfo": {
"totalResults": 525,
"resultsPerPage": 1
},
"items": [
{
"kind": "youtube#subscription",
"etag": "4Hr8w5f03mLak3fZID0aXypQRDg",
"id": "l6YW-siEBx2rtBlTJ_ip10UA2t_d09UYkgtJsqbYblE",
"snippet": {
"publishedAt": "2015-08-09T21:37:44Z",
"title": "Linus Tech Tips",
"description": "Linus Tech Tips is a passionate team of \"professionally curious\" experts in consumer technology and video production who aim to educate and entertain.",
"resourceId": {
"kind": "youtube#channel",
"channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw"
},
"channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw",
"thumbnails": {
"default": {
"url": "https://yt3.ggpht.com/Vy6KL7EM_apxPSxF0pPy5w_c87YDTOlBQo3MADDF0Wl51kwxmt9wmRotnt2xQXwlrcyO0Xe56w=s88-c-k-c0x00ffffff-no-rj"
},
"medium": {
"url": "https://yt3.ggpht.com/Vy6KL7EM_apxPSxF0pPy5w_c87YDTOlBQo3MADDF0Wl51kwxmt9wmRotnt2xQXwlrcyO0Xe56w=s240-c-k-c0x00ffffff-no-rj"
},
"high": {
"url": "https://yt3.ggpht.com/Vy6KL7EM_apxPSxF0pPy5w_c87YDTOlBQo3MADDF0Wl51kwxmt9wmRotnt2xQXwlrcyO0Xe56w=s800-c-k-c0x00ffffff-no-rj"
}
}
}
}
]
}

View File

@ -0,0 +1,171 @@
"""Test the YouTube config flow."""
from unittest.mock import patch
from googleapiclient.errors import HttpError
from httplib2 import Response
from homeassistant import config_entries
from homeassistant.components.youtube.const import CONF_CHANNELS, DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_entry_oauth2_flow
from . import MockService
from .conftest import CLIENT_ID, GOOGLE_AUTH_URI, SCOPES, TITLE
from tests.typing import ClientSessionGenerator
async def test_full_flow(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
current_request_with_host: None,
) -> None:
"""Check full flow."""
result = await hass.config_entries.flow.async_init(
"youtube", context={"source": config_entries.SOURCE_USER}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
assert result["url"] == (
f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}"
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}&scope={'+'.join(SCOPES)}"
"&access_type=offline&prompt=consent"
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8"
with patch(
"homeassistant.components.youtube.async_setup_entry", return_value=True
) as mock_setup, patch(
"homeassistant.components.youtube.api.build", return_value=MockService()
), patch(
"homeassistant.components.youtube.config_flow.build", return_value=MockService()
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "channels"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]}
)
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(mock_setup.mock_calls) == 1
assert result["type"] == "create_entry"
assert result["title"] == TITLE
assert "result" in result
assert result["result"].unique_id == "UC_x5XG1OV2P6uZZ5FSM9Ttw"
assert "token" in result["result"].data
assert result["result"].data["token"]["access_token"] == "mock-access-token"
assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token"
assert result["options"] == {CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]}
async def test_flow_http_error(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
current_request_with_host: None,
) -> None:
"""Check full flow."""
result = await hass.config_entries.flow.async_init(
"youtube", context={"source": config_entries.SOURCE_USER}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
assert result["url"] == (
f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}"
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}&scope={'+'.join(SCOPES)}"
"&access_type=offline&prompt=consent"
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8"
with patch(
"homeassistant.components.youtube.config_flow.build",
side_effect=HttpError(
Response(
{
"vary": "Origin, X-Origin, Referer",
"content-type": "application/json; charset=UTF-8",
"date": "Mon, 15 May 2023 21:25:42 GMT",
"server": "scaffolding on HTTPServer2",
"cache-control": "private",
"x-xss-protection": "0",
"x-frame-options": "SAMEORIGIN",
"x-content-type-options": "nosniff",
"alt-svc": 'h3=":443"; ma=2592000,h3-29=":443"; ma=2592000',
"transfer-encoding": "chunked",
"status": "403",
"content-length": "947",
"-content-encoding": "gzip",
}
),
b'{"error": {"code": 403,"message": "YouTube Data API v3 has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/youtube.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.","errors": [ { "message": "YouTube Data API v3 has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/youtube.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.", "domain": "usageLimits", "reason": "accessNotConfigured", "extendedHelp": "https://console.developers.google.com" }],"status": "PERMISSION_DENIED"\n }\n}\n',
),
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "access_not_configured"
assert (
result["description_placeholders"]["message"]
== "YouTube Data API v3 has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/youtube.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry."
)
async def test_flow_exception(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
current_request_with_host: None,
) -> None:
"""Check full flow."""
result = await hass.config_entries.flow.async_init(
"youtube", context={"source": config_entries.SOURCE_USER}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
assert result["url"] == (
f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}"
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}&scope={'+'.join(SCOPES)}"
"&access_type=offline&prompt=consent"
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8"
with patch(
"homeassistant.components.youtube.config_flow.build", side_effect=Exception
):
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "unknown"

View File

@ -0,0 +1,135 @@
"""Tests for YouTube."""
import http
import time
from unittest.mock import patch
from aiohttp.client_exceptions import ClientError
import pytest
from homeassistant.components.youtube import DOMAIN
from homeassistant.components.youtube.const import CONF_CHANNELS
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from .conftest import GOOGLE_TOKEN_URI, ComponentSetup
from tests.test_util.aiohttp import AiohttpClientMocker
async def test_setup_success(
hass: HomeAssistant, setup_integration: ComponentSetup
) -> None:
"""Test successful setup and unload."""
await setup_integration()
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
assert entries[0].state is ConfigEntryState.LOADED
await hass.config_entries.async_unload(entries[0].entry_id)
await hass.async_block_till_done()
assert not hass.services.async_services().get(DOMAIN)
@pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"])
async def test_expired_token_refresh_success(
hass: HomeAssistant,
setup_integration: ComponentSetup,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test expired token is refreshed."""
aioclient_mock.clear_requests()
aioclient_mock.post(
GOOGLE_TOKEN_URI,
json={
"access_token": "updated-access-token",
"refresh_token": "updated-refresh-token",
"expires_at": time.time() + 3600,
"expires_in": 3600,
},
)
await setup_integration()
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
assert entries[0].state is ConfigEntryState.LOADED
assert entries[0].data["token"]["access_token"] == "updated-access-token"
assert entries[0].data["token"]["expires_in"] == 3600
@pytest.mark.parametrize(
("expires_at", "status", "expected_state"),
[
(
time.time() - 3600,
http.HTTPStatus.UNAUTHORIZED,
ConfigEntryState.SETUP_RETRY,
),
(
time.time() - 3600,
http.HTTPStatus.INTERNAL_SERVER_ERROR,
ConfigEntryState.SETUP_RETRY,
),
],
ids=["failure_requires_reauth", "transient_failure"],
)
async def test_expired_token_refresh_failure(
hass: HomeAssistant,
setup_integration: ComponentSetup,
aioclient_mock: AiohttpClientMocker,
status: http.HTTPStatus,
expected_state: ConfigEntryState,
) -> None:
"""Test failure while refreshing token with a transient error."""
aioclient_mock.clear_requests()
aioclient_mock.post(
GOOGLE_TOKEN_URI,
status=status,
)
await setup_integration()
# Verify a transient failure has occurred
entries = hass.config_entries.async_entries(DOMAIN)
assert entries[0].state is expected_state
async def test_expired_token_refresh_client_error(
hass: HomeAssistant,
setup_integration: ComponentSetup,
) -> None:
"""Test failure while refreshing token with a client error."""
with patch(
"homeassistant.components.youtube.OAuth2Session.async_ensure_token_valid",
side_effect=ClientError,
):
await setup_integration()
# Verify a transient failure has occurred
entries = hass.config_entries.async_entries(DOMAIN)
assert entries[0].state is ConfigEntryState.SETUP_RETRY
async def test_device_info(
hass: HomeAssistant, setup_integration: ComponentSetup
) -> None:
"""Test device info."""
await setup_integration()
device_registry = dr.async_get(hass)
entry = hass.config_entries.async_entries(DOMAIN)[0]
channel_id = entry.options[CONF_CHANNELS][0]
device = device_registry.async_get_device(
{(DOMAIN, f"{entry.entry_id}_{channel_id}")}
)
assert device.entry_type is dr.DeviceEntryType.SERVICE
assert device.identifiers == {(DOMAIN, f"{entry.entry_id}_{channel_id}")}
assert device.manufacturer == "Google, Inc."
assert device.name == "Google for Developers"

View File

@ -0,0 +1,29 @@
"""Sensor tests for the YouTube integration."""
from homeassistant.core import HomeAssistant
from .conftest import ComponentSetup
async def test_sensor(hass: HomeAssistant, setup_integration: ComponentSetup) -> None:
"""Test sensor."""
await setup_integration()
state = hass.states.get("sensor.google_for_developers_latest_upload")
assert state
assert state.name == "Google for Developers Latest upload"
assert state.state == "What's new in Google Home in less than 1 minute"
assert (
state.attributes["entity_picture"]
== "https://i.ytimg.com/vi/wysukDrMdqU/sddefault.jpg"
)
state = hass.states.get("sensor.google_for_developers_subscribers")
assert state
assert state.name == "Google for Developers Subscribers"
assert state.state == "2290000"
assert (
state.attributes["entity_picture"]
== "https://yt3.ggpht.com/fca_HuJ99xUxflWdex0XViC3NfctBFreIl8y4i9z411asnGTWY-Ql3MeH_ybA4kNaOjY7kyA=s800-c-k-c0x00ffffff-no-rj"
)