From 04f6d1848bed237d020eede02be63273b43242a8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 25 Jul 2023 10:18:20 +0200 Subject: [PATCH] Implement YouTube async library (#97072) --- homeassistant/components/youtube/api.py | 31 ++--- .../components/youtube/config_flow.py | 95 +++++---------- .../components/youtube/coordinator.py | 102 +++++----------- homeassistant/components/youtube/entity.py | 2 +- .../components/youtube/manifest.json | 2 +- homeassistant/components/youtube/sensor.py | 14 ++- requirements_all.txt | 4 +- requirements_test_all.txt | 4 +- tests/components/youtube/__init__.py | 114 ++++++------------ tests/components/youtube/conftest.py | 4 +- .../youtube/fixtures/get_channel_2.json | 57 +++++---- .../fixtures/get_no_playlist_items.json | 9 ++ .../youtube/fixtures/thumbnail/default.json | 42 ------- .../youtube/fixtures/thumbnail/high.json | 52 -------- .../youtube/fixtures/thumbnail/medium.json | 47 -------- .../youtube/fixtures/thumbnail/none.json | 36 ------ .../youtube/fixtures/thumbnail/standard.json | 57 --------- .../youtube/snapshots/test_diagnostics.ambr | 4 +- .../youtube/snapshots/test_sensor.ambr | 32 ++++- tests/components/youtube/test_config_flow.py | 67 ++++------ tests/components/youtube/test_sensor.py | 82 ++++++------- 21 files changed, 270 insertions(+), 587 deletions(-) create mode 100644 tests/components/youtube/fixtures/get_no_playlist_items.json delete mode 100644 tests/components/youtube/fixtures/thumbnail/default.json delete mode 100644 tests/components/youtube/fixtures/thumbnail/high.json delete mode 100644 tests/components/youtube/fixtures/thumbnail/medium.json delete mode 100644 tests/components/youtube/fixtures/thumbnail/none.json delete mode 100644 tests/components/youtube/fixtures/thumbnail/standard.json diff --git a/homeassistant/components/youtube/api.py b/homeassistant/components/youtube/api.py index 64abf1a6753..f8a9008d9b3 100644 --- a/homeassistant/components/youtube/api.py +++ b/homeassistant/components/youtube/api.py @@ -1,16 +1,18 @@ """API for YouTube bound to Home Assistant OAuth.""" -from google.auth.exceptions import RefreshError -from google.oauth2.credentials import Credentials -from googleapiclient.discovery import Resource, build +from youtubeaio.types import AuthScope +from youtubeaio.youtube import YouTube from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.aiohttp_client import async_get_clientsession class AsyncConfigEntryAuth: """Provide Google authentication tied to an OAuth2 based config entry.""" + youtube: YouTube | None = None + def __init__( self, hass: HomeAssistant, @@ -30,19 +32,10 @@ class AsyncConfigEntryAuth: await self.oauth_session.async_ensure_token_valid() return self.access_token - async def get_resource(self) -> Resource: - """Create executor job to get current resource.""" - try: - credentials = Credentials(await self.check_and_refresh_token()) - except RefreshError as ex: - self.oauth_session.config_entry.async_start_reauth(self.oauth_session.hass) - raise ex - 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, - ) + async def get_resource(self) -> YouTube: + """Create resource.""" + token = await self.check_and_refresh_token() + 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 diff --git a/homeassistant/components/youtube/config_flow.py b/homeassistant/components/youtube/config_flow.py index fa3bc6c8237..50dee14d61a 100644 --- a/homeassistant/components/youtube/config_flow.py +++ b/homeassistant/components/youtube/config_flow.py @@ -1,21 +1,21 @@ """Config flow for YouTube integration.""" from __future__ import annotations -from collections.abc import AsyncGenerator, Mapping +from collections.abc import Mapping import logging from typing import Any -from google.oauth2.credentials import Credentials -from googleapiclient.discovery import Resource, build -from googleapiclient.errors import HttpError -from googleapiclient.http import HttpRequest import voluptuous as vol +from youtubeaio.helper import first +from youtubeaio.types import AuthScope, ForbiddenError +from youtubeaio.youtube import YouTube from homeassistant.config_entries import ConfigEntry, OptionsFlowWithConfigEntry 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.helpers import config_entry_oauth2_flow +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( SelectOptionDict, 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( config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN ): @@ -73,6 +42,7 @@ class OAuth2FlowHandler( DOMAIN = DOMAIN reauth_entry: ConfigEntry | None = None + _youtube: YouTube | None = None @staticmethod @callback @@ -112,25 +82,25 @@ class OAuth2FlowHandler( return self.async_show_form(step_id="reauth_confirm") 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: """Create an entry for the flow, or update existing entry.""" try: - service = await get_resource(self.hass, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) - # pylint: disable=no-member - own_channel_request: HttpRequest = service.channels().list( - part="snippet", mine=True - ) - response = await self.hass.async_add_executor_job( - own_channel_request.execute - ) - if not response["items"]: + youtube = await self.get_resource(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) + own_channel = await first(youtube.get_user_channels()) + if own_channel is None or own_channel.snippet is None: return self.async_abort( reason="no_channel", description_placeholders={"support_url": CHANNEL_CREATION_HELP_URL}, ) - own_channel = response["items"][0] - except HttpError as ex: - error = ex.reason + except ForbiddenError as ex: + error = ex.args[0] return self.async_abort( reason="access_not_configured", description_placeholders={"message": error}, @@ -138,16 +108,16 @@ class OAuth2FlowHandler( except Exception as ex: # pylint: disable=broad-except LOGGER.error("Unknown error occurred: %s", ex.args) return self.async_abort(reason="unknown") - self._title = own_channel["snippet"]["title"] + self._title = own_channel.snippet.title self._data = data 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() 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) await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) return self.async_abort(reason="reauth_successful") @@ -167,15 +137,13 @@ class OAuth2FlowHandler( data=self._data, options=user_input, ) - service = await get_resource( - self.hass, self._data[CONF_TOKEN][CONF_ACCESS_TOKEN] - ) + youtube = await self.get_resource(self._data[CONF_TOKEN][CONF_ACCESS_TOKEN]) selectable_channels = [ SelectOptionDict( - value=subscription["snippet"]["resourceId"]["channelId"], - label=subscription["snippet"]["title"], + value=subscription.snippet.channel_id, + 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( step_id="channels", @@ -201,15 +169,16 @@ class YouTubeOptionsFlowHandler(OptionsFlowWithConfigEntry): title=self.config_entry.title, data=user_input, ) - service = await get_resource( - self.hass, self.config_entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN] + youtube = YouTube(session=async_get_clientsession(self.hass)) + await youtube.set_user_authentication( + self.config_entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN], [AuthScope.READ_ONLY] ) selectable_channels = [ SelectOptionDict( - value=subscription["snippet"]["resourceId"]["channelId"], - label=subscription["snippet"]["title"], + value=subscription.snippet.channel_id, + 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( step_id="init", diff --git a/homeassistant/components/youtube/coordinator.py b/homeassistant/components/youtube/coordinator.py index 72629544895..cb9d1e8214e 100644 --- a/homeassistant/components/youtube/coordinator.py +++ b/homeassistant/components/youtube/coordinator.py @@ -4,12 +4,13 @@ from __future__ import annotations from datetime import timedelta from typing import Any -from googleapiclient.discovery import Resource -from googleapiclient.http import HttpRequest +from youtubeaio.helper import first +from youtubeaio.types import UnauthorizedError from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ICON, ATTR_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import AsyncConfigEntryAuth @@ -27,16 +28,7 @@ from .const import ( ) -def get_upload_playlist_id(channel_id: str) -> str: - """Return the playlist id with the uploads of the channel. - - Replacing the UC in the channel id (UCxxxxxxxxxxxx) with UU is - the way to do it without extra request (UUxxxxxxxxxxxx). - """ - return channel_id.replace("UC", "UU", 1) - - -class YouTubeDataUpdateCoordinator(DataUpdateCoordinator): +class YouTubeDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """A YouTube Data Update Coordinator.""" config_entry: ConfigEntry @@ -52,64 +44,30 @@ class YouTubeDataUpdateCoordinator(DataUpdateCoordinator): ) async def _async_update_data(self) -> dict[str, Any]: - service = await self._auth.get_resource() - channels = await self._get_channels(service) - - return await self.hass.async_add_executor_job( - self._get_channel_data, service, channels - ) - - 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 + youtube = await self._auth.get_resource() + res = {} + channel_ids = self.config_entry.options[CONF_CHANNELS] + try: + async for channel in youtube.get_channels(channel_ids): + video = await first( + youtube.get_playlist_items(channel.upload_playlist_id, 1) ) - .execute() - ) - video = response["items"][0] - data[channel["id"]] = { - ATTR_ID: channel["id"], - ATTR_TITLE: channel["snippet"]["title"], - ATTR_ICON: channel["snippet"]["thumbnails"]["high"]["url"], - ATTR_LATEST_VIDEO: { - ATTR_PUBLISHED_AT: video["snippet"]["publishedAt"], - ATTR_TITLE: video["snippet"]["title"], - ATTR_DESCRIPTION: video["snippet"]["description"], - ATTR_THUMBNAIL: self._get_thumbnail(video), - ATTR_VIDEO_ID: video["contentDetails"]["videoId"], - }, - ATTR_SUBSCRIBER_COUNT: int(channel["statistics"]["subscriberCount"]), - } - return data - - def _get_thumbnail(self, video: dict[str, Any]) -> str | None: - thumbnails = video["snippet"]["thumbnails"] - for size in ("standard", "high", "medium", "default"): - if size in thumbnails: - return thumbnails[size]["url"] - return None + latest_video = None + if video: + latest_video = { + ATTR_PUBLISHED_AT: video.snippet.added_at, + ATTR_TITLE: video.snippet.title, + ATTR_DESCRIPTION: video.snippet.description, + ATTR_THUMBNAIL: video.snippet.thumbnails.get_highest_quality().url, + ATTR_VIDEO_ID: video.content_details.video_id, + } + res[channel.channel_id] = { + ATTR_ID: channel.channel_id, + ATTR_TITLE: channel.snippet.title, + ATTR_ICON: channel.snippet.thumbnails.get_highest_quality().url, + ATTR_LATEST_VIDEO: latest_video, + ATTR_SUBSCRIBER_COUNT: channel.statistics.subscriber_count, + } + except UnauthorizedError as err: + raise ConfigEntryAuthFailed from err + return res diff --git a/homeassistant/components/youtube/entity.py b/homeassistant/components/youtube/entity.py index 2f9238dec26..46deaf40450 100644 --- a/homeassistant/components/youtube/entity.py +++ b/homeassistant/components/youtube/entity.py @@ -9,7 +9,7 @@ from .const import ATTR_TITLE, DOMAIN, MANUFACTURER from .coordinator import YouTubeDataUpdateCoordinator -class YouTubeChannelEntity(CoordinatorEntity): +class YouTubeChannelEntity(CoordinatorEntity[YouTubeDataUpdateCoordinator]): """An HA implementation for YouTube entity.""" _attr_has_entity_name = True diff --git a/homeassistant/components/youtube/manifest.json b/homeassistant/components/youtube/manifest.json index fbc02bda006..b37d242fe52 100644 --- a/homeassistant/components/youtube/manifest.json +++ b/homeassistant/components/youtube/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/youtube", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["google-api-python-client==2.71.0"] + "requirements": ["youtubeaio==1.1.4"] } diff --git a/homeassistant/components/youtube/sensor.py b/homeassistant/components/youtube/sensor.py index b5d3fc79b39..a63b8fb0c0b 100644 --- a/homeassistant/components/youtube/sensor.py +++ b/homeassistant/components/youtube/sensor.py @@ -30,9 +30,10 @@ from .entity import YouTubeChannelEntity class YouTubeMixin: """Mixin for required keys.""" + available_fn: Callable[[Any], bool] value_fn: Callable[[Any], StateType] 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 @@ -45,6 +46,7 @@ SENSOR_TYPES = [ key="latest_upload", translation_key="latest_upload", icon="mdi:youtube", + available_fn=lambda channel: channel[ATTR_LATEST_VIDEO] is not None, value_fn=lambda channel: channel[ATTR_LATEST_VIDEO][ATTR_TITLE], entity_picture_fn=lambda channel: channel[ATTR_LATEST_VIDEO][ATTR_THUMBNAIL], attributes_fn=lambda channel: { @@ -57,6 +59,7 @@ SENSOR_TYPES = [ translation_key="subscribers", icon="mdi:youtube-subscription", native_unit_of_measurement="subscribers", + available_fn=lambda _: True, value_fn=lambda channel: channel[ATTR_SUBSCRIBER_COUNT], entity_picture_fn=lambda channel: channel[ATTR_ICON], attributes_fn=None, @@ -83,6 +86,13 @@ class YouTubeSensor(YouTubeChannelEntity, SensorEntity): 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 def native_value(self) -> StateType: """Return the value reported by the sensor.""" @@ -91,6 +101,8 @@ class YouTubeSensor(YouTubeChannelEntity, SensorEntity): @property def entity_picture(self) -> str | None: """Return the value reported by the sensor.""" + if not self.available: + return None return self.entity_description.entity_picture_fn( self.coordinator.data[self._channel_id] ) diff --git a/requirements_all.txt b/requirements_all.txt index cde697360aa..d4836ea1522 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -873,7 +873,6 @@ goalzero==0.2.2 goodwe==0.2.31 # homeassistant.components.google_mail -# homeassistant.components.youtube google-api-python-client==2.71.0 # homeassistant.components.google_pubsub @@ -2728,6 +2727,9 @@ yolink-api==0.3.0 # homeassistant.components.youless youless-api==1.0.1 +# homeassistant.components.youtube +youtubeaio==1.1.4 + # homeassistant.components.media_extractor yt-dlp==2023.7.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 70e07baf8d6..6d3a9d3819f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -689,7 +689,6 @@ goalzero==0.2.2 goodwe==0.2.31 # homeassistant.components.google_mail -# homeassistant.components.youtube google-api-python-client==2.71.0 # homeassistant.components.google_pubsub @@ -2004,6 +2003,9 @@ yolink-api==0.3.0 # homeassistant.components.youless youless-api==1.0.1 +# homeassistant.components.youtube +youtubeaio==1.1.4 + # homeassistant.components.zamg zamg==0.2.4 diff --git a/tests/components/youtube/__init__.py b/tests/components/youtube/__init__.py index 15a43d7a62f..3c46ff92661 100644 --- a/tests/components/youtube/__init__.py +++ b/tests/components/youtube/__init__.py @@ -1,78 +1,18 @@ """Tests for the YouTube integration.""" -from dataclasses import dataclass +from collections.abc import AsyncGenerator import json -from typing import Any + +from youtubeaio.models import YouTubeChannel, YouTubePlaylistItem, YouTubeSubscription +from youtubeaio.types import AuthScope from tests.common import load_fixture -@dataclass -class MockRequest: - """Mock object for a request.""" - - fixture: str - - def execute(self) -> dict[str, Any]: - """Return a fixture.""" - return json.loads(load_fixture(self.fixture)) - - -class MockChannels: - """Mock object for channels.""" - - def __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: +class MockYouTube: """Service which returns mock objects.""" + _authenticated = False + def __init__( self, channel_fixture: str = "youtube/get_channel.json", @@ -84,14 +24,36 @@ class MockService: self._playlist_items_fixture = playlist_items_fixture self._subscriptions_fixture = subscriptions_fixture - def channels(self) -> MockChannels: - """Return a mock object.""" - return MockChannels(self._channel_fixture) + async def set_user_authentication( + self, token: str, scopes: list[AuthScope] + ) -> None: + """Authenticate the user.""" + self._authenticated = True - def playlistItems(self) -> MockPlaylistItems: - """Return a mock object.""" - return MockPlaylistItems(self._playlist_items_fixture) + async def get_user_channels(self) -> AsyncGenerator[YouTubeChannel, None]: + """Get channels for authenticated user.""" + channels = json.loads(load_fixture(self._channel_fixture)) + for item in channels["items"]: + yield YouTubeChannel(**item) - def subscriptions(self) -> MockSubscriptions: - """Return a mock object.""" - return MockSubscriptions(self._subscriptions_fixture) + async def get_channels( + self, channel_ids: list[str] + ) -> 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) diff --git a/tests/components/youtube/conftest.py b/tests/components/youtube/conftest.py index d87a3c07679..a8a333190ee 100644 --- a/tests/components/youtube/conftest.py +++ b/tests/components/youtube/conftest.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -from tests.components.youtube import MockService +from tests.components.youtube import MockYouTube from tests.test_util.aiohttp import AiohttpClientMocker ComponentSetup = Callable[[], Awaitable[None]] @@ -106,7 +106,7 @@ async def mock_setup_integration( async def func() -> None: 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, {}) await hass.async_block_till_done() diff --git a/tests/components/youtube/fixtures/get_channel_2.json b/tests/components/youtube/fixtures/get_channel_2.json index 24e71ad91ab..f2757b169bb 100644 --- a/tests/components/youtube/fixtures/get_channel_2.json +++ b/tests/components/youtube/fixtures/get_channel_2.json @@ -1,47 +1,54 @@ { - "kind": "youtube#SubscriptionListResponse", - "etag": "6C9iFE7CzKQqPrEoJlE0H2U27xI", - "nextPageToken": "CAEQAA", + "kind": "youtube#channelListResponse", + "etag": "en7FWhCsHOdM398MU6qRntH03cQ", "pageInfo": { - "totalResults": 525, - "resultsPerPage": 1 + "totalResults": 1, + "resultsPerPage": 5 }, "items": [ { - "kind": "youtube#subscription", - "etag": "4Hr8w5f03mLak3fZID0aXypQRDg", - "id": "l6YW-siEBx2rtBlTJ_ip10UA2t_d09UYkgtJsqbYblE", + "kind": "youtube#channel", + "etag": "PyFk-jpc2-v4mvG_6imAHx3y6TM", + "id": "UCXuqSBlHAE6Xw-yeJA0Tunw", "snippet": { - "publishedAt": "2015-08-09T21:37:44Z", "title": "Linus Tech Tips", - "description": "Linus Tech Tips is a passionate team of \"professionally curious\" experts in consumer technology and video production who aim to educate and entertain.", - "resourceId": { - "kind": "youtube#channel", - "channelId": "UCXuqSBlHAE6Xw-yeJA0Tunw" - }, - "channelId": "UCXuqSBlHAE6Xw-yeJA0Tunw", + "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", + "customUrl": "@linustechtips", + "publishedAt": "2008-11-25T00:46:52Z", "thumbnails": { "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": { - "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": { - "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": { - "totalItemCount": 6178, - "newItemCount": 0, - "activityType": "all" + "relatedPlaylists": { + "likes": "", + "uploads": "UUXuqSBlHAE6Xw-yeJA0Tunw" + } }, "statistics": { - "viewCount": "214141263", - "subscriberCount": "2290000", + "viewCount": "7190986011", + "subscriberCount": "15600000", "hiddenSubscriberCount": false, - "videoCount": "5798" + "videoCount": "6541" } } ] diff --git a/tests/components/youtube/fixtures/get_no_playlist_items.json b/tests/components/youtube/fixtures/get_no_playlist_items.json new file mode 100644 index 00000000000..98b9a11737e --- /dev/null +++ b/tests/components/youtube/fixtures/get_no_playlist_items.json @@ -0,0 +1,9 @@ +{ + "kind": "youtube#playlistItemListResponse", + "etag": "O0Ah8Wd5pUD2Gsv-n0A42RDRcX8", + "items": [], + "pageInfo": { + "totalResults": 0, + "resultsPerPage": 0 + } +} diff --git a/tests/components/youtube/fixtures/thumbnail/default.json b/tests/components/youtube/fixtures/thumbnail/default.json deleted file mode 100644 index 6b5d66d6501..00000000000 --- a/tests/components/youtube/fixtures/thumbnail/default.json +++ /dev/null @@ -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 - } -} diff --git a/tests/components/youtube/fixtures/thumbnail/high.json b/tests/components/youtube/fixtures/thumbnail/high.json deleted file mode 100644 index 430ad3715cc..00000000000 --- a/tests/components/youtube/fixtures/thumbnail/high.json +++ /dev/null @@ -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 - } -} diff --git a/tests/components/youtube/fixtures/thumbnail/medium.json b/tests/components/youtube/fixtures/thumbnail/medium.json deleted file mode 100644 index 21cb09bd886..00000000000 --- a/tests/components/youtube/fixtures/thumbnail/medium.json +++ /dev/null @@ -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 - } -} diff --git a/tests/components/youtube/fixtures/thumbnail/none.json b/tests/components/youtube/fixtures/thumbnail/none.json deleted file mode 100644 index d4c28730cab..00000000000 --- a/tests/components/youtube/fixtures/thumbnail/none.json +++ /dev/null @@ -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 - } -} diff --git a/tests/components/youtube/fixtures/thumbnail/standard.json b/tests/components/youtube/fixtures/thumbnail/standard.json deleted file mode 100644 index bdbedfcf4c9..00000000000 --- a/tests/components/youtube/fixtures/thumbnail/standard.json +++ /dev/null @@ -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 - } -} diff --git a/tests/components/youtube/snapshots/test_diagnostics.ambr b/tests/components/youtube/snapshots/test_diagnostics.ambr index 6a41465ac92..a938cb8daad 100644 --- a/tests/components/youtube/snapshots/test_diagnostics.ambr +++ b/tests/components/youtube/snapshots/test_diagnostics.ambr @@ -5,8 +5,8 @@ 'icon': 'https://yt3.ggpht.com/fca_HuJ99xUxflWdex0XViC3NfctBFreIl8y4i9z411asnGTWY-Ql3MeH_ybA4kNaOjY7kyA=s800-c-k-c0x00ffffff-no-rj', 'id': 'UC_x5XG1OV2P6uZZ5FSM9Ttw', 'latest_video': dict({ - 'published_at': '2023-05-11T00:20:46Z', - 'thumbnail': 'https://i.ytimg.com/vi/wysukDrMdqU/sddefault.jpg', + 'published_at': '2023-05-11T00:20:46+00:00', + 'thumbnail': 'https://i.ytimg.com/vi/wysukDrMdqU/maxresdefault.jpg', 'title': "What's new in Google Home in less than 1 minute", 'video_id': 'wysukDrMdqU', }), diff --git a/tests/components/youtube/snapshots/test_sensor.ambr b/tests/components/youtube/snapshots/test_sensor.ambr index b643bdeb979..e3bfa4ec4bd 100644 --- a/tests/components/youtube/snapshots/test_sensor.ambr +++ b/tests/components/youtube/snapshots/test_sensor.ambr @@ -2,10 +2,10 @@ # name: test_sensor StateSnapshot({ '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', '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', }), 'context': , @@ -30,3 +30,31 @@ 'state': '2290000', }) # --- +# name: test_sensor_without_uploaded_video + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Google for Developers Latest upload', + 'icon': 'mdi:youtube', + }), + 'context': , + 'entity_id': 'sensor.google_for_developers_latest_upload', + 'last_changed': , + 'last_updated': , + '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': , + 'entity_id': 'sensor.google_for_developers_subscribers', + 'last_changed': , + 'last_updated': , + 'state': '2290000', + }) +# --- diff --git a/tests/components/youtube/test_config_flow.py b/tests/components/youtube/test_config_flow.py index 5b91ff958f8..97875004d11 100644 --- a/tests/components/youtube/test_config_flow.py +++ b/tests/components/youtube/test_config_flow.py @@ -1,9 +1,8 @@ """Test the YouTube config flow.""" from unittest.mock import patch -from googleapiclient.errors import HttpError -from httplib2 import Response import pytest +from youtubeaio.types import ForbiddenError from homeassistant import config_entries 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.helpers import config_entry_oauth2_flow -from . import MockService +from . import MockYouTube from .conftest import ( CLIENT_ID, GOOGLE_AUTH_URI, @@ -21,7 +20,7 @@ from .conftest import ( ComponentSetup, ) -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator @@ -58,9 +57,8 @@ async def test_full_flow( with patch( "homeassistant.components.youtube.async_setup_entry", return_value=True ) as mock_setup, patch( - "homeassistant.components.youtube.api.build", return_value=MockService() - ), patch( - "homeassistant.components.youtube.config_flow.build", return_value=MockService() + "homeassistant.components.youtube.config_flow.YouTube", + return_value=MockYouTube(), ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.FORM @@ -112,11 +110,11 @@ async def test_flow_abort_without_channel( assert resp.status == 200 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( "homeassistant.components.youtube.async_setup_entry", return_value=True - ), patch("homeassistant.components.youtube.api.build", return_value=service), patch( - "homeassistant.components.youtube.config_flow.build", return_value=service + ), patch( + "homeassistant.components.youtube.config_flow.YouTube", return_value=service ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) 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" with patch( - "homeassistant.components.youtube.config_flow.build", - side_effect=HttpError( - Response( - { - "vary": "Origin, X-Origin, Referer", - "content-type": "application/json; charset=UTF-8", - "date": "Mon, 15 May 2023 21:25:42 GMT", - "server": "scaffolding on HTTPServer2", - "cache-control": "private", - "x-xss-protection": "0", - "x-frame-options": "SAMEORIGIN", - "x-content-type-options": "nosniff", - "alt-svc": 'h3=":443"; ma=2592000,h3-29=":443"; ma=2592000', - "transfer-encoding": "chunked", - "status": "403", - "content-length": "947", - "-content-encoding": "gzip", - } - ), - b'{"error": {"code": 403,"message": "YouTube Data API v3 has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/youtube.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.","errors": [ { "message": "YouTube Data API v3 has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/youtube.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.", "domain": "usageLimits", "reason": "accessNotConfigured", "extendedHelp": "https://console.developers.google.com" }],"status": "PERMISSION_DENIED"\n }\n}\n', + "homeassistant.components.youtube.config_flow.YouTube.get_user_channels", + side_effect=ForbiddenError( + "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." ), ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "access_not_configured" - assert ( - result["description_placeholders"]["message"] - == "YouTube Data API v3 has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/youtube.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry." + assert result["description_placeholders"]["message"] == ( + "YouTube Data API v3 has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/youtube.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry." ) @pytest.mark.parametrize( ("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", "wrong_account", @@ -254,14 +240,12 @@ async def test_reauth( }, ) + youtube = MockYouTube(channel_fixture=f"youtube/{fixture}.json") with patch( "homeassistant.components.youtube.async_setup_entry", return_value=True ) as mock_setup, patch( - "httplib2.Http.request", - return_value=( - Response({}), - bytes(load_fixture(f"youtube/{fixture}.json"), encoding="UTF-8"), - ), + "homeassistant.components.youtube.config_flow.YouTube", + return_value=youtube, ): 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" 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"]) assert result["type"] == FlowResultType.ABORT @@ -322,7 +306,8 @@ async def test_options_flow( """Test the full options flow.""" await setup_integration() 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] result = await hass.config_entries.options.async_init(entry.entry_id) diff --git a/tests/components/youtube/test_sensor.py b/tests/components/youtube/test_sensor.py index f2c5274c4a7..7dc368a5860 100644 --- a/tests/components/youtube/test_sensor.py +++ b/tests/components/youtube/test_sensor.py @@ -2,17 +2,17 @@ from datetime import timedelta from unittest.mock import patch -from google.auth.exceptions import RefreshError -import pytest from syrupy import SnapshotAssertion +from youtubeaio.types import UnauthorizedError 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.setup import async_setup_component from homeassistant.util import dt as dt_util -from . import MockService -from .conftest import TOKEN, ComponentSetup +from . import MockYouTube +from .conftest import ComponentSetup from tests.common import async_fire_time_changed @@ -30,6 +30,29 @@ async def test_sensor( 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( hass: HomeAssistant, setup_integration: ComponentSetup ) -> None: @@ -41,8 +64,8 @@ async def test_sensor_updating( assert state.attributes["video_id"] == "wysukDrMdqU" with patch( - "homeassistant.components.youtube.api.build", - return_value=MockService( + "homeassistant.components.youtube.api.AsyncConfigEntryAuth.get_resource", + return_value=MockYouTube( 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.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" @@ -64,9 +87,11 @@ async def test_sensor_reauth_trigger( hass: HomeAssistant, setup_integration: ComponentSetup ) -> None: """Test reauth is triggered after a refresh error.""" - await setup_integration() - - with patch(TOKEN, side_effect=RefreshError): + with patch( + "youtubeaio.youtube.YouTube.get_channels", side_effect=UnauthorizedError + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() future = dt_util.utcnow() + timedelta(minutes=15) async_fire_time_changed(hass, future) await hass.async_block_till_done() @@ -78,38 +103,3 @@ async def test_sensor_reauth_trigger( assert flow["step_id"] == "reauth_confirm" assert flow["handler"] == DOMAIN 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