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."""
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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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",
"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",
"etag": "PyFk-jpc2-v4mvG_6imAHx3y6TM",
"id": "UCXuqSBlHAE6Xw-yeJA0Tunw",
"snippet": {
"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",
"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"
}
}
]

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

View file

@ -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': <ANY>,
@ -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': <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."""
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)

View file

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