Implement YouTube async library (#97072)

This commit is contained in:
Joost Lekkerkerker 2023-07-25 10:18:20 +02:00 committed by GitHub
parent 714a04d603
commit 04f6d1848b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 270 additions and 587 deletions

View File

@ -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,
)

View File

@ -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",

View File

@ -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

View File

@ -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

View File

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

View File

@ -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]
) )

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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()

View File

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

View File

@ -0,0 +1,9 @@
{
"kind": "youtube#playlistItemListResponse",
"etag": "O0Ah8Wd5pUD2Gsv-n0A42RDRcX8",
"items": [],
"pageInfo": {
"totalResults": 0,
"resultsPerPage": 0
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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',
}), }),

View File

@ -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',
})
# ---

View File

@ -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)

View File

@ -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