mirror of
https://github.com/home-assistant/core.git
synced 2025-07-22 04:37:06 +00:00
Implement YouTube async library (#97072)
This commit is contained in:
parent
714a04d603
commit
04f6d1848b
@ -1,16 +1,18 @@
|
|||||||
"""API for YouTube bound to Home Assistant OAuth."""
|
"""API for YouTube bound to Home Assistant OAuth."""
|
||||||
from google.auth.exceptions import RefreshError
|
from youtubeaio.types import AuthScope
|
||||||
from google.oauth2.credentials import Credentials
|
from youtubeaio.youtube import YouTube
|
||||||
from googleapiclient.discovery import Resource, build
|
|
||||||
|
|
||||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import config_entry_oauth2_flow
|
from homeassistant.helpers import config_entry_oauth2_flow
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
|
|
||||||
class AsyncConfigEntryAuth:
|
class AsyncConfigEntryAuth:
|
||||||
"""Provide Google authentication tied to an OAuth2 based config entry."""
|
"""Provide Google authentication tied to an OAuth2 based config entry."""
|
||||||
|
|
||||||
|
youtube: YouTube | None = None
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
@ -30,19 +32,10 @@ class AsyncConfigEntryAuth:
|
|||||||
await self.oauth_session.async_ensure_token_valid()
|
await self.oauth_session.async_ensure_token_valid()
|
||||||
return self.access_token
|
return self.access_token
|
||||||
|
|
||||||
async def get_resource(self) -> Resource:
|
async def get_resource(self) -> YouTube:
|
||||||
"""Create executor job to get current resource."""
|
"""Create resource."""
|
||||||
try:
|
token = await self.check_and_refresh_token()
|
||||||
credentials = Credentials(await self.check_and_refresh_token())
|
if self.youtube is None:
|
||||||
except RefreshError as ex:
|
self.youtube = YouTube(session=async_get_clientsession(self.hass))
|
||||||
self.oauth_session.config_entry.async_start_reauth(self.oauth_session.hass)
|
await self.youtube.set_user_authentication(token, [AuthScope.READ_ONLY])
|
||||||
raise ex
|
return self.youtube
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
@ -1,21 +1,21 @@
|
|||||||
"""Config flow for YouTube integration."""
|
"""Config flow for YouTube integration."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import AsyncGenerator, Mapping
|
from collections.abc import Mapping
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
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
|
import voluptuous as vol
|
||||||
|
from youtubeaio.helper import first
|
||||||
|
from youtubeaio.types import AuthScope, ForbiddenError
|
||||||
|
from youtubeaio.youtube import YouTube
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry, OptionsFlowWithConfigEntry
|
from homeassistant.config_entries import ConfigEntry, OptionsFlowWithConfigEntry
|
||||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
|
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.data_entry_flow import FlowResult
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
from homeassistant.helpers import config_entry_oauth2_flow
|
from homeassistant.helpers import config_entry_oauth2_flow
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.selector import (
|
from homeassistant.helpers.selector import (
|
||||||
SelectOptionDict,
|
SelectOptionDict,
|
||||||
SelectSelector,
|
SelectSelector,
|
||||||
@ -31,37 +31,6 @@ from .const import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _get_subscriptions(hass: HomeAssistant, resource: Resource) -> AsyncGenerator:
|
|
||||||
amount_of_subscriptions = 50
|
|
||||||
received_amount_of_subscriptions = 0
|
|
||||||
next_page_token = None
|
|
||||||
while received_amount_of_subscriptions < amount_of_subscriptions:
|
|
||||||
# pylint: disable=no-member
|
|
||||||
subscription_request: HttpRequest = resource.subscriptions().list(
|
|
||||||
part="snippet", mine=True, maxResults=50, pageToken=next_page_token
|
|
||||||
)
|
|
||||||
res = await hass.async_add_executor_job(subscription_request.execute)
|
|
||||||
amount_of_subscriptions = res["pageInfo"]["totalResults"]
|
|
||||||
if "nextPageToken" in res:
|
|
||||||
next_page_token = res["nextPageToken"]
|
|
||||||
for item in res["items"]:
|
|
||||||
received_amount_of_subscriptions += 1
|
|
||||||
yield item
|
|
||||||
|
|
||||||
|
|
||||||
async def get_resource(hass: HomeAssistant, token: str) -> Resource:
|
|
||||||
"""Get Youtube resource async."""
|
|
||||||
|
|
||||||
def _build_resource() -> Resource:
|
|
||||||
return build(
|
|
||||||
"youtube",
|
|
||||||
"v3",
|
|
||||||
credentials=Credentials(token),
|
|
||||||
)
|
|
||||||
|
|
||||||
return await hass.async_add_executor_job(_build_resource)
|
|
||||||
|
|
||||||
|
|
||||||
class OAuth2FlowHandler(
|
class OAuth2FlowHandler(
|
||||||
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
|
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
|
||||||
):
|
):
|
||||||
@ -73,6 +42,7 @@ class OAuth2FlowHandler(
|
|||||||
DOMAIN = DOMAIN
|
DOMAIN = DOMAIN
|
||||||
|
|
||||||
reauth_entry: ConfigEntry | None = None
|
reauth_entry: ConfigEntry | None = None
|
||||||
|
_youtube: YouTube | None = None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@callback
|
@callback
|
||||||
@ -112,25 +82,25 @@ class OAuth2FlowHandler(
|
|||||||
return self.async_show_form(step_id="reauth_confirm")
|
return self.async_show_form(step_id="reauth_confirm")
|
||||||
return await self.async_step_user()
|
return await self.async_step_user()
|
||||||
|
|
||||||
|
async def get_resource(self, token: str) -> YouTube:
|
||||||
|
"""Get Youtube resource async."""
|
||||||
|
if self._youtube is None:
|
||||||
|
self._youtube = YouTube(session=async_get_clientsession(self.hass))
|
||||||
|
await self._youtube.set_user_authentication(token, [AuthScope.READ_ONLY])
|
||||||
|
return self._youtube
|
||||||
|
|
||||||
async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult:
|
async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult:
|
||||||
"""Create an entry for the flow, or update existing entry."""
|
"""Create an entry for the flow, or update existing entry."""
|
||||||
try:
|
try:
|
||||||
service = await get_resource(self.hass, data[CONF_TOKEN][CONF_ACCESS_TOKEN])
|
youtube = await self.get_resource(data[CONF_TOKEN][CONF_ACCESS_TOKEN])
|
||||||
# pylint: disable=no-member
|
own_channel = await first(youtube.get_user_channels())
|
||||||
own_channel_request: HttpRequest = service.channels().list(
|
if own_channel is None or own_channel.snippet is None:
|
||||||
part="snippet", mine=True
|
|
||||||
)
|
|
||||||
response = await self.hass.async_add_executor_job(
|
|
||||||
own_channel_request.execute
|
|
||||||
)
|
|
||||||
if not response["items"]:
|
|
||||||
return self.async_abort(
|
return self.async_abort(
|
||||||
reason="no_channel",
|
reason="no_channel",
|
||||||
description_placeholders={"support_url": CHANNEL_CREATION_HELP_URL},
|
description_placeholders={"support_url": CHANNEL_CREATION_HELP_URL},
|
||||||
)
|
)
|
||||||
own_channel = response["items"][0]
|
except ForbiddenError as ex:
|
||||||
except HttpError as ex:
|
error = ex.args[0]
|
||||||
error = ex.reason
|
|
||||||
return self.async_abort(
|
return self.async_abort(
|
||||||
reason="access_not_configured",
|
reason="access_not_configured",
|
||||||
description_placeholders={"message": error},
|
description_placeholders={"message": error},
|
||||||
@ -138,16 +108,16 @@ class OAuth2FlowHandler(
|
|||||||
except Exception as ex: # pylint: disable=broad-except
|
except Exception as ex: # pylint: disable=broad-except
|
||||||
LOGGER.error("Unknown error occurred: %s", ex.args)
|
LOGGER.error("Unknown error occurred: %s", ex.args)
|
||||||
return self.async_abort(reason="unknown")
|
return self.async_abort(reason="unknown")
|
||||||
self._title = own_channel["snippet"]["title"]
|
self._title = own_channel.snippet.title
|
||||||
self._data = data
|
self._data = data
|
||||||
|
|
||||||
if not self.reauth_entry:
|
if not self.reauth_entry:
|
||||||
await self.async_set_unique_id(own_channel["id"])
|
await self.async_set_unique_id(own_channel.channel_id)
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
|
|
||||||
return await self.async_step_channels()
|
return await self.async_step_channels()
|
||||||
|
|
||||||
if self.reauth_entry.unique_id == own_channel["id"]:
|
if self.reauth_entry.unique_id == own_channel.channel_id:
|
||||||
self.hass.config_entries.async_update_entry(self.reauth_entry, data=data)
|
self.hass.config_entries.async_update_entry(self.reauth_entry, data=data)
|
||||||
await self.hass.config_entries.async_reload(self.reauth_entry.entry_id)
|
await self.hass.config_entries.async_reload(self.reauth_entry.entry_id)
|
||||||
return self.async_abort(reason="reauth_successful")
|
return self.async_abort(reason="reauth_successful")
|
||||||
@ -167,15 +137,13 @@ class OAuth2FlowHandler(
|
|||||||
data=self._data,
|
data=self._data,
|
||||||
options=user_input,
|
options=user_input,
|
||||||
)
|
)
|
||||||
service = await get_resource(
|
youtube = await self.get_resource(self._data[CONF_TOKEN][CONF_ACCESS_TOKEN])
|
||||||
self.hass, self._data[CONF_TOKEN][CONF_ACCESS_TOKEN]
|
|
||||||
)
|
|
||||||
selectable_channels = [
|
selectable_channels = [
|
||||||
SelectOptionDict(
|
SelectOptionDict(
|
||||||
value=subscription["snippet"]["resourceId"]["channelId"],
|
value=subscription.snippet.channel_id,
|
||||||
label=subscription["snippet"]["title"],
|
label=subscription.snippet.title,
|
||||||
)
|
)
|
||||||
async for subscription in _get_subscriptions(self.hass, service)
|
async for subscription in youtube.get_user_subscriptions()
|
||||||
]
|
]
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="channels",
|
step_id="channels",
|
||||||
@ -201,15 +169,16 @@ class YouTubeOptionsFlowHandler(OptionsFlowWithConfigEntry):
|
|||||||
title=self.config_entry.title,
|
title=self.config_entry.title,
|
||||||
data=user_input,
|
data=user_input,
|
||||||
)
|
)
|
||||||
service = await get_resource(
|
youtube = YouTube(session=async_get_clientsession(self.hass))
|
||||||
self.hass, self.config_entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN]
|
await youtube.set_user_authentication(
|
||||||
|
self.config_entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN], [AuthScope.READ_ONLY]
|
||||||
)
|
)
|
||||||
selectable_channels = [
|
selectable_channels = [
|
||||||
SelectOptionDict(
|
SelectOptionDict(
|
||||||
value=subscription["snippet"]["resourceId"]["channelId"],
|
value=subscription.snippet.channel_id,
|
||||||
label=subscription["snippet"]["title"],
|
label=subscription.snippet.title,
|
||||||
)
|
)
|
||||||
async for subscription in _get_subscriptions(self.hass, service)
|
async for subscription in youtube.get_user_subscriptions()
|
||||||
]
|
]
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="init",
|
step_id="init",
|
||||||
|
@ -4,12 +4,13 @@ from __future__ import annotations
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from googleapiclient.discovery import Resource
|
from youtubeaio.helper import first
|
||||||
from googleapiclient.http import HttpRequest
|
from youtubeaio.types import UnauthorizedError
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import ATTR_ICON, ATTR_ID
|
from homeassistant.const import ATTR_ICON, ATTR_ID
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||||
|
|
||||||
from . import AsyncConfigEntryAuth
|
from . import AsyncConfigEntryAuth
|
||||||
@ -27,16 +28,7 @@ from .const import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_upload_playlist_id(channel_id: str) -> str:
|
class YouTubeDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||||
"""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."""
|
"""A YouTube Data Update Coordinator."""
|
||||||
|
|
||||||
config_entry: ConfigEntry
|
config_entry: ConfigEntry
|
||||||
@ -52,64 +44,30 @@ class YouTubeDataUpdateCoordinator(DataUpdateCoordinator):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def _async_update_data(self) -> dict[str, Any]:
|
async def _async_update_data(self) -> dict[str, Any]:
|
||||||
service = await self._auth.get_resource()
|
youtube = await self._auth.get_resource()
|
||||||
channels = await self._get_channels(service)
|
res = {}
|
||||||
|
channel_ids = self.config_entry.options[CONF_CHANNELS]
|
||||||
return await self.hass.async_add_executor_job(
|
try:
|
||||||
self._get_channel_data, service, channels
|
async for channel in youtube.get_channels(channel_ids):
|
||||||
)
|
video = await first(
|
||||||
|
youtube.get_playlist_items(channel.upload_playlist_id, 1)
|
||||||
async def _get_channels(self, service: Resource) -> list[dict[str, Any]]:
|
|
||||||
data = []
|
|
||||||
received_channels = 0
|
|
||||||
channels = self.config_entry.options[CONF_CHANNELS]
|
|
||||||
while received_channels < len(channels):
|
|
||||||
# We're slicing the channels in chunks of 50 to avoid making the URI too long
|
|
||||||
end = min(received_channels + 50, len(channels))
|
|
||||||
channel_request: HttpRequest = service.channels().list(
|
|
||||||
part="snippet,statistics",
|
|
||||||
id=",".join(channels[received_channels:end]),
|
|
||||||
maxResults=50,
|
|
||||||
)
|
|
||||||
response: dict = await self.hass.async_add_executor_job(
|
|
||||||
channel_request.execute
|
|
||||||
)
|
|
||||||
data.extend(response["items"])
|
|
||||||
received_channels += len(response["items"])
|
|
||||||
return data
|
|
||||||
|
|
||||||
def _get_channel_data(
|
|
||||||
self, service: Resource, channels: list[dict[str, Any]]
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
data: dict[str, Any] = {}
|
|
||||||
for channel in channels:
|
|
||||||
playlist_id = get_upload_playlist_id(channel["id"])
|
|
||||||
response = (
|
|
||||||
service.playlistItems()
|
|
||||||
.list(
|
|
||||||
part="snippet,contentDetails", playlistId=playlist_id, maxResults=1
|
|
||||||
)
|
)
|
||||||
.execute()
|
latest_video = None
|
||||||
)
|
if video:
|
||||||
video = response["items"][0]
|
latest_video = {
|
||||||
data[channel["id"]] = {
|
ATTR_PUBLISHED_AT: video.snippet.added_at,
|
||||||
ATTR_ID: channel["id"],
|
ATTR_TITLE: video.snippet.title,
|
||||||
ATTR_TITLE: channel["snippet"]["title"],
|
ATTR_DESCRIPTION: video.snippet.description,
|
||||||
ATTR_ICON: channel["snippet"]["thumbnails"]["high"]["url"],
|
ATTR_THUMBNAIL: video.snippet.thumbnails.get_highest_quality().url,
|
||||||
ATTR_LATEST_VIDEO: {
|
ATTR_VIDEO_ID: video.content_details.video_id,
|
||||||
ATTR_PUBLISHED_AT: video["snippet"]["publishedAt"],
|
}
|
||||||
ATTR_TITLE: video["snippet"]["title"],
|
res[channel.channel_id] = {
|
||||||
ATTR_DESCRIPTION: video["snippet"]["description"],
|
ATTR_ID: channel.channel_id,
|
||||||
ATTR_THUMBNAIL: self._get_thumbnail(video),
|
ATTR_TITLE: channel.snippet.title,
|
||||||
ATTR_VIDEO_ID: video["contentDetails"]["videoId"],
|
ATTR_ICON: channel.snippet.thumbnails.get_highest_quality().url,
|
||||||
},
|
ATTR_LATEST_VIDEO: latest_video,
|
||||||
ATTR_SUBSCRIBER_COUNT: int(channel["statistics"]["subscriberCount"]),
|
ATTR_SUBSCRIBER_COUNT: channel.statistics.subscriber_count,
|
||||||
}
|
}
|
||||||
return data
|
except UnauthorizedError as err:
|
||||||
|
raise ConfigEntryAuthFailed from err
|
||||||
def _get_thumbnail(self, video: dict[str, Any]) -> str | None:
|
return res
|
||||||
thumbnails = video["snippet"]["thumbnails"]
|
|
||||||
for size in ("standard", "high", "medium", "default"):
|
|
||||||
if size in thumbnails:
|
|
||||||
return thumbnails[size]["url"]
|
|
||||||
return None
|
|
||||||
|
@ -9,7 +9,7 @@ from .const import ATTR_TITLE, DOMAIN, MANUFACTURER
|
|||||||
from .coordinator import YouTubeDataUpdateCoordinator
|
from .coordinator import YouTubeDataUpdateCoordinator
|
||||||
|
|
||||||
|
|
||||||
class YouTubeChannelEntity(CoordinatorEntity):
|
class YouTubeChannelEntity(CoordinatorEntity[YouTubeDataUpdateCoordinator]):
|
||||||
"""An HA implementation for YouTube entity."""
|
"""An HA implementation for YouTube entity."""
|
||||||
|
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
|
@ -7,5 +7,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/youtube",
|
"documentation": "https://www.home-assistant.io/integrations/youtube",
|
||||||
"integration_type": "service",
|
"integration_type": "service",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"requirements": ["google-api-python-client==2.71.0"]
|
"requirements": ["youtubeaio==1.1.4"]
|
||||||
}
|
}
|
||||||
|
@ -30,9 +30,10 @@ from .entity import YouTubeChannelEntity
|
|||||||
class YouTubeMixin:
|
class YouTubeMixin:
|
||||||
"""Mixin for required keys."""
|
"""Mixin for required keys."""
|
||||||
|
|
||||||
|
available_fn: Callable[[Any], bool]
|
||||||
value_fn: Callable[[Any], StateType]
|
value_fn: Callable[[Any], StateType]
|
||||||
entity_picture_fn: Callable[[Any], str | None]
|
entity_picture_fn: Callable[[Any], str | None]
|
||||||
attributes_fn: Callable[[Any], dict[str, Any]] | None
|
attributes_fn: Callable[[Any], dict[str, Any] | None] | None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -45,6 +46,7 @@ SENSOR_TYPES = [
|
|||||||
key="latest_upload",
|
key="latest_upload",
|
||||||
translation_key="latest_upload",
|
translation_key="latest_upload",
|
||||||
icon="mdi:youtube",
|
icon="mdi:youtube",
|
||||||
|
available_fn=lambda channel: channel[ATTR_LATEST_VIDEO] is not None,
|
||||||
value_fn=lambda channel: channel[ATTR_LATEST_VIDEO][ATTR_TITLE],
|
value_fn=lambda channel: channel[ATTR_LATEST_VIDEO][ATTR_TITLE],
|
||||||
entity_picture_fn=lambda channel: channel[ATTR_LATEST_VIDEO][ATTR_THUMBNAIL],
|
entity_picture_fn=lambda channel: channel[ATTR_LATEST_VIDEO][ATTR_THUMBNAIL],
|
||||||
attributes_fn=lambda channel: {
|
attributes_fn=lambda channel: {
|
||||||
@ -57,6 +59,7 @@ SENSOR_TYPES = [
|
|||||||
translation_key="subscribers",
|
translation_key="subscribers",
|
||||||
icon="mdi:youtube-subscription",
|
icon="mdi:youtube-subscription",
|
||||||
native_unit_of_measurement="subscribers",
|
native_unit_of_measurement="subscribers",
|
||||||
|
available_fn=lambda _: True,
|
||||||
value_fn=lambda channel: channel[ATTR_SUBSCRIBER_COUNT],
|
value_fn=lambda channel: channel[ATTR_SUBSCRIBER_COUNT],
|
||||||
entity_picture_fn=lambda channel: channel[ATTR_ICON],
|
entity_picture_fn=lambda channel: channel[ATTR_ICON],
|
||||||
attributes_fn=None,
|
attributes_fn=None,
|
||||||
@ -83,6 +86,13 @@ class YouTubeSensor(YouTubeChannelEntity, SensorEntity):
|
|||||||
|
|
||||||
entity_description: YouTubeSensorEntityDescription
|
entity_description: YouTubeSensorEntityDescription
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self):
|
||||||
|
"""Return if the entity is available."""
|
||||||
|
return self.entity_description.available_fn(
|
||||||
|
self.coordinator.data[self._channel_id]
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_value(self) -> StateType:
|
def native_value(self) -> StateType:
|
||||||
"""Return the value reported by the sensor."""
|
"""Return the value reported by the sensor."""
|
||||||
@ -91,6 +101,8 @@ class YouTubeSensor(YouTubeChannelEntity, SensorEntity):
|
|||||||
@property
|
@property
|
||||||
def entity_picture(self) -> str | None:
|
def entity_picture(self) -> str | None:
|
||||||
"""Return the value reported by the sensor."""
|
"""Return the value reported by the sensor."""
|
||||||
|
if not self.available:
|
||||||
|
return None
|
||||||
return self.entity_description.entity_picture_fn(
|
return self.entity_description.entity_picture_fn(
|
||||||
self.coordinator.data[self._channel_id]
|
self.coordinator.data[self._channel_id]
|
||||||
)
|
)
|
||||||
|
@ -873,7 +873,6 @@ goalzero==0.2.2
|
|||||||
goodwe==0.2.31
|
goodwe==0.2.31
|
||||||
|
|
||||||
# homeassistant.components.google_mail
|
# homeassistant.components.google_mail
|
||||||
# homeassistant.components.youtube
|
|
||||||
google-api-python-client==2.71.0
|
google-api-python-client==2.71.0
|
||||||
|
|
||||||
# homeassistant.components.google_pubsub
|
# homeassistant.components.google_pubsub
|
||||||
@ -2728,6 +2727,9 @@ yolink-api==0.3.0
|
|||||||
# homeassistant.components.youless
|
# homeassistant.components.youless
|
||||||
youless-api==1.0.1
|
youless-api==1.0.1
|
||||||
|
|
||||||
|
# homeassistant.components.youtube
|
||||||
|
youtubeaio==1.1.4
|
||||||
|
|
||||||
# homeassistant.components.media_extractor
|
# homeassistant.components.media_extractor
|
||||||
yt-dlp==2023.7.6
|
yt-dlp==2023.7.6
|
||||||
|
|
||||||
|
@ -689,7 +689,6 @@ goalzero==0.2.2
|
|||||||
goodwe==0.2.31
|
goodwe==0.2.31
|
||||||
|
|
||||||
# homeassistant.components.google_mail
|
# homeassistant.components.google_mail
|
||||||
# homeassistant.components.youtube
|
|
||||||
google-api-python-client==2.71.0
|
google-api-python-client==2.71.0
|
||||||
|
|
||||||
# homeassistant.components.google_pubsub
|
# homeassistant.components.google_pubsub
|
||||||
@ -2004,6 +2003,9 @@ yolink-api==0.3.0
|
|||||||
# homeassistant.components.youless
|
# homeassistant.components.youless
|
||||||
youless-api==1.0.1
|
youless-api==1.0.1
|
||||||
|
|
||||||
|
# homeassistant.components.youtube
|
||||||
|
youtubeaio==1.1.4
|
||||||
|
|
||||||
# homeassistant.components.zamg
|
# homeassistant.components.zamg
|
||||||
zamg==0.2.4
|
zamg==0.2.4
|
||||||
|
|
||||||
|
@ -1,78 +1,18 @@
|
|||||||
"""Tests for the YouTube integration."""
|
"""Tests for the YouTube integration."""
|
||||||
from dataclasses import dataclass
|
from collections.abc import AsyncGenerator
|
||||||
import json
|
import json
|
||||||
from typing import Any
|
|
||||||
|
from youtubeaio.models import YouTubeChannel, YouTubePlaylistItem, YouTubeSubscription
|
||||||
|
from youtubeaio.types import AuthScope
|
||||||
|
|
||||||
from tests.common import load_fixture
|
from tests.common import load_fixture
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
class MockYouTube:
|
||||||
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 __init__(self, fixture: str):
|
|
||||||
"""Initialize mock channels."""
|
|
||||||
self._fixture = fixture
|
|
||||||
|
|
||||||
def list(
|
|
||||||
self,
|
|
||||||
part: str,
|
|
||||||
id: str | None = None,
|
|
||||||
mine: bool | None = None,
|
|
||||||
maxResults: int | None = None,
|
|
||||||
) -> MockRequest:
|
|
||||||
"""Return a fixture."""
|
|
||||||
return MockRequest(fixture=self._fixture)
|
|
||||||
|
|
||||||
|
|
||||||
class MockPlaylistItems:
|
|
||||||
"""Mock object for playlist items."""
|
|
||||||
|
|
||||||
def __init__(self, fixture: str):
|
|
||||||
"""Initialize mock playlist items."""
|
|
||||||
self._fixture = fixture
|
|
||||||
|
|
||||||
def list(
|
|
||||||
self,
|
|
||||||
part: str,
|
|
||||||
playlistId: str,
|
|
||||||
maxResults: int | None = None,
|
|
||||||
) -> MockRequest:
|
|
||||||
"""Return a fixture."""
|
|
||||||
return MockRequest(fixture=self._fixture)
|
|
||||||
|
|
||||||
|
|
||||||
class MockSubscriptions:
|
|
||||||
"""Mock object for subscriptions."""
|
|
||||||
|
|
||||||
def __init__(self, fixture: str):
|
|
||||||
"""Initialize mock subscriptions."""
|
|
||||||
self._fixture = fixture
|
|
||||||
|
|
||||||
def list(
|
|
||||||
self,
|
|
||||||
part: str,
|
|
||||||
mine: bool,
|
|
||||||
maxResults: int | None = None,
|
|
||||||
pageToken: str | None = None,
|
|
||||||
) -> MockRequest:
|
|
||||||
"""Return a fixture."""
|
|
||||||
return MockRequest(fixture=self._fixture)
|
|
||||||
|
|
||||||
|
|
||||||
class MockService:
|
|
||||||
"""Service which returns mock objects."""
|
"""Service which returns mock objects."""
|
||||||
|
|
||||||
|
_authenticated = False
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
channel_fixture: str = "youtube/get_channel.json",
|
channel_fixture: str = "youtube/get_channel.json",
|
||||||
@ -84,14 +24,36 @@ class MockService:
|
|||||||
self._playlist_items_fixture = playlist_items_fixture
|
self._playlist_items_fixture = playlist_items_fixture
|
||||||
self._subscriptions_fixture = subscriptions_fixture
|
self._subscriptions_fixture = subscriptions_fixture
|
||||||
|
|
||||||
def channels(self) -> MockChannels:
|
async def set_user_authentication(
|
||||||
"""Return a mock object."""
|
self, token: str, scopes: list[AuthScope]
|
||||||
return MockChannels(self._channel_fixture)
|
) -> None:
|
||||||
|
"""Authenticate the user."""
|
||||||
|
self._authenticated = True
|
||||||
|
|
||||||
def playlistItems(self) -> MockPlaylistItems:
|
async def get_user_channels(self) -> AsyncGenerator[YouTubeChannel, None]:
|
||||||
"""Return a mock object."""
|
"""Get channels for authenticated user."""
|
||||||
return MockPlaylistItems(self._playlist_items_fixture)
|
channels = json.loads(load_fixture(self._channel_fixture))
|
||||||
|
for item in channels["items"]:
|
||||||
|
yield YouTubeChannel(**item)
|
||||||
|
|
||||||
def subscriptions(self) -> MockSubscriptions:
|
async def get_channels(
|
||||||
"""Return a mock object."""
|
self, channel_ids: list[str]
|
||||||
return MockSubscriptions(self._subscriptions_fixture)
|
) -> AsyncGenerator[YouTubeChannel, None]:
|
||||||
|
"""Get channels."""
|
||||||
|
channels = json.loads(load_fixture(self._channel_fixture))
|
||||||
|
for item in channels["items"]:
|
||||||
|
yield YouTubeChannel(**item)
|
||||||
|
|
||||||
|
async def get_playlist_items(
|
||||||
|
self, playlist_id: str, amount: int
|
||||||
|
) -> AsyncGenerator[YouTubePlaylistItem, None]:
|
||||||
|
"""Get channels."""
|
||||||
|
channels = json.loads(load_fixture(self._playlist_items_fixture))
|
||||||
|
for item in channels["items"]:
|
||||||
|
yield YouTubePlaylistItem(**item)
|
||||||
|
|
||||||
|
async def get_user_subscriptions(self) -> AsyncGenerator[YouTubeSubscription, None]:
|
||||||
|
"""Get channels for authenticated user."""
|
||||||
|
channels = json.loads(load_fixture(self._subscriptions_fixture))
|
||||||
|
for item in channels["items"]:
|
||||||
|
yield YouTubeSubscription(**item)
|
||||||
|
@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
from tests.common import MockConfigEntry
|
from tests.common import MockConfigEntry
|
||||||
from tests.components.youtube import MockService
|
from tests.components.youtube import MockYouTube
|
||||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||||
|
|
||||||
ComponentSetup = Callable[[], Awaitable[None]]
|
ComponentSetup = Callable[[], Awaitable[None]]
|
||||||
@ -106,7 +106,7 @@ async def mock_setup_integration(
|
|||||||
|
|
||||||
async def func() -> None:
|
async def func() -> None:
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.youtube.api.build", return_value=MockService()
|
"homeassistant.components.youtube.api.YouTube", return_value=MockYouTube()
|
||||||
):
|
):
|
||||||
assert await async_setup_component(hass, DOMAIN, {})
|
assert await async_setup_component(hass, DOMAIN, {})
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
@ -1,47 +1,54 @@
|
|||||||
{
|
{
|
||||||
"kind": "youtube#SubscriptionListResponse",
|
"kind": "youtube#channelListResponse",
|
||||||
"etag": "6C9iFE7CzKQqPrEoJlE0H2U27xI",
|
"etag": "en7FWhCsHOdM398MU6qRntH03cQ",
|
||||||
"nextPageToken": "CAEQAA",
|
|
||||||
"pageInfo": {
|
"pageInfo": {
|
||||||
"totalResults": 525,
|
"totalResults": 1,
|
||||||
"resultsPerPage": 1
|
"resultsPerPage": 5
|
||||||
},
|
},
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"kind": "youtube#subscription",
|
"kind": "youtube#channel",
|
||||||
"etag": "4Hr8w5f03mLak3fZID0aXypQRDg",
|
"etag": "PyFk-jpc2-v4mvG_6imAHx3y6TM",
|
||||||
"id": "l6YW-siEBx2rtBlTJ_ip10UA2t_d09UYkgtJsqbYblE",
|
"id": "UCXuqSBlHAE6Xw-yeJA0Tunw",
|
||||||
"snippet": {
|
"snippet": {
|
||||||
"publishedAt": "2015-08-09T21:37:44Z",
|
|
||||||
"title": "Linus Tech Tips",
|
"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.",
|
"description": "Linus Tech Tips is a passionate team of \"professionally curious\" experts in consumer technology and video production who aim to educate and entertain.\n",
|
||||||
"resourceId": {
|
"customUrl": "@linustechtips",
|
||||||
"kind": "youtube#channel",
|
"publishedAt": "2008-11-25T00:46:52Z",
|
||||||
"channelId": "UCXuqSBlHAE6Xw-yeJA0Tunw"
|
|
||||||
},
|
|
||||||
"channelId": "UCXuqSBlHAE6Xw-yeJA0Tunw",
|
|
||||||
"thumbnails": {
|
"thumbnails": {
|
||||||
"default": {
|
"default": {
|
||||||
"url": "https://yt3.ggpht.com/Vy6KL7EM_apxPSxF0pPy5w_c87YDTOlBQo3MADDF0Wl51kwxmt9wmRotnt2xQXwlrcyO0Xe56w=s88-c-k-c0x00ffffff-no-rj"
|
"url": "https://yt3.ggpht.com/Vy6KL7EM_apxPSxF0pPy5w_c87YDTOlBQo3MADDF0Wl51kwxmt9wmRotnt2xQXwlrcyO0Xe56w=s88-c-k-c0x00ffffff-no-rj",
|
||||||
|
"width": 88,
|
||||||
|
"height": 88
|
||||||
},
|
},
|
||||||
"medium": {
|
"medium": {
|
||||||
"url": "https://yt3.ggpht.com/Vy6KL7EM_apxPSxF0pPy5w_c87YDTOlBQo3MADDF0Wl51kwxmt9wmRotnt2xQXwlrcyO0Xe56w=s240-c-k-c0x00ffffff-no-rj"
|
"url": "https://yt3.ggpht.com/Vy6KL7EM_apxPSxF0pPy5w_c87YDTOlBQo3MADDF0Wl51kwxmt9wmRotnt2xQXwlrcyO0Xe56w=s240-c-k-c0x00ffffff-no-rj",
|
||||||
|
"width": 240,
|
||||||
|
"height": 240
|
||||||
},
|
},
|
||||||
"high": {
|
"high": {
|
||||||
"url": "https://yt3.ggpht.com/Vy6KL7EM_apxPSxF0pPy5w_c87YDTOlBQo3MADDF0Wl51kwxmt9wmRotnt2xQXwlrcyO0Xe56w=s800-c-k-c0x00ffffff-no-rj"
|
"url": "https://yt3.ggpht.com/Vy6KL7EM_apxPSxF0pPy5w_c87YDTOlBQo3MADDF0Wl51kwxmt9wmRotnt2xQXwlrcyO0Xe56w=s800-c-k-c0x00ffffff-no-rj",
|
||||||
|
"width": 800,
|
||||||
|
"height": 800
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"localized": {
|
||||||
|
"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.\n"
|
||||||
|
},
|
||||||
|
"country": "CA"
|
||||||
},
|
},
|
||||||
"contentDetails": {
|
"contentDetails": {
|
||||||
"totalItemCount": 6178,
|
"relatedPlaylists": {
|
||||||
"newItemCount": 0,
|
"likes": "",
|
||||||
"activityType": "all"
|
"uploads": "UUXuqSBlHAE6Xw-yeJA0Tunw"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
"viewCount": "214141263",
|
"viewCount": "7190986011",
|
||||||
"subscriberCount": "2290000",
|
"subscriberCount": "15600000",
|
||||||
"hiddenSubscriberCount": false,
|
"hiddenSubscriberCount": false,
|
||||||
"videoCount": "5798"
|
"videoCount": "6541"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"kind": "youtube#playlistItemListResponse",
|
||||||
|
"etag": "O0Ah8Wd5pUD2Gsv-n0A42RDRcX8",
|
||||||
|
"items": [],
|
||||||
|
"pageInfo": {
|
||||||
|
"totalResults": 0,
|
||||||
|
"resultsPerPage": 0
|
||||||
|
}
|
||||||
|
}
|
@ -1,42 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"pageInfo": {
|
|
||||||
"totalResults": 5798,
|
|
||||||
"resultsPerPage": 1
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,52 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"pageInfo": {
|
|
||||||
"totalResults": 5798,
|
|
||||||
"resultsPerPage": 1
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,47 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"pageInfo": {
|
|
||||||
"totalResults": 5798,
|
|
||||||
"resultsPerPage": 1
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,36 +0,0 @@
|
|||||||
{
|
|
||||||
"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": {},
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"pageInfo": {
|
|
||||||
"totalResults": 5798,
|
|
||||||
"resultsPerPage": 1
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,57 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"pageInfo": {
|
|
||||||
"totalResults": 5798,
|
|
||||||
"resultsPerPage": 1
|
|
||||||
}
|
|
||||||
}
|
|
@ -5,8 +5,8 @@
|
|||||||
'icon': 'https://yt3.ggpht.com/fca_HuJ99xUxflWdex0XViC3NfctBFreIl8y4i9z411asnGTWY-Ql3MeH_ybA4kNaOjY7kyA=s800-c-k-c0x00ffffff-no-rj',
|
'icon': 'https://yt3.ggpht.com/fca_HuJ99xUxflWdex0XViC3NfctBFreIl8y4i9z411asnGTWY-Ql3MeH_ybA4kNaOjY7kyA=s800-c-k-c0x00ffffff-no-rj',
|
||||||
'id': 'UC_x5XG1OV2P6uZZ5FSM9Ttw',
|
'id': 'UC_x5XG1OV2P6uZZ5FSM9Ttw',
|
||||||
'latest_video': dict({
|
'latest_video': dict({
|
||||||
'published_at': '2023-05-11T00:20:46Z',
|
'published_at': '2023-05-11T00:20:46+00:00',
|
||||||
'thumbnail': 'https://i.ytimg.com/vi/wysukDrMdqU/sddefault.jpg',
|
'thumbnail': 'https://i.ytimg.com/vi/wysukDrMdqU/maxresdefault.jpg',
|
||||||
'title': "What's new in Google Home in less than 1 minute",
|
'title': "What's new in Google Home in less than 1 minute",
|
||||||
'video_id': 'wysukDrMdqU',
|
'video_id': 'wysukDrMdqU',
|
||||||
}),
|
}),
|
||||||
|
@ -2,10 +2,10 @@
|
|||||||
# name: test_sensor
|
# name: test_sensor
|
||||||
StateSnapshot({
|
StateSnapshot({
|
||||||
'attributes': ReadOnlyDict({
|
'attributes': ReadOnlyDict({
|
||||||
'entity_picture': 'https://i.ytimg.com/vi/wysukDrMdqU/sddefault.jpg',
|
'entity_picture': 'https://i.ytimg.com/vi/wysukDrMdqU/maxresdefault.jpg',
|
||||||
'friendly_name': 'Google for Developers Latest upload',
|
'friendly_name': 'Google for Developers Latest upload',
|
||||||
'icon': 'mdi:youtube',
|
'icon': 'mdi:youtube',
|
||||||
'published_at': '2023-05-11T00:20:46Z',
|
'published_at': datetime.datetime(2023, 5, 11, 0, 20, 46, tzinfo=datetime.timezone.utc),
|
||||||
'video_id': 'wysukDrMdqU',
|
'video_id': 'wysukDrMdqU',
|
||||||
}),
|
}),
|
||||||
'context': <ANY>,
|
'context': <ANY>,
|
||||||
@ -30,3 +30,31 @@
|
|||||||
'state': '2290000',
|
'state': '2290000',
|
||||||
})
|
})
|
||||||
# ---
|
# ---
|
||||||
|
# name: test_sensor_without_uploaded_video
|
||||||
|
StateSnapshot({
|
||||||
|
'attributes': ReadOnlyDict({
|
||||||
|
'friendly_name': 'Google for Developers Latest upload',
|
||||||
|
'icon': 'mdi:youtube',
|
||||||
|
}),
|
||||||
|
'context': <ANY>,
|
||||||
|
'entity_id': 'sensor.google_for_developers_latest_upload',
|
||||||
|
'last_changed': <ANY>,
|
||||||
|
'last_updated': <ANY>,
|
||||||
|
'state': 'unavailable',
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
# name: test_sensor_without_uploaded_video.1
|
||||||
|
StateSnapshot({
|
||||||
|
'attributes': ReadOnlyDict({
|
||||||
|
'entity_picture': 'https://yt3.ggpht.com/fca_HuJ99xUxflWdex0XViC3NfctBFreIl8y4i9z411asnGTWY-Ql3MeH_ybA4kNaOjY7kyA=s800-c-k-c0x00ffffff-no-rj',
|
||||||
|
'friendly_name': 'Google for Developers Subscribers',
|
||||||
|
'icon': 'mdi:youtube-subscription',
|
||||||
|
'unit_of_measurement': 'subscribers',
|
||||||
|
}),
|
||||||
|
'context': <ANY>,
|
||||||
|
'entity_id': 'sensor.google_for_developers_subscribers',
|
||||||
|
'last_changed': <ANY>,
|
||||||
|
'last_updated': <ANY>,
|
||||||
|
'state': '2290000',
|
||||||
|
})
|
||||||
|
# ---
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
"""Test the YouTube config flow."""
|
"""Test the YouTube config flow."""
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from googleapiclient.errors import HttpError
|
|
||||||
from httplib2 import Response
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from youtubeaio.types import ForbiddenError
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.components.youtube.const import CONF_CHANNELS, DOMAIN
|
from homeassistant.components.youtube.const import CONF_CHANNELS, DOMAIN
|
||||||
@ -11,7 +10,7 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.data_entry_flow import FlowResultType
|
from homeassistant.data_entry_flow import FlowResultType
|
||||||
from homeassistant.helpers import config_entry_oauth2_flow
|
from homeassistant.helpers import config_entry_oauth2_flow
|
||||||
|
|
||||||
from . import MockService
|
from . import MockYouTube
|
||||||
from .conftest import (
|
from .conftest import (
|
||||||
CLIENT_ID,
|
CLIENT_ID,
|
||||||
GOOGLE_AUTH_URI,
|
GOOGLE_AUTH_URI,
|
||||||
@ -21,7 +20,7 @@ from .conftest import (
|
|||||||
ComponentSetup,
|
ComponentSetup,
|
||||||
)
|
)
|
||||||
|
|
||||||
from tests.common import MockConfigEntry, load_fixture
|
from tests.common import MockConfigEntry
|
||||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||||
from tests.typing import ClientSessionGenerator
|
from tests.typing import ClientSessionGenerator
|
||||||
|
|
||||||
@ -58,9 +57,8 @@ async def test_full_flow(
|
|||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.youtube.async_setup_entry", return_value=True
|
"homeassistant.components.youtube.async_setup_entry", return_value=True
|
||||||
) as mock_setup, patch(
|
) as mock_setup, patch(
|
||||||
"homeassistant.components.youtube.api.build", return_value=MockService()
|
"homeassistant.components.youtube.config_flow.YouTube",
|
||||||
), patch(
|
return_value=MockYouTube(),
|
||||||
"homeassistant.components.youtube.config_flow.build", return_value=MockService()
|
|
||||||
):
|
):
|
||||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||||
assert result["type"] == FlowResultType.FORM
|
assert result["type"] == FlowResultType.FORM
|
||||||
@ -112,11 +110,11 @@ async def test_flow_abort_without_channel(
|
|||||||
assert resp.status == 200
|
assert resp.status == 200
|
||||||
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
||||||
|
|
||||||
service = MockService(channel_fixture="youtube/get_no_channel.json")
|
service = MockYouTube(channel_fixture="youtube/get_no_channel.json")
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.youtube.async_setup_entry", return_value=True
|
"homeassistant.components.youtube.async_setup_entry", return_value=True
|
||||||
), patch("homeassistant.components.youtube.api.build", return_value=service), patch(
|
), patch(
|
||||||
"homeassistant.components.youtube.config_flow.build", return_value=service
|
"homeassistant.components.youtube.config_flow.YouTube", return_value=service
|
||||||
):
|
):
|
||||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||||
assert result["type"] == FlowResultType.ABORT
|
assert result["type"] == FlowResultType.ABORT
|
||||||
@ -153,41 +151,29 @@ async def test_flow_http_error(
|
|||||||
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.youtube.config_flow.build",
|
"homeassistant.components.youtube.config_flow.YouTube.get_user_channels",
|
||||||
side_effect=HttpError(
|
side_effect=ForbiddenError(
|
||||||
Response(
|
"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."
|
||||||
{
|
|
||||||
"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"])
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||||
assert result["type"] == FlowResultType.ABORT
|
assert result["type"] == FlowResultType.ABORT
|
||||||
assert result["reason"] == "access_not_configured"
|
assert result["reason"] == "access_not_configured"
|
||||||
assert (
|
assert result["description_placeholders"]["message"] == (
|
||||||
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."
|
||||||
== "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."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("fixture", "abort_reason", "placeholders", "calls", "access_token"),
|
("fixture", "abort_reason", "placeholders", "calls", "access_token"),
|
||||||
[
|
[
|
||||||
("get_channel", "reauth_successful", None, 1, "updated-access-token"),
|
(
|
||||||
|
"get_channel",
|
||||||
|
"reauth_successful",
|
||||||
|
None,
|
||||||
|
1,
|
||||||
|
"updated-access-token",
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"get_channel_2",
|
"get_channel_2",
|
||||||
"wrong_account",
|
"wrong_account",
|
||||||
@ -254,14 +240,12 @@ async def test_reauth(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
youtube = MockYouTube(channel_fixture=f"youtube/{fixture}.json")
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.youtube.async_setup_entry", return_value=True
|
"homeassistant.components.youtube.async_setup_entry", return_value=True
|
||||||
) as mock_setup, patch(
|
) as mock_setup, patch(
|
||||||
"httplib2.Http.request",
|
"homeassistant.components.youtube.config_flow.YouTube",
|
||||||
return_value=(
|
return_value=youtube,
|
||||||
Response({}),
|
|
||||||
bytes(load_fixture(f"youtube/{fixture}.json"), encoding="UTF-8"),
|
|
||||||
),
|
|
||||||
):
|
):
|
||||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||||
|
|
||||||
@ -309,7 +293,7 @@ async def test_flow_exception(
|
|||||||
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.youtube.config_flow.build", side_effect=Exception
|
"homeassistant.components.youtube.config_flow.YouTube", side_effect=Exception
|
||||||
):
|
):
|
||||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||||
assert result["type"] == FlowResultType.ABORT
|
assert result["type"] == FlowResultType.ABORT
|
||||||
@ -322,7 +306,8 @@ async def test_options_flow(
|
|||||||
"""Test the full options flow."""
|
"""Test the full options flow."""
|
||||||
await setup_integration()
|
await setup_integration()
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.youtube.config_flow.build", return_value=MockService()
|
"homeassistant.components.youtube.config_flow.YouTube",
|
||||||
|
return_value=MockYouTube(),
|
||||||
):
|
):
|
||||||
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||||
|
@ -2,17 +2,17 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from google.auth.exceptions import RefreshError
|
|
||||||
import pytest
|
|
||||||
from syrupy import SnapshotAssertion
|
from syrupy import SnapshotAssertion
|
||||||
|
from youtubeaio.types import UnauthorizedError
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.components.youtube import DOMAIN
|
from homeassistant.components.youtube.const import DOMAIN
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.setup import async_setup_component
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from . import MockService
|
from . import MockYouTube
|
||||||
from .conftest import TOKEN, ComponentSetup
|
from .conftest import ComponentSetup
|
||||||
|
|
||||||
from tests.common import async_fire_time_changed
|
from tests.common import async_fire_time_changed
|
||||||
|
|
||||||
@ -30,6 +30,29 @@ async def test_sensor(
|
|||||||
assert state == snapshot
|
assert state == snapshot
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sensor_without_uploaded_video(
|
||||||
|
hass: HomeAssistant, snapshot: SnapshotAssertion, setup_integration: ComponentSetup
|
||||||
|
) -> None:
|
||||||
|
"""Test sensor when there is no video on the channel."""
|
||||||
|
await setup_integration()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.youtube.api.AsyncConfigEntryAuth.get_resource",
|
||||||
|
return_value=MockYouTube(
|
||||||
|
playlist_items_fixture="youtube/get_no_playlist_items.json"
|
||||||
|
),
|
||||||
|
):
|
||||||
|
future = dt_util.utcnow() + timedelta(minutes=15)
|
||||||
|
async_fire_time_changed(hass, future)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
state = hass.states.get("sensor.google_for_developers_latest_upload")
|
||||||
|
assert state == snapshot
|
||||||
|
|
||||||
|
state = hass.states.get("sensor.google_for_developers_subscribers")
|
||||||
|
assert state == snapshot
|
||||||
|
|
||||||
|
|
||||||
async def test_sensor_updating(
|
async def test_sensor_updating(
|
||||||
hass: HomeAssistant, setup_integration: ComponentSetup
|
hass: HomeAssistant, setup_integration: ComponentSetup
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -41,8 +64,8 @@ async def test_sensor_updating(
|
|||||||
assert state.attributes["video_id"] == "wysukDrMdqU"
|
assert state.attributes["video_id"] == "wysukDrMdqU"
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.youtube.api.build",
|
"homeassistant.components.youtube.api.AsyncConfigEntryAuth.get_resource",
|
||||||
return_value=MockService(
|
return_value=MockYouTube(
|
||||||
playlist_items_fixture="youtube/get_playlist_items_2.json"
|
playlist_items_fixture="youtube/get_playlist_items_2.json"
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
@ -55,7 +78,7 @@ async def test_sensor_updating(
|
|||||||
assert state.state == "Google I/O 2023 Developer Keynote in 5 minutes"
|
assert state.state == "Google I/O 2023 Developer Keynote in 5 minutes"
|
||||||
assert (
|
assert (
|
||||||
state.attributes["entity_picture"]
|
state.attributes["entity_picture"]
|
||||||
== "https://i.ytimg.com/vi/hleLlcHwQLM/sddefault.jpg"
|
== "https://i.ytimg.com/vi/hleLlcHwQLM/maxresdefault.jpg"
|
||||||
)
|
)
|
||||||
assert state.attributes["video_id"] == "hleLlcHwQLM"
|
assert state.attributes["video_id"] == "hleLlcHwQLM"
|
||||||
|
|
||||||
@ -64,9 +87,11 @@ async def test_sensor_reauth_trigger(
|
|||||||
hass: HomeAssistant, setup_integration: ComponentSetup
|
hass: HomeAssistant, setup_integration: ComponentSetup
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test reauth is triggered after a refresh error."""
|
"""Test reauth is triggered after a refresh error."""
|
||||||
await setup_integration()
|
with patch(
|
||||||
|
"youtubeaio.youtube.YouTube.get_channels", side_effect=UnauthorizedError
|
||||||
with patch(TOKEN, side_effect=RefreshError):
|
):
|
||||||
|
assert await async_setup_component(hass, DOMAIN, {})
|
||||||
|
await hass.async_block_till_done()
|
||||||
future = dt_util.utcnow() + timedelta(minutes=15)
|
future = dt_util.utcnow() + timedelta(minutes=15)
|
||||||
async_fire_time_changed(hass, future)
|
async_fire_time_changed(hass, future)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
@ -78,38 +103,3 @@ async def test_sensor_reauth_trigger(
|
|||||||
assert flow["step_id"] == "reauth_confirm"
|
assert flow["step_id"] == "reauth_confirm"
|
||||||
assert flow["handler"] == DOMAIN
|
assert flow["handler"] == DOMAIN
|
||||||
assert flow["context"]["source"] == config_entries.SOURCE_REAUTH
|
assert flow["context"]["source"] == config_entries.SOURCE_REAUTH
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("fixture", "url", "has_entity_picture"),
|
|
||||||
[
|
|
||||||
("standard", "https://i.ytimg.com/vi/wysukDrMdqU/sddefault.jpg", True),
|
|
||||||
("high", "https://i.ytimg.com/vi/wysukDrMdqU/hqdefault.jpg", True),
|
|
||||||
("medium", "https://i.ytimg.com/vi/wysukDrMdqU/mqdefault.jpg", True),
|
|
||||||
("default", "https://i.ytimg.com/vi/wysukDrMdqU/default.jpg", True),
|
|
||||||
("none", None, False),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
async def test_thumbnail(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
setup_integration: ComponentSetup,
|
|
||||||
fixture: str,
|
|
||||||
url: str | None,
|
|
||||||
has_entity_picture: bool,
|
|
||||||
) -> None:
|
|
||||||
"""Test if right thumbnail is selected."""
|
|
||||||
await setup_integration()
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"homeassistant.components.youtube.api.build",
|
|
||||||
return_value=MockService(
|
|
||||||
playlist_items_fixture=f"youtube/thumbnail/{fixture}.json"
|
|
||||||
),
|
|
||||||
):
|
|
||||||
future = dt_util.utcnow() + timedelta(minutes=15)
|
|
||||||
async_fire_time_changed(hass, future)
|
|
||||||
await hass.async_block_till_done()
|
|
||||||
state = hass.states.get("sensor.google_for_developers_latest_upload")
|
|
||||||
assert state
|
|
||||||
assert ("entity_picture" in state.attributes) is has_entity_picture
|
|
||||||
assert state.attributes.get("entity_picture") == url
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user