mirror of
https://github.com/home-assistant/core.git
synced 2025-04-24 09:17:53 +00:00
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:
parent
bb170a2bbf
commit
e4c51d43f0
@ -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
|
||||
|
@ -17,6 +17,7 @@
|
||||
"google",
|
||||
"nest",
|
||||
"cast",
|
||||
"dialogflow"
|
||||
"dialogflow",
|
||||
"youtube"
|
||||
]
|
||||
}
|
||||
|
55
homeassistant/components/youtube/__init__.py
Normal file
55
homeassistant/components/youtube/__init__.py
Normal 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
|
47
homeassistant/components/youtube/api.py
Normal file
47
homeassistant/components/youtube/api.py
Normal 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,
|
||||
)
|
11
homeassistant/components/youtube/application_credentials.py
Normal file
11
homeassistant/components/youtube/application_credentials.py
Normal 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",
|
||||
)
|
121
homeassistant/components/youtube/config_flow.py
Normal file
121
homeassistant/components/youtube/config_flow.py
Normal 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)
|
22
homeassistant/components/youtube/const.py
Normal file
22
homeassistant/components/youtube/const.py
Normal 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"
|
90
homeassistant/components/youtube/coordinator.py
Normal file
90
homeassistant/components/youtube/coordinator.py
Normal 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"],
|
||||
}
|
38
homeassistant/components/youtube/entity.py
Normal file
38
homeassistant/components/youtube/entity.py
Normal 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
|
11
homeassistant/components/youtube/manifest.json
Normal file
11
homeassistant/components/youtube/manifest.json
Normal 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"]
|
||||
}
|
86
homeassistant/components/youtube/sensor.py
Normal file
86
homeassistant/components/youtube/sensor.py
Normal 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)
|
42
homeassistant/components/youtube/strings.json
Normal file
42
homeassistant/components/youtube/strings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -20,4 +20,5 @@ APPLICATION_CREDENTIALS = [
|
||||
"withings",
|
||||
"xbox",
|
||||
"yolink",
|
||||
"youtube",
|
||||
]
|
||||
|
@ -522,6 +522,7 @@ FLOWS = {
|
||||
"yeelight",
|
||||
"yolink",
|
||||
"youless",
|
||||
"youtube",
|
||||
"zamg",
|
||||
"zerproc",
|
||||
"zeversolar",
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
68
tests/components/youtube/__init__.py
Normal file
68
tests/components/youtube/__init__.py
Normal 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()
|
116
tests/components/youtube/conftest.py
Normal file
116
tests/components/youtube/conftest.py
Normal 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
|
55
tests/components/youtube/fixtures/get_channel.json
Normal file
55
tests/components/youtube/fixtures/get_channel.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
42
tests/components/youtube/fixtures/get_channel_2.json
Normal file
42
tests/components/youtube/fixtures/get_channel_2.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
266
tests/components/youtube/fixtures/get_playlist_items.json
Normal file
266
tests/components/youtube/fixtures/get_playlist_items.json
Normal 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 what’s 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
|
||||
}
|
||||
}
|
37
tests/components/youtube/fixtures/get_subscriptions.json
Normal file
37
tests/components/youtube/fixtures/get_subscriptions.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
171
tests/components/youtube/test_config_flow.py
Normal file
171
tests/components/youtube/test_config_flow.py
Normal 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"
|
135
tests/components/youtube/test_init.py
Normal file
135
tests/components/youtube/test_init.py
Normal 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"
|
29
tests/components/youtube/test_sensor.py
Normal file
29
tests/components/youtube/test_sensor.py
Normal 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"
|
||||
)
|
Loading…
x
Reference in New Issue
Block a user