Implement YouTube async library (#97072)
This commit is contained in:
parent
714a04d603
commit
04f6d1848b
21 changed files with 270 additions and 587 deletions
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
@ -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]
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"kind": "youtube#playlistItemListResponse",
|
||||
"etag": "O0Ah8Wd5pUD2Gsv-n0A42RDRcX8",
|
||||
"items": [],
|
||||
"pageInfo": {
|
||||
"totalResults": 0,
|
||||
"resultsPerPage": 0
|
||||
}
|
||||
}
|
|
@ -1,42 +0,0 @@
|
|||
{
|
||||
"kind": "youtube#playlistItemListResponse",
|
||||
"etag": "O0Ah8Wd5pUD2Gsv-n0A42RDRcX8",
|
||||
"nextPageToken": "EAAaBlBUOkNBVQ",
|
||||
"items": [
|
||||
{
|
||||
"kind": "youtube#playlistItem",
|
||||
"etag": "qgpoAJRNskzLhD99njC8e2kPB0M",
|
||||
"id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3Lnd5c3VrRHJNZHFV",
|
||||
"snippet": {
|
||||
"publishedAt": "2023-05-11T00:20:46Z",
|
||||
"channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw",
|
||||
"title": "What's new in Google Home in less than 1 minute",
|
||||
"description": "Discover how your connected devices can do more with Google Home using Matter and Automations at Google I/O 2023.\n\nTo learn more about what's new in Google Home, check out the keynote → https://goo.gle/IO23_homekey\n\nSubscribe to Google Developers → https://goo.gle/developers \n\n#GoogleIO #GoogleHome",
|
||||
"thumbnails": {
|
||||
"default": {
|
||||
"url": "https://i.ytimg.com/vi/wysukDrMdqU/default.jpg",
|
||||
"width": 120,
|
||||
"height": 90
|
||||
}
|
||||
},
|
||||
"channelTitle": "Google for Developers",
|
||||
"playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw",
|
||||
"position": 0,
|
||||
"resourceId": {
|
||||
"kind": "youtube#video",
|
||||
"videoId": "wysukDrMdqU"
|
||||
},
|
||||
"videoOwnerChannelTitle": "Google for Developers",
|
||||
"videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw"
|
||||
},
|
||||
"contentDetails": {
|
||||
"videoId": "wysukDrMdqU",
|
||||
"videoPublishedAt": "2023-05-11T00:20:46Z"
|
||||
}
|
||||
}
|
||||
],
|
||||
"pageInfo": {
|
||||
"totalResults": 5798,
|
||||
"resultsPerPage": 1
|
||||
}
|
||||
}
|
|
@ -1,52 +0,0 @@
|
|||
{
|
||||
"kind": "youtube#playlistItemListResponse",
|
||||
"etag": "O0Ah8Wd5pUD2Gsv-n0A42RDRcX8",
|
||||
"nextPageToken": "EAAaBlBUOkNBVQ",
|
||||
"items": [
|
||||
{
|
||||
"kind": "youtube#playlistItem",
|
||||
"etag": "qgpoAJRNskzLhD99njC8e2kPB0M",
|
||||
"id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3Lnd5c3VrRHJNZHFV",
|
||||
"snippet": {
|
||||
"publishedAt": "2023-05-11T00:20:46Z",
|
||||
"channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw",
|
||||
"title": "What's new in Google Home in less than 1 minute",
|
||||
"description": "Discover how your connected devices can do more with Google Home using Matter and Automations at Google I/O 2023.\n\nTo learn more about what's new in Google Home, check out the keynote → https://goo.gle/IO23_homekey\n\nSubscribe to Google Developers → https://goo.gle/developers \n\n#GoogleIO #GoogleHome",
|
||||
"thumbnails": {
|
||||
"default": {
|
||||
"url": "https://i.ytimg.com/vi/wysukDrMdqU/default.jpg",
|
||||
"width": 120,
|
||||
"height": 90
|
||||
},
|
||||
"medium": {
|
||||
"url": "https://i.ytimg.com/vi/wysukDrMdqU/mqdefault.jpg",
|
||||
"width": 320,
|
||||
"height": 180
|
||||
},
|
||||
"high": {
|
||||
"url": "https://i.ytimg.com/vi/wysukDrMdqU/hqdefault.jpg",
|
||||
"width": 480,
|
||||
"height": 360
|
||||
}
|
||||
},
|
||||
"channelTitle": "Google for Developers",
|
||||
"playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw",
|
||||
"position": 0,
|
||||
"resourceId": {
|
||||
"kind": "youtube#video",
|
||||
"videoId": "wysukDrMdqU"
|
||||
},
|
||||
"videoOwnerChannelTitle": "Google for Developers",
|
||||
"videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw"
|
||||
},
|
||||
"contentDetails": {
|
||||
"videoId": "wysukDrMdqU",
|
||||
"videoPublishedAt": "2023-05-11T00:20:46Z"
|
||||
}
|
||||
}
|
||||
],
|
||||
"pageInfo": {
|
||||
"totalResults": 5798,
|
||||
"resultsPerPage": 1
|
||||
}
|
||||
}
|
|
@ -1,47 +0,0 @@
|
|||
{
|
||||
"kind": "youtube#playlistItemListResponse",
|
||||
"etag": "O0Ah8Wd5pUD2Gsv-n0A42RDRcX8",
|
||||
"nextPageToken": "EAAaBlBUOkNBVQ",
|
||||
"items": [
|
||||
{
|
||||
"kind": "youtube#playlistItem",
|
||||
"etag": "qgpoAJRNskzLhD99njC8e2kPB0M",
|
||||
"id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3Lnd5c3VrRHJNZHFV",
|
||||
"snippet": {
|
||||
"publishedAt": "2023-05-11T00:20:46Z",
|
||||
"channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw",
|
||||
"title": "What's new in Google Home in less than 1 minute",
|
||||
"description": "Discover how your connected devices can do more with Google Home using Matter and Automations at Google I/O 2023.\n\nTo learn more about what's new in Google Home, check out the keynote → https://goo.gle/IO23_homekey\n\nSubscribe to Google Developers → https://goo.gle/developers \n\n#GoogleIO #GoogleHome",
|
||||
"thumbnails": {
|
||||
"default": {
|
||||
"url": "https://i.ytimg.com/vi/wysukDrMdqU/default.jpg",
|
||||
"width": 120,
|
||||
"height": 90
|
||||
},
|
||||
"medium": {
|
||||
"url": "https://i.ytimg.com/vi/wysukDrMdqU/mqdefault.jpg",
|
||||
"width": 320,
|
||||
"height": 180
|
||||
}
|
||||
},
|
||||
"channelTitle": "Google for Developers",
|
||||
"playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw",
|
||||
"position": 0,
|
||||
"resourceId": {
|
||||
"kind": "youtube#video",
|
||||
"videoId": "wysukDrMdqU"
|
||||
},
|
||||
"videoOwnerChannelTitle": "Google for Developers",
|
||||
"videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw"
|
||||
},
|
||||
"contentDetails": {
|
||||
"videoId": "wysukDrMdqU",
|
||||
"videoPublishedAt": "2023-05-11T00:20:46Z"
|
||||
}
|
||||
}
|
||||
],
|
||||
"pageInfo": {
|
||||
"totalResults": 5798,
|
||||
"resultsPerPage": 1
|
||||
}
|
||||
}
|
|
@ -1,36 +0,0 @@
|
|||
{
|
||||
"kind": "youtube#playlistItemListResponse",
|
||||
"etag": "O0Ah8Wd5pUD2Gsv-n0A42RDRcX8",
|
||||
"nextPageToken": "EAAaBlBUOkNBVQ",
|
||||
"items": [
|
||||
{
|
||||
"kind": "youtube#playlistItem",
|
||||
"etag": "qgpoAJRNskzLhD99njC8e2kPB0M",
|
||||
"id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3Lnd5c3VrRHJNZHFV",
|
||||
"snippet": {
|
||||
"publishedAt": "2023-05-11T00:20:46Z",
|
||||
"channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw",
|
||||
"title": "What's new in Google Home in less than 1 minute",
|
||||
"description": "Discover how your connected devices can do more with Google Home using Matter and Automations at Google I/O 2023.\n\nTo learn more about what's new in Google Home, check out the keynote → https://goo.gle/IO23_homekey\n\nSubscribe to Google Developers → https://goo.gle/developers \n\n#GoogleIO #GoogleHome",
|
||||
"thumbnails": {},
|
||||
"channelTitle": "Google for Developers",
|
||||
"playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw",
|
||||
"position": 0,
|
||||
"resourceId": {
|
||||
"kind": "youtube#video",
|
||||
"videoId": "wysukDrMdqU"
|
||||
},
|
||||
"videoOwnerChannelTitle": "Google for Developers",
|
||||
"videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw"
|
||||
},
|
||||
"contentDetails": {
|
||||
"videoId": "wysukDrMdqU",
|
||||
"videoPublishedAt": "2023-05-11T00:20:46Z"
|
||||
}
|
||||
}
|
||||
],
|
||||
"pageInfo": {
|
||||
"totalResults": 5798,
|
||||
"resultsPerPage": 1
|
||||
}
|
||||
}
|
|
@ -1,57 +0,0 @@
|
|||
{
|
||||
"kind": "youtube#playlistItemListResponse",
|
||||
"etag": "O0Ah8Wd5pUD2Gsv-n0A42RDRcX8",
|
||||
"nextPageToken": "EAAaBlBUOkNBVQ",
|
||||
"items": [
|
||||
{
|
||||
"kind": "youtube#playlistItem",
|
||||
"etag": "qgpoAJRNskzLhD99njC8e2kPB0M",
|
||||
"id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3Lnd5c3VrRHJNZHFV",
|
||||
"snippet": {
|
||||
"publishedAt": "2023-05-11T00:20:46Z",
|
||||
"channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw",
|
||||
"title": "What's new in Google Home in less than 1 minute",
|
||||
"description": "Discover how your connected devices can do more with Google Home using Matter and Automations at Google I/O 2023.\n\nTo learn more about what's new in Google Home, check out the keynote → https://goo.gle/IO23_homekey\n\nSubscribe to Google Developers → https://goo.gle/developers \n\n#GoogleIO #GoogleHome",
|
||||
"thumbnails": {
|
||||
"default": {
|
||||
"url": "https://i.ytimg.com/vi/wysukDrMdqU/default.jpg",
|
||||
"width": 120,
|
||||
"height": 90
|
||||
},
|
||||
"medium": {
|
||||
"url": "https://i.ytimg.com/vi/wysukDrMdqU/mqdefault.jpg",
|
||||
"width": 320,
|
||||
"height": 180
|
||||
},
|
||||
"high": {
|
||||
"url": "https://i.ytimg.com/vi/wysukDrMdqU/hqdefault.jpg",
|
||||
"width": 480,
|
||||
"height": 360
|
||||
},
|
||||
"standard": {
|
||||
"url": "https://i.ytimg.com/vi/wysukDrMdqU/sddefault.jpg",
|
||||
"width": 640,
|
||||
"height": 480
|
||||
}
|
||||
},
|
||||
"channelTitle": "Google for Developers",
|
||||
"playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw",
|
||||
"position": 0,
|
||||
"resourceId": {
|
||||
"kind": "youtube#video",
|
||||
"videoId": "wysukDrMdqU"
|
||||
},
|
||||
"videoOwnerChannelTitle": "Google for Developers",
|
||||
"videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw"
|
||||
},
|
||||
"contentDetails": {
|
||||
"videoId": "wysukDrMdqU",
|
||||
"videoPublishedAt": "2023-05-11T00:20:46Z"
|
||||
}
|
||||
}
|
||||
],
|
||||
"pageInfo": {
|
||||
"totalResults": 5798,
|
||||
"resultsPerPage": 1
|
||||
}
|
||||
}
|
|
@ -5,8 +5,8 @@
|
|||
'icon': 'https://yt3.ggpht.com/fca_HuJ99xUxflWdex0XViC3NfctBFreIl8y4i9z411asnGTWY-Ql3MeH_ybA4kNaOjY7kyA=s800-c-k-c0x00ffffff-no-rj',
|
||||
'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',
|
||||
}),
|
||||
|
|
|
@ -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',
|
||||
})
|
||||
# ---
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue