diff --git a/homeassistant/components/youtube/__init__.py b/homeassistant/components/youtube/__init__.py index 2c1beae358e5f0..7124d779ad5b7d 100644 --- a/homeassistant/components/youtube/__init__.py +++ b/homeassistant/components/youtube/__init__.py @@ -1,14 +1,24 @@ """Support for YouTube.""" +from types import MappingProxyType + +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) from .api import AsyncConfigEntryAuth +from .const import ( + ATTR_TITLE, + CONF_CHANNEL_ID, + CONF_CHANNELS, + DOMAIN, + SUBENTRY_TYPE_CHANNEL, +) from .coordinator import YouTubeConfigEntry, YouTubeDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -20,13 +30,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: YouTubeConfigEntry) -> b session = OAuth2Session(hass, entry, implementation) auth = AsyncConfigEntryAuth(hass, session) await auth.check_and_refresh_token() - coordinator = YouTubeDataUpdateCoordinator(hass, entry, auth) + coordinator = YouTubeDataUpdateCoordinator(hass, entry, auth) await coordinator.async_config_entry_first_refresh() - await delete_devices(hass, entry, coordinator) + data = coordinator.data + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_CHANNEL): + channel = data.get(subentry.data[CONF_CHANNEL_ID]) + if channel is not None and (title := channel[ATTR_TITLE]) != subentry.title: + hass.config_entries.async_update_subentry(entry, subentry, title=title) entry.runtime_data = coordinator + entry.async_on_unload(entry.add_update_listener(async_update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -37,15 +52,90 @@ async def async_unload_entry(hass: HomeAssistant, entry: YouTubeConfigEntry) -> return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def delete_devices( - hass: HomeAssistant, - entry: YouTubeConfigEntry, - coordinator: YouTubeDataUpdateCoordinator, -) -> None: - """Delete all devices created by integration.""" - channel_ids = list(coordinator.data) +async def async_update_listener(hass: HomeAssistant, entry: YouTubeConfigEntry) -> None: + """Reload the config entry when channels are added or removed.""" + subentry_ids = { + subentry.subentry_id + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_CHANNEL) + } + if subentry_ids == entry.runtime_data.subentry_ids: + # Token refreshes update the entry data only; the coordinator + # uses the refreshed token without needing a reload. + return + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old entries to the subentry structure.""" device_registry = dr.async_get(hass) - dev_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) - for dev_entry in dev_entries: - if any(identifier[1] in channel_ids for identifier in dev_entry.identifiers): - device_registry.async_remove_device(dev_entry.id) + entity_registry = er.async_get(hass) + prefix = f"{entry.entry_id}_" + channel_ids = dict.fromkeys(entry.options.get(CONF_CHANNELS, [])) + + subentries: dict[str, ConfigSubentry] = {} + for channel_id in channel_ids: + device = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{prefix}{channel_id}"), entry.entry_id + ) + title = channel_id + if device is not None and device.name is not None: + # Prefer the channel name of the existing device so the title + # survives even if the channel can no longer be fetched. + title = device.name + subentry = ConfigSubentry( + data=MappingProxyType({CONF_CHANNEL_ID: channel_id}), + subentry_type=SUBENTRY_TYPE_CHANNEL, + title=title, + unique_id=channel_id, + ) + hass.config_entries.async_add_subentry(entry, subentry) + subentries[channel_id] = subentry + + # Attach the entities of tracked channels to their subentry and remove + # entities left behind by channels which are no longer tracked. + channel_prefixes = { + f"{prefix}{channel_id}_": channel_id for channel_id in channel_ids + } + for entity_entry in er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ): + channel_subentry = next( + ( + subentries[channel_id] + for channel_prefix, channel_id in channel_prefixes.items() + if entity_entry.unique_id.startswith(channel_prefix) + ), + None, + ) + if channel_subentry is None: + entity_registry.async_remove(entity_entry.entity_id) + else: + entity_registry.async_update_entity( + entity_entry.entity_id, + config_subentry_id=channel_subentry.subentry_id, + ) + + # Move the devices of tracked channels to their subentry and remove + # devices left behind by channels which are no longer tracked. + for device_entry in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + channel_id = next( + ( + identifier[1].removeprefix(prefix) + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN and identifier[1].startswith(prefix) + ), + None, + ) + if channel_id is not None and channel_id in subentries: + device_registry.async_update_device( + device_entry.id, + new_identifiers={(DOMAIN, channel_id)}, + new_config_subentry_id=subentries[channel_id].subentry_id, + ) + else: + device_registry.async_remove_device(device_entry.id) + + hass.config_entries.async_update_entry(entry, version=2, options={}) + return True diff --git a/homeassistant/components/youtube/config_flow.py b/homeassistant/components/youtube/config_flow.py index 12dfe5a8b31d49..130fb8ce140731 100644 --- a/homeassistant/components/youtube/config_flow.py +++ b/homeassistant/components/youtube/config_flow.py @@ -8,47 +8,105 @@ from youtubeaio.types import AuthScope, ForbiddenError from youtubeaio.youtube import YouTube -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + SOURCE_REAUTH, + ConfigEntry, + ConfigFlowResult, + ConfigSubentryData, + ConfigSubentryFlow, + SubentryFlowResult, +) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.core import callback from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, SelectSelectorConfig, + SelectSelectorMode, ) +from .api import AsyncConfigEntryAuth from .const import ( CHANNEL_CREATION_HELP_URL, + CONF_CHANNEL_ID, CONF_CHANNELS, DEFAULT_ACCESS, DOMAIN, LOGGER, + SUBENTRY_TYPE_CHANNEL, ) from .coordinator import YouTubeConfigEntry +async def async_get_channel_options( + youtube: YouTube, +) -> tuple[list[SelectOptionDict], dict[str, str], bool]: + """List the channels the user can track. + + Returns the selectable options, a mapping of channel id to title, and + whether the user has their own channel. + """ + own_channels = [ + channel + async for channel in youtube.get_user_channels() + if channel.snippet is not None + ] + subscriptions = [ + subscription + async for subscription in youtube.get_user_subscriptions() + if subscription.snippet is not None + ] + + selectable_channels = [ + SelectOptionDict( + value=channel.channel_id, + label=f"{channel.snippet.title} (Your Channel)", + ) + for channel in own_channels + ] + selectable_channels.extend( + SelectOptionDict( + value=subscription.snippet.channel_id, + label=subscription.snippet.title, + ) + for subscription in subscriptions + ) + channel_titles = { + subscription.snippet.channel_id: subscription.snippet.title + for subscription in subscriptions + } | {channel.channel_id: channel.snippet.title for channel in own_channels} + return selectable_channels, channel_titles, bool(own_channels) + + class OAuth2FlowHandler( config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN ): """Config flow to handle Google OAuth2 authentication.""" + VERSION = 2 + _data: dict[str, Any] = {} _title: str = "" + _channel_titles: dict[str, str] = {} DOMAIN = DOMAIN _youtube: YouTube | None = None - @staticmethod + @classmethod @callback @override - def async_get_options_flow( - config_entry: YouTubeConfigEntry, - ) -> YouTubeOptionsFlowHandler: - """Get the options flow for this handler.""" - return YouTubeOptionsFlowHandler() + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this handler.""" + return {SUBENTRY_TYPE_CHANNEL: ChannelFlowHandler} @property @override @@ -133,120 +191,147 @@ async def async_step_channels( ) -> ConfigFlowResult: """Select which channels to track.""" if user_input: + channel_ids = dict.fromkeys(user_input[CONF_CHANNELS]) return self.async_create_entry( title=self._title, data=self._data, - options=user_input, + subentries=[ + ConfigSubentryData( + data={CONF_CHANNEL_ID: channel_id}, + subentry_type=SUBENTRY_TYPE_CHANNEL, + title=self._channel_titles[channel_id], + unique_id=channel_id, + ) + for channel_id in channel_ids + ], ) - youtube = await self.get_resource(self._data[CONF_TOKEN][CONF_ACCESS_TOKEN]) - - # Get user's own channels - own_channels = [ - channel - async for channel in youtube.get_user_channels() - if channel.snippet is not None - ] - if not own_channels: - return self.async_abort( - reason="no_channel", - description_placeholders={"support_url": CHANNEL_CREATION_HELP_URL}, + try: + youtube = YouTube(session=async_get_clientsession(self.hass)) + await youtube.set_user_authentication( + self._data[CONF_TOKEN][CONF_ACCESS_TOKEN], [AuthScope.READ_ONLY] ) - - # Start with user's own channels - selectable_channels = [ - SelectOptionDict( - value=channel.channel_id, - label=f"{channel.snippet.title} (Your Channel)", + ( + selectable_channels, + channel_titles, + _has_own_channel, + ) = await async_get_channel_options(youtube) + except ForbiddenError as ex: + error = ex.args[0] + return self.async_abort( + reason="access_not_configured", + description_placeholders={"message": error}, ) - for channel in own_channels - ] - - # Add subscribed channels - selectable_channels.extend( - [ - SelectOptionDict( - value=subscription.snippet.channel_id, - label=subscription.snippet.title, - ) - async for subscription in youtube.get_user_subscriptions() - ] - ) - - if not selectable_channels: - return self.async_abort(reason="no_subscriptions") + except Exception as ex: # noqa: BLE001 + LOGGER.error("Unknown error occurred: %s", ex.args) + return self.async_abort(reason="unknown") + self._channel_titles = channel_titles return self.async_show_form( step_id="channels", data_schema=vol.Schema( { vol.Required(CONF_CHANNELS): SelectSelector( - SelectSelectorConfig(options=selectable_channels, multiple=True) + SelectSelectorConfig( + options=selectable_channels, + multiple=True, + mode=SelectSelectorMode.DROPDOWN, + ) ), } ), ) -class YouTubeOptionsFlowHandler(OptionsFlow): - """YouTube Options flow handler.""" +class ChannelFlowHandler(ConfigSubentryFlow): + """Handle subentry flow for adding a channel.""" - async def async_step_init( + async def async_step_user( self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Initialize form.""" + ) -> SubentryFlowResult: + """User flow to add a channel.""" if user_input is not None: - return self.async_create_entry( - title=self.config_entry.title, - data=user_input, + return await self._async_create_entry(user_input[CONF_CHANNEL_ID]) + config_entry: YouTubeConfigEntry = self._get_entry() + try: + ( + selectable_channels, + _channel_titles, + _has_own_channel, + ) = await async_get_channel_options( + await self._async_get_youtube(config_entry) ) - 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] - ) - - # Get user's own channels - own_channels = [ - channel - async for channel in youtube.get_user_channels() - if channel.snippet is not None - ] - if not own_channels: + except ForbiddenError as ex: + error = ex.args[0] return self.async_abort( - reason="no_channel", - description_placeholders={"support_url": CHANNEL_CREATION_HELP_URL}, + reason="access_not_configured", + description_placeholders={"message": error}, ) + except Exception as ex: # noqa: BLE001 + LOGGER.error("Unknown error occurred: %s", ex.args) + return self.async_abort(reason="unknown") + + configured = self._async_configured_channel_ids() + options: list[SelectOptionDict] = [] + seen: set[str] = set() + for option in selectable_channels: + if option["value"] in seen or option["value"] in configured: + continue + seen.add(option["value"]) + options.append(option) + if not options: + return self.async_abort(reason="no_subscriptions") + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_CHANNEL_ID): SelectSelector( + SelectSelectorConfig( + options=options, mode=SelectSelectorMode.DROPDOWN + ) + ), + } + ), + ) - # Start with user's own channels - selectable_channels = [ - SelectOptionDict( - value=channel.channel_id, - label=f"{channel.snippet.title} (Your Channel)", + @callback + def _async_configured_channel_ids(self) -> set[str]: + """Return channel ids already tracked by this config entry.""" + return { + subentry.unique_id + for subentry in self._get_entry().get_subentries_of_type( + SUBENTRY_TYPE_CHANNEL ) - for channel in own_channels - ] - - # Add subscribed channels - selectable_channels.extend( - [ - SelectOptionDict( - value=subscription.snippet.channel_id, - label=subscription.snippet.title, - ) - async for subscription in youtube.get_user_subscriptions() - ] + if subentry.unique_id + } + + async def _async_get_youtube(self, config_entry: YouTubeConfigEntry) -> YouTube: + """Return a YouTube client using a refreshed OAuth token.""" + implementation = await async_get_config_entry_implementation( + self.hass, config_entry ) + auth = AsyncConfigEntryAuth( + self.hass, OAuth2Session(self.hass, config_entry, implementation) + ) + return await auth.get_resource() - return self.async_show_form( - step_id="init", - data_schema=self.add_suggested_values_to_schema( - vol.Schema( - { - vol.Required(CONF_CHANNELS): SelectSelector( - SelectSelectorConfig( - options=selectable_channels, multiple=True - ) - ), - } - ), - self.config_entry.options, - ), + async def _async_create_entry(self, channel_id: str) -> SubentryFlowResult: + """Create a subentry for the selected channel.""" + config_entry: YouTubeConfigEntry = self._get_entry() + try: + youtube = await self._async_get_youtube(config_entry) + channels = [channel async for channel in youtube.get_channels([channel_id])] + except ForbiddenError as ex: + error = ex.args[0] + return self.async_abort( + reason="access_not_configured", + description_placeholders={"message": error}, + ) + except Exception as ex: # noqa: BLE001 + LOGGER.error("Unknown error occurred: %s", ex.args) + return self.async_abort(reason="unknown") + if not channels or channels[0].snippet is None: + return self.async_abort(reason="unknown_channel") + return self.async_create_entry( + title=channels[0].snippet.title, + data={CONF_CHANNEL_ID: channel_id}, + unique_id=channel_id, ) diff --git a/homeassistant/components/youtube/const.py b/homeassistant/components/youtube/const.py index d9316dd5d085fb..a681db221adad9 100644 --- a/homeassistant/components/youtube/const.py +++ b/homeassistant/components/youtube/const.py @@ -7,9 +7,15 @@ MANUFACTURER = "Google, Inc." CHANNEL_CREATION_HELP_URL = "https://support.google.com/youtube/answer/1646861" +CONF_CHANNEL_ID = "channel_id" CONF_CHANNELS = "channels" CONF_UPLOAD_PLAYLIST = "upload_playlist_id" +SUBENTRY_TYPE_CHANNEL = "channel" + +# The YouTube channels.list API accepts at most 50 ids per request +MAX_CHANNEL_IDS_PER_REQUEST = 50 + LOGGER = logging.getLogger(__package__) ATTR_TITLE = "title" diff --git a/homeassistant/components/youtube/coordinator.py b/homeassistant/components/youtube/coordinator.py index 71ca3d43fb23e9..507bbc19ae478c 100644 --- a/homeassistant/components/youtube/coordinator.py +++ b/homeassistant/components/youtube/coordinator.py @@ -2,6 +2,7 @@ import asyncio from datetime import timedelta +from itertools import batched from typing import Any, override from youtubeaio.types import UnauthorizedError, YouTubeBackendError @@ -26,9 +27,11 @@ ATTR_TOTAL_VIEWS, ATTR_VIDEO_COUNT, ATTR_VIDEO_ID, - CONF_CHANNELS, + CONF_CHANNEL_ID, DOMAIN, LOGGER, + MAX_CHANNEL_IDS_PER_REQUEST, + SUBENTRY_TYPE_CHANNEL, ) type YouTubeConfigEntry = ConfigEntry[YouTubeDataUpdateCoordinator] @@ -48,7 +51,11 @@ def _build_video_dict(video: Any, is_short: bool) -> dict[str, Any]: class YouTubeDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): - """A YouTube Data Update Coordinator.""" + """A YouTube Data Update Coordinator fetching all tracked channels. + + The data maps channel id to its channel data. Channels missing from + the API response are absent from the data. + """ config_entry: YouTubeConfigEntry @@ -61,6 +68,10 @@ def __init__( """Initialize the YouTube data coordinator.""" self._auth = auth self._is_short_cache: dict[str, bool] = {} + self.subentry_ids = { + subentry.subentry_id + for subentry in config_entry.get_subentries_of_type(SUBENTRY_TYPE_CHANNEL) + } super().__init__( hass, LOGGER, @@ -71,54 +82,70 @@ def __init__( @override async def _async_update_data(self) -> dict[str, Any]: + """Fetch the data of all tracked channels in batched calls.""" youtube = await self._auth.get_resource() - res = {} - channel_ids = self.config_entry.options[CONF_CHANNELS] + channel_ids = [ + subentry.data[CONF_CHANNEL_ID] + for subentry in self.config_entry.get_subentries_of_type( + SUBENTRY_TYPE_CHANNEL + ) + ] + if not channel_ids: + return {} + res: dict[str, Any] = {} try: - async for channel in youtube.get_channels(channel_ids): - # Fetch up to 10 recent videos to find a Short and a non-Short. - videos = [ - v - async for v in youtube.get_playlist_items( - channel.upload_playlist_id, 10 + for chunk in batched( + channel_ids, MAX_CHANNEL_IDS_PER_REQUEST, strict=False + ): + async for channel in youtube.get_channels(list(chunk)): + res[channel.channel_id] = await self._async_get_channel_data( + youtube, channel ) - ] - LOGGER.debug( - "Fetched %d videos for channel %s", len(videos), channel.channel_id - ) - is_short_flags = await self._get_is_short_flags(youtube, videos) - - latest_video: dict[str, Any] | None = None - latest_short: dict[str, Any] | None = None - latest_video_non_short: dict[str, Any] | None = None - for video, is_short in zip(videos, is_short_flags, strict=False): - entry = _build_video_dict(video, is_short) - if latest_video is None: - latest_video = entry - if is_short and latest_short is None: - latest_short = entry - if not is_short and latest_video_non_short is None: - latest_video_non_short = entry - if latest_short is not None and latest_video_non_short is not None: - break - - 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_LATEST_SHORT: latest_short, - ATTR_LATEST_VIDEO_NON_SHORT: latest_video_non_short, - ATTR_SUBSCRIBER_COUNT: channel.statistics.subscriber_count, - ATTR_TOTAL_VIEWS: channel.statistics.view_count, - ATTR_VIDEO_COUNT: channel.statistics.video_count, - } except UnauthorizedError as err: raise ConfigEntryAuthFailed from err except YouTubeBackendError as err: raise UpdateFailed("Couldn't connect to YouTube") from err return res + async def _async_get_channel_data( + self, youtube: Any, channel: Any + ) -> dict[str, Any]: + """Fetch the uploads of the channel and build its sensor data.""" + # Fetch up to 10 recent videos to find a Short and a non-Short. + videos = [ + v async for v in youtube.get_playlist_items(channel.upload_playlist_id, 10) + ] + LOGGER.debug( + "Fetched %d videos for channel %s", len(videos), channel.channel_id + ) + is_short_flags = await self._get_is_short_flags(youtube, videos) + + latest_video: dict[str, Any] | None = None + latest_short: dict[str, Any] | None = None + latest_video_non_short: dict[str, Any] | None = None + for video, is_short in zip(videos, is_short_flags, strict=False): + entry = _build_video_dict(video, is_short) + if latest_video is None: + latest_video = entry + if is_short and latest_short is None: + latest_short = entry + if not is_short and latest_video_non_short is None: + latest_video_non_short = entry + if latest_short is not None and latest_video_non_short is not None: + break + + return { + 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_LATEST_SHORT: latest_short, + ATTR_LATEST_VIDEO_NON_SHORT: latest_video_non_short, + ATTR_SUBSCRIBER_COUNT: channel.statistics.subscriber_count, + ATTR_TOTAL_VIEWS: channel.statistics.view_count, + ATTR_VIDEO_COUNT: channel.statistics.video_count, + } + async def _get_is_short_flags(self, youtube: Any, videos: list[Any]) -> list[bool]: """Return is_short flags for each video, using cache when available.""" uncached = [ diff --git a/homeassistant/components/youtube/diagnostics.py b/homeassistant/components/youtube/diagnostics.py index 1fced9313d4914..51ccfdfca0426c 100644 --- a/homeassistant/components/youtube/diagnostics.py +++ b/homeassistant/components/youtube/diagnostics.py @@ -17,9 +17,8 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: YouTubeConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator = entry.runtime_data sensor_data: dict[str, Any] = {} - for channel_id, channel_data in coordinator.data.items(): + for channel_id, channel_data in entry.runtime_data.data.items(): channel_copy = dict(channel_data) # Strip verbose description field from all video entries. for attr in ( diff --git a/homeassistant/components/youtube/entity.py b/homeassistant/components/youtube/entity.py index 32830ee98218ad..4e1ce346690ca1 100644 --- a/homeassistant/components/youtube/entity.py +++ b/homeassistant/components/youtube/entity.py @@ -1,10 +1,13 @@ -"""Entity representing a YouTube account.""" +"""Entity representing a YouTube channel.""" +from typing import Any + +from homeassistant.config_entries import ConfigSubentry from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ATTR_TITLE, DOMAIN, MANUFACTURER +from .const import CONF_CHANNEL_ID, DOMAIN, MANUFACTURER from .coordinator import YouTubeDataUpdateCoordinator @@ -16,19 +19,27 @@ class YouTubeChannelEntity(CoordinatorEntity[YouTubeDataUpdateCoordinator]): def __init__( self, coordinator: YouTubeDataUpdateCoordinator, + subentry: ConfigSubentry, description: EntityDescription, - channel_id: str, ) -> None: """Initialize a YouTube entity.""" super().__init__(coordinator) self.entity_description = description + channel_id = subentry.data[CONF_CHANNEL_ID] + self._channel_id = channel_id + # The entry id prefix keeps unique ids unique when two accounts + # track the same channel. self._attr_unique_id = ( f"{coordinator.config_entry.entry_id}_{channel_id}_{description.key}" ) - self._channel_id = channel_id self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, f"{coordinator.config_entry.entry_id}_{channel_id}")}, + identifiers={(DOMAIN, channel_id)}, manufacturer=MANUFACTURER, - name=coordinator.data[channel_id][ATTR_TITLE], + name=subentry.title, ) + + @property + def _channel_data(self) -> dict[str, Any] | None: + """Return the channel data, None when the channel is not available.""" + return self.coordinator.data.get(self._channel_id) diff --git a/homeassistant/components/youtube/sensor.py b/homeassistant/components/youtube/sensor.py index 0c91605a12e44d..0c60f44439c978 100644 --- a/homeassistant/components/youtube/sensor.py +++ b/homeassistant/components/youtube/sensor.py @@ -25,6 +25,7 @@ ATTR_TOTAL_VIEWS, ATTR_VIDEO_COUNT, ATTR_VIDEO_ID, + SUBENTRY_TYPE_CHANNEL, ) from .coordinator import YouTubeConfigEntry from .entity import YouTubeChannelEntity @@ -117,11 +118,14 @@ async def async_setup_entry( ) -> None: """Set up the YouTube sensor.""" coordinator = entry.runtime_data - async_add_entities( - YouTubeSensor(coordinator, sensor_type, channel_id) - for channel_id in coordinator.data - for sensor_type in SENSOR_TYPES - ) + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_CHANNEL): + async_add_entities( + ( + YouTubeSensor(coordinator, subentry, sensor_type) + for sensor_type in SENSOR_TYPES + ), + config_subentry_id=subentry.subentry_id, + ) class YouTubeSensor(YouTubeChannelEntity, SensorEntity): @@ -133,15 +137,17 @@ class YouTubeSensor(YouTubeChannelEntity, SensorEntity): @override def available(self) -> bool: """Return if the entity is available.""" - return super().available and self.entity_description.available_fn( - self.coordinator.data[self._channel_id] + return ( + super().available + and self._channel_data is not None + and self.entity_description.available_fn(self._channel_data) ) @property @override def native_value(self) -> StateType: """Return the value reported by the sensor.""" - return self.entity_description.value_fn(self.coordinator.data[self._channel_id]) + return self.entity_description.value_fn(self._channel_data) @property @override @@ -149,16 +155,12 @@ 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] - ) + return self.entity_description.entity_picture_fn(self._channel_data) @property @override def extra_state_attributes(self) -> dict[str, Any] | None: """Return the extra state attributes.""" if self.entity_description.attributes_fn: - return self.entity_description.attributes_fn( - self.coordinator.data[self._channel_id] - ) + return self.entity_description.attributes_fn(self._channel_data) return None diff --git a/homeassistant/components/youtube/strings.json b/homeassistant/components/youtube/strings.json index 457ab5be86f23f..e1198a0e250643 100644 --- a/homeassistant/components/youtube/strings.json +++ b/homeassistant/components/youtube/strings.json @@ -26,6 +26,27 @@ } } }, + "config_subentries": { + "channel": { + "abort": { + "access_not_configured": "[%key:component::youtube::config::abort::access_not_configured%]", + "already_configured": "This channel is already configured.", + "no_subscriptions": "There are no channels available to add.", + "unknown": "[%key:common::config_flow::error::unknown%]", + "unknown_channel": "The selected channel is no longer available." + }, + "entry_type": "YouTube channel", + "initiate_flow": { + "user": "Add channel" + }, + "step": { + "user": { + "data": { "channel_id": "Channel" }, + "description": "Select the channel you want to add." + } + } + } + }, "entity": { "sensor": { "latest_short": { @@ -53,15 +74,5 @@ "videos": { "name": "Videos" }, "views": { "name": "Views" } } - }, - "options": { - "step": { - "init": { - "data": { - "channels": "[%key:component::youtube::config::step::channels::data::channels%]" - }, - "description": "[%key:component::youtube::config::step::channels::description%]" - } - } } } diff --git a/tests/components/youtube/__init__.py b/tests/components/youtube/__init__.py index 65e03b44f03009..afbaba6ad16f95 100644 --- a/tests/components/youtube/__init__.py +++ b/tests/components/youtube/__init__.py @@ -23,6 +23,7 @@ def __init__( playlist_items_fixture: str = "get_playlist_items.json", subscriptions_fixture: str = "get_subscriptions.json", short_video_ids: set[str] | None = None, + extra_channel_fixtures: list[str] | None = None, ) -> None: """Initialize mock service.""" self.hass = hass @@ -30,6 +31,7 @@ def __init__( self._playlist_items_fixture = playlist_items_fixture self._subscriptions_fixture = subscriptions_fixture self._short_video_ids: set[str] = short_video_ids or set() + self._extra_channel_fixtures = extra_channel_fixtures or [] async def set_user_authentication( self, token: str, scopes: list[AuthScope] @@ -50,11 +52,11 @@ async def get_channels( """Get channels.""" if self._thrown_error is not None: raise self._thrown_error - channels = await async_load_json_object_fixture( - self.hass, self._channel_fixture, DOMAIN - ) - for item in channels["items"]: - yield YouTubeChannel(**item) + for fixture in (self._channel_fixture, *self._extra_channel_fixtures): + channels = await async_load_json_object_fixture(self.hass, fixture, DOMAIN) + for item in channels["items"]: + if item["id"] in channel_ids: + yield YouTubeChannel(**item) async def get_playlist_items( self, playlist_id: str, amount: int diff --git a/tests/components/youtube/conftest.py b/tests/components/youtube/conftest.py index 52ef80c5d97435..ed6957f4c4f1b9 100644 --- a/tests/components/youtube/conftest.py +++ b/tests/components/youtube/conftest.py @@ -12,7 +12,12 @@ ClientCredential, async_import_client_credential, ) -from homeassistant.components.youtube.const import DOMAIN +from homeassistant.components.youtube.const import ( + CONF_CHANNEL_ID, + DOMAIN, + SUBENTRY_TYPE_CHANNEL, +) +from homeassistant.config_entries import ConfigSubentryData from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -31,12 +36,27 @@ "https://www.googleapis.com/auth/youtube.readonly", ] TITLE = "Google for Developers" +CHANNEL_ID = "UC_x5XG1OV2P6uZZ5FSM9Ttw" +LINUS_CHANNEL_ID = "UCXuqSBlHAE6Xw-yeJA0Tunw" TOKEN = ( "homeassistant.components.youtube.api" ".config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid" ) +def mock_entry_data(expires_at: int, scopes: list[str]) -> dict[str, Any]: + """Return OAuth data for a YouTube config entry.""" + return { + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": " ".join(scopes), + }, + } + + @pytest.fixture(name="scopes") def mock_scopes() -> list[str]: """Fixture to set the scopes present in the OAuth token.""" @@ -67,17 +87,18 @@ def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: return MockConfigEntry( domain=DOMAIN, title=TITLE, - unique_id="UC_x5XG1OV2P6uZZ5FSM9Ttw", - data={ - "auth_implementation": DOMAIN, - "token": { - "access_token": "mock-access-token", - "refresh_token": "mock-refresh-token", - "expires_at": expires_at, - "scope": " ".join(scopes), - }, - }, - options={"channels": ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]}, + unique_id=CHANNEL_ID, + version=2, + data=mock_entry_data(expires_at, scopes), + subentries_data=[ + ConfigSubentryData( + data={CONF_CHANNEL_ID: CHANNEL_ID}, + subentry_id="channel_1", + subentry_type=SUBENTRY_TYPE_CHANNEL, + title="Google for Developers", + unique_id=CHANNEL_ID, + ) + ], ) diff --git a/tests/components/youtube/test_config_flow.py b/tests/components/youtube/test_config_flow.py index 03bfb674e3990e..04ae21edde9535 100644 --- a/tests/components/youtube/test_config_flow.py +++ b/tests/components/youtube/test_config_flow.py @@ -1,21 +1,30 @@ """Test the YouTube config flow.""" +import time from unittest.mock import patch import pytest from youtubeaio.types import ForbiddenError from homeassistant import config_entries -from homeassistant.components.youtube.const import CONF_CHANNELS, DOMAIN +from homeassistant.components.youtube.const import ( + CONF_CHANNEL_ID, + CONF_CHANNELS, + DOMAIN, + SUBENTRY_TYPE_CHANNEL, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.exceptions import OAuth2TokenRequestConnectionError from homeassistant.helpers import config_entry_oauth2_flow from . import MockYouTube from .conftest import ( + CHANNEL_ID, CLIENT_ID, GOOGLE_AUTH_URI, GOOGLE_TOKEN_URI, + LINUS_CHANNEL_ID, SCOPES, TITLE, ComponentSetup, @@ -69,7 +78,7 @@ async def test_full_flow( assert result["step_id"] == "channels" result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]} + result["flow_id"], user_input={CONF_CHANNELS: [CHANNEL_ID]} ) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 @@ -78,11 +87,19 @@ async def test_full_flow( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TITLE assert "result" in result - assert result["result"].unique_id == "UC_x5XG1OV2P6uZZ5FSM9Ttw" - assert "token" in result["result"].data - assert result["result"].data["token"]["access_token"] == "mock-access-token" - assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token" - assert result["options"] == {CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]} + entry = result["result"] + assert entry.unique_id == CHANNEL_ID + assert entry.version == 2 + assert "token" in entry.data + assert entry.data["token"]["access_token"] == "mock-access-token" + assert entry.data["token"]["refresh_token"] == "mock-refresh-token" + assert entry.options == {} + assert len(entry.subentries) == 1 + subentry = next(iter(entry.subentries.values())) + assert subentry.subentry_type == SUBENTRY_TYPE_CHANNEL + assert subentry.unique_id == CHANNEL_ID + assert subentry.title == "Google for Developers" + assert subentry.data == {CONF_CHANNEL_ID: CHANNEL_ID} @pytest.mark.usefixtures("current_request_with_host") @@ -215,23 +232,27 @@ async def test_flow_without_subscriptions( schema = result["data_schema"] channels = schema.schema[CONF_CHANNELS].config["options"] assert len(channels) == 1 - assert channels[0]["value"] == "UC_x5XG1OV2P6uZZ5FSM9Ttw" + assert channels[0]["value"] == CHANNEL_ID assert "(Your Channel)" in channels[0]["label"] # Test selecting the own channel result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]}, + user_input={CONF_CHANNELS: [CHANNEL_ID]}, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TITLE assert "result" in result - assert result["result"].unique_id == "UC_x5XG1OV2P6uZZ5FSM9Ttw" - assert "token" in result["result"].data - assert result["result"].data["token"]["access_token"] == "mock-access-token" - assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token" - assert result["options"] == {CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]} + entry = result["result"] + assert entry.unique_id == CHANNEL_ID + assert "token" in entry.data + assert entry.data["token"]["access_token"] == "mock-access-token" + assert entry.data["token"]["refresh_token"] == "mock-refresh-token" + assert entry.options == {} + assert len(entry.subentries) == 1 + subentry = next(iter(entry.subentries.values())) + assert subentry.unique_id == CHANNEL_ID @pytest.mark.usefixtures("current_request_with_host") @@ -384,7 +405,7 @@ async def test_reauth( assert result["description_placeholders"] == placeholders assert len(mock_setup.mock_calls) == call_count - assert config_entry.unique_id == "UC_x5XG1OV2P6uZZ5FSM9Ttw" + assert config_entry.unique_id == CHANNEL_ID assert "token" in config_entry.data # Verify access token is refreshed assert config_entry.data["token"]["access_token"] == access_token @@ -428,30 +449,70 @@ async def test_flow_exception( assert result["reason"] == "unknown" -async def test_options_flow( - hass: HomeAssistant, setup_integration: ComponentSetup +@pytest.mark.parametrize( + ("exception", "abort_reason", "placeholders"), + [ + ( + ForbiddenError( + "YouTube Data API v3 has not been used in project 0" + " before or it is disabled." + ), + "access_not_configured", + { + "message": "YouTube Data API v3 has not been used in project 0" + " before or it is disabled." + }, + ), + (Exception("Some failure"), "unknown", None), + ], + ids=["forbidden", "unknown"], +) +@pytest.mark.usefixtures("current_request_with_host") +async def test_flow_channel_listing_error( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + exception: Exception, + abort_reason: str, + placeholders: dict[str, str] | None, ) -> None: - """Test the full options flow.""" - await setup_integration() - with patch( - "homeassistant.components.youtube.config_flow.YouTube", - return_value=MockYouTube(hass), - ): - entry = hass.config_entries.async_entries(DOMAIN)[0] - result = await hass.config_entries.options.async_init(entry.entry_id) - await hass.async_block_till_done() + """Test the initial flow aborts when listing the channels fails.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" + assert result["url"] == ( + f"{GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope={'+'.join(SCOPES)}" + "&access_type=offline&prompt=consent" + ) - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]}, - ) - await hass.async_block_till_done() + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == {CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]} + # The account check succeeds, listing the subscriptions afterwards fails + mock = MockYouTube(hass) + with ( + patch("homeassistant.components.youtube.async_setup_entry", return_value=True), + patch( + "homeassistant.components.youtube.config_flow.YouTube", + return_value=mock, + ), + patch.object(mock, "get_user_subscriptions", side_effect=exception), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == abort_reason + assert result.get("description_placeholders") == placeholders @pytest.mark.usefixtures("current_request_with_host") @@ -500,17 +561,14 @@ async def test_own_channel_included( schema = result["data_schema"] channels = schema.schema[CONF_CHANNELS].config["options"] assert any( - channel["value"] == "UC_x5XG1OV2P6uZZ5FSM9Ttw" - and "(Your Channel)" in channel["label"] + channel["value"] == CHANNEL_ID and "(Your Channel)" in channel["label"] for channel in channels ) # Test selecting both own channel and a subscribed channel result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={ - CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw", "UC_x5XG1OV2P6uZZ5FSM9Ttw"] - }, + user_input={CONF_CHANNELS: [CHANNEL_ID, CHANNEL_ID]}, ) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 @@ -519,45 +577,300 @@ async def test_own_channel_included( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TITLE assert "result" in result - assert result["result"].unique_id == "UC_x5XG1OV2P6uZZ5FSM9Ttw" - assert "token" in result["result"].data - assert result["result"].data["token"]["access_token"] == "mock-access-token" - assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token" - assert result["options"] == { - CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw", "UC_x5XG1OV2P6uZZ5FSM9Ttw"] - } + entry = result["result"] + assert entry.unique_id == CHANNEL_ID + assert "token" in entry.data + assert entry.data["token"]["access_token"] == "mock-access-token" + assert entry.data["token"]["refresh_token"] == "mock-refresh-token" + assert entry.options == {} + # Duplicate selections are deduplicated into a single subentry + assert len(entry.subentries) == 1 + subentry = next(iter(entry.subentries.values())) + assert subentry.unique_id == CHANNEL_ID + assert subentry.title == "Google for Developers" + + +async def test_subentry_flow_add_channel( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test adding a channel subentry.""" + await setup_integration() + entry = hass.config_entries.async_entries(DOMAIN)[0] + # Listing shows the Linus channel, the Google channel is already tracked; + # the reload after adding needs both channels + mock = MockYouTube( + hass, + channel_fixture="get_channel_2.json", + extra_channel_fixtures=["get_channel.json"], + ) + with patch( + "homeassistant.components.youtube.api.YouTube", + return_value=mock, + ): + result = await hass.config_entries.subentries.async_init( + (entry.entry_id, SUBENTRY_TYPE_CHANNEL), + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" -async def test_options_flow_own_channel( + # The already configured channel is not selectable + options = result["data_schema"].schema[CONF_CHANNEL_ID].config["options"] + assert [option["value"] for option in options] == [LINUS_CHANNEL_ID] + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], user_input={CONF_CHANNEL_ID: LINUS_CHANNEL_ID} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Linus Tech Tips" + assert result["data"] == {CONF_CHANNEL_ID: LINUS_CHANNEL_ID} + assert len(entry.subentries) == 2 + new_subentry = next( + subentry + for subentry in entry.subentries.values() + if subentry.unique_id == LINUS_CHANNEL_ID + ) + assert new_subentry.title == "Linus Tech Tips" + assert hass.states.get("sensor.linus_tech_tips_subscribers") is not None + + +async def test_subentry_flow_no_channels_left( hass: HomeAssistant, setup_integration: ComponentSetup ) -> None: - """Test the options flow includes the user's own channel.""" + """Test the subentry flow aborts when all channels are already tracked.""" await setup_integration() + entry = hass.config_entries.async_entries(DOMAIN)[0] + with patch( - "homeassistant.components.youtube.config_flow.YouTube", + "homeassistant.components.youtube.api.YouTube", return_value=MockYouTube(hass), ): - entry = hass.config_entries.async_entries(DOMAIN)[0] - result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.subentries.async_init( + (entry.entry_id, SUBENTRY_TYPE_CHANNEL), + context={"source": config_entries.SOURCE_USER}, + ) await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_subscriptions" + + +async def test_subentry_flow_add_own_channel( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test the account's own channel can be added if not tracked yet.""" + await setup_integration() + entry = hass.config_entries.async_entries(DOMAIN)[0] + hass.config_entries.async_remove_subentry(entry, "channel_1") + await hass.async_block_till_done() + assert not entry.subentries + + with patch( + "homeassistant.components.youtube.api.YouTube", + return_value=MockYouTube(hass), + ): + result = await hass.config_entries.subentries.async_init( + (entry.entry_id, SUBENTRY_TYPE_CHANNEL), + context={"source": config_entries.SOURCE_USER}, + ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" + assert result["step_id"] == "user" - # Verify the form schema contains the user's own channel - schema = result["data_schema"] - channels = schema.schema[CONF_CHANNELS].config["options"] - assert any( - channel["value"] == "UC_x5XG1OV2P6uZZ5FSM9Ttw" - and "(Your Channel)" in channel["label"] - for channel in channels + options = result["data_schema"].schema[CONF_CHANNEL_ID].config["options"] + assert [option["value"] for option in options] == [CHANNEL_ID] + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], user_input={CONF_CHANNEL_ID: CHANNEL_ID} ) + await hass.async_block_till_done() - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]}, + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Google for Developers" + assert len(entry.subentries) == 1 + subentry = next(iter(entry.subentries.values())) + assert subentry.unique_id == CHANNEL_ID + assert subentry.title == "Google for Developers" + assert hass.states.get("sensor.google_for_developers_subscribers") is not None + + +async def test_subentry_flow_unknown_channel( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test the subentry flow aborts when the channel is not available.""" + await setup_integration() + entry = hass.config_entries.async_entries(DOMAIN)[0] + hass.config_entries.async_remove_subentry(entry, "channel_1") + await hass.async_block_till_done() + + # The subscription lists the channel, but it cannot be fetched anymore + mock = MockYouTube(hass, channel_fixture="get_no_channel.json") + with patch( + "homeassistant.components.youtube.api.YouTube", + return_value=mock, + ): + result = await hass.config_entries.subentries.async_init( + (entry.entry_id, SUBENTRY_TYPE_CHANNEL), + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], user_input={CONF_CHANNEL_ID: CHANNEL_ID} ) await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == {CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]} + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unknown_channel" + assert not entry.subentries + assert entry.state is config_entries.ConfigEntryState.LOADED + + +@pytest.mark.parametrize( + ("exception", "abort_reason", "placeholders"), + [ + ( + ForbiddenError( + "YouTube Data API v3 has not been used in project 0" + " before or it is disabled." + ), + "access_not_configured", + { + "message": "YouTube Data API v3 has not been used in project 0" + " before or it is disabled." + }, + ), + (Exception("Some failure"), "unknown", None), + ], + ids=["forbidden", "unknown"], +) +@pytest.mark.usefixtures("current_request_with_host") +async def test_subentry_flow_api_error_listing_channels( + hass: HomeAssistant, + setup_integration: ComponentSetup, + exception: Exception, + abort_reason: str, + placeholders: dict[str, str] | None, +) -> None: + """Test the subentry flow aborts when listing channels fails.""" + await setup_integration() + entry = hass.config_entries.async_entries(DOMAIN)[0] + + mock = MockYouTube(hass) + with ( + patch( + "homeassistant.components.youtube.api.YouTube", + return_value=mock, + ), + patch.object(mock, "get_user_channels", side_effect=exception), + ): + result = await hass.config_entries.subentries.async_init( + (entry.entry_id, SUBENTRY_TYPE_CHANNEL), + context={"source": config_entries.SOURCE_USER}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == abort_reason + assert result.get("description_placeholders") == placeholders + + +@pytest.mark.parametrize( + ("exception", "abort_reason", "placeholders"), + [ + ( + ForbiddenError( + "YouTube Data API v3 has not been used in project 0" + " before or it is disabled." + ), + "access_not_configured", + { + "message": "YouTube Data API v3 has not been used in project 0" + " before or it is disabled." + }, + ), + (Exception("Some failure"), "unknown", None), + ], + ids=["forbidden", "unknown"], +) +async def test_subentry_flow_api_error_fetching_channel( + hass: HomeAssistant, + setup_integration: ComponentSetup, + exception: Exception, + abort_reason: str, + placeholders: dict[str, str] | None, +) -> None: + """Test the subentry flow aborts when fetching the selected channel fails.""" + await setup_integration() + entry = hass.config_entries.async_entries(DOMAIN)[0] + hass.config_entries.async_remove_subentry(entry, "channel_1") + await hass.async_block_till_done() + + # Listing channels for the form succeeds, only get_channels raises + mock = MockYouTube(hass) + mock.set_thrown_exception(exception) + with patch( + "homeassistant.components.youtube.api.YouTube", + return_value=mock, + ): + result = await hass.config_entries.subentries.async_init( + (entry.entry_id, SUBENTRY_TYPE_CHANNEL), + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], user_input={CONF_CHANNEL_ID: CHANNEL_ID} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == abort_reason + assert result.get("description_placeholders") == placeholders + + +async def test_subentry_flow_token_refresh_error( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test the subentry flow aborts when the token cannot be refreshed.""" + await setup_integration() + entry = hass.config_entries.async_entries(DOMAIN)[0] + hass.config_entries.async_remove_subentry(entry, "channel_1") + await hass.async_block_till_done() + + with patch( + "homeassistant.components.youtube.api.YouTube", + return_value=MockYouTube(hass), + ): + # Listing channels succeeds with the still valid token + result = await hass.config_entries.subentries.async_init( + (entry.entry_id, SUBENTRY_TYPE_CHANNEL), + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + + # The token expires before the submission and the refresh fails + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + "token": { + **entry.data["token"], + "expires_at": time.time() - 3600, + }, + }, + ) + await hass.async_block_till_done() + with patch( + "homeassistant.components.youtube.OAuth2Session.async_ensure_token_valid", + side_effect=OAuth2TokenRequestConnectionError(domain=DOMAIN), + ): + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], user_input={CONF_CHANNEL_ID: CHANNEL_ID} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unknown" diff --git a/tests/components/youtube/test_init.py b/tests/components/youtube/test_init.py index 8930e7d82bf87e..06010942efbd1b 100644 --- a/tests/components/youtube/test_init.py +++ b/tests/components/youtube/test_init.py @@ -1,22 +1,41 @@ """Tests for YouTube.""" +from collections.abc import AsyncGenerator import http import time from unittest.mock import patch import pytest +from youtubeaio.models import YouTubeChannel -from homeassistant.components.youtube.const import CONF_CHANNELS, DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.components.youtube.const import ( + CONF_CHANNEL_ID, + CONF_CHANNELS, + DOMAIN, + SUBENTRY_TYPE_CHANNEL, +) +from homeassistant.components.youtube.diagnostics import ( + async_get_config_entry_diagnostics, +) +from homeassistant.config_entries import ConfigEntryState, ConfigSubentryData from homeassistant.core import HomeAssistant from homeassistant.exceptions import OAuth2TokenRequestConnectionError -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, ) -from .conftest import GOOGLE_TOKEN_URI, ComponentSetup +from . import MockYouTube +from .conftest import ( + CHANNEL_ID, + GOOGLE_TOKEN_URI, + LINUS_CHANNEL_ID, + TITLE, + ComponentSetup, + mock_entry_data, +) +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -128,17 +147,357 @@ async def test_device_info( await setup_integration() entry = hass.config_entries.async_entries(DOMAIN)[0] - channel_id = entry.options[CONF_CHANNELS][0] device = device_registry.async_get_device_by_identifier( - (DOMAIN, f"{entry.entry_id}_{channel_id}"), entry.entry_id + (DOMAIN, CHANNEL_ID), entry.entry_id ) assert device.entry_type is dr.DeviceEntryType.SERVICE - assert device.identifiers == {(DOMAIN, f"{entry.entry_id}_{channel_id}")} + assert device.identifiers == {(DOMAIN, CHANNEL_ID)} assert device.manufacturer == "Google, Inc." assert device.name == "Google for Developers" +async def test_migration( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + expires_at: int, + scopes: list[str], +) -> None: + """Test migration of an options based entry to the subentry structure.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=TITLE, + unique_id=CHANNEL_ID, + version=1, + data=mock_entry_data(expires_at, scopes), + options={CONF_CHANNELS: [CHANNEL_ID]}, + ) + entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + entry_type=dr.DeviceEntryType.SERVICE, + identifiers={(DOMAIN, f"{entry.entry_id}_{CHANNEL_ID}")}, + manufacturer="Google, Inc.", + # Stale name: the channel was renamed since the device was created + name="Google Developers Channel", + ) + entity_registry.async_get_or_create( + "sensor", + DOMAIN, + f"{entry.entry_id}_{CHANNEL_ID}_subscribers", + config_entry=entry, + device_id=device.id, + suggested_object_id="google_for_developers_subscribers", + ) + # Device and entity left behind by a channel which is no longer tracked, + # as happened when it was removed from the options before this migration. + orphan_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + entry_type=dr.DeviceEntryType.SERVICE, + identifiers={(DOMAIN, f"{entry.entry_id}_{LINUS_CHANNEL_ID}")}, + manufacturer="Google, Inc.", + name="Linus Tech Tips", + ) + entity_registry.async_get_or_create( + "sensor", + DOMAIN, + f"{entry.entry_id}_{LINUS_CHANNEL_ID}_subscribers", + config_entry=entry, + device_id=orphan_device.id, + suggested_object_id="linus_tech_tips_subscribers", + ) + + with patch( + "homeassistant.components.youtube.api.YouTube", + return_value=MockYouTube(hass), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert entry.version == 2 + assert entry.options == {} + assert len(entry.subentries) == 1 + subentry = next(iter(entry.subentries.values())) + assert subentry.subentry_type == SUBENTRY_TYPE_CHANNEL + assert subentry.unique_id == CHANNEL_ID + assert subentry.title == "Google for Developers" + assert subentry.data == {CONF_CHANNEL_ID: CHANNEL_ID} + + migrated_device = device_registry.async_get_device_by_identifier( + (DOMAIN, CHANNEL_ID), entry.entry_id + ) + assert migrated_device is not None + assert migrated_device.id == device.id + assert migrated_device.config_subentry_id == subentry.subentry_id + + # The unique id keeps the entry id prefix so two accounts can track + # the same channel. + migrated_entity_id = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{entry.entry_id}_{CHANNEL_ID}_subscribers" + ) + assert migrated_entity_id is not None + migrated_entity = entity_registry.async_get(migrated_entity_id) + assert migrated_entity is not None + assert migrated_entity.config_subentry_id == subentry.subentry_id + assert migrated_entity.device_id == device.id + + # The untracked channel's device and entity are cleaned up + assert device_registry.async_get(orphan_device.id) is None + assert ( + entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{entry.entry_id}_{LINUS_CHANNEL_ID}_subscribers" + ) + is None + ) + assert hass.states.get("sensor.google_for_developers_subscribers") is not None + + +async def test_remove_subentry( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + setup_integration: ComponentSetup, +) -> None: + """Test removing a channel subentry removes its device and entities.""" + await setup_integration() + + entry = hass.config_entries.async_entries(DOMAIN)[0] + subentry = next(iter(entry.subentries.values())) + device = device_registry.async_get_device_by_identifier( + (DOMAIN, CHANNEL_ID), entry.entry_id + ) + assert device is not None + + hass.config_entries.async_remove_subentry(entry, subentry.subentry_id) + await hass.async_block_till_done() + + assert not entry.subentries + assert ( + device_registry.async_get_device_by_identifier( + (DOMAIN, CHANNEL_ID), entry.entry_id + ) + is None + ) + assert hass.states.get("sensor.google_for_developers_subscribers") is None + + +async def test_channel_missing_from_api( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + setup_integration: ComponentSetup, +) -> None: + """Test the channel's entities are unavailable when the API omits it.""" + await setup_integration() + + entry = hass.config_entries.async_entries(DOMAIN)[0] + device = device_registry.async_get_device_by_identifier( + (DOMAIN, CHANNEL_ID), entry.entry_id + ) + assert device is not None + + with patch( + "homeassistant.components.youtube.api.AsyncConfigEntryAuth.get_resource", + return_value=MockYouTube(hass, channel_fixture="get_no_channel.json"), + ): + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + state = hass.states.get("sensor.google_for_developers_subscribers") + assert state is not None + assert state.state == "unavailable" + assert ( + device_registry.async_get_device_by_identifier( + (DOMAIN, CHANNEL_ID), entry.entry_id + ) + is not None + ) + + +async def test_missing_channel_does_not_affect_other_channels( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + expires_at: int, + scopes: list[str], +) -> None: + """Test a channel missing from the API only takes itself down.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=TITLE, + unique_id=CHANNEL_ID, + version=2, + data=mock_entry_data(expires_at, scopes), + subentries_data=[ + ConfigSubentryData( + data={CONF_CHANNEL_ID: CHANNEL_ID}, + subentry_id="channel_1", + subentry_type=SUBENTRY_TYPE_CHANNEL, + title="Google for Developers", + unique_id=CHANNEL_ID, + ), + ConfigSubentryData( + data={CONF_CHANNEL_ID: LINUS_CHANNEL_ID}, + subentry_id="channel_2", + subentry_type=SUBENTRY_TYPE_CHANNEL, + title="Linus Tech Tips", + unique_id=LINUS_CHANNEL_ID, + ), + ], + ) + entry.add_to_hass(hass) + + # The API only returns the Google channel, the Linus channel is missing + with patch( + "homeassistant.components.youtube.api.YouTube", + return_value=MockYouTube(hass), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + state = hass.states.get("sensor.google_for_developers_subscribers") + assert state is not None + assert state.state == "2290000" + state = hass.states.get("sensor.linus_tech_tips_subscribers") + assert state is not None + assert state.state == "unavailable" + assert ( + device_registry.async_get_device_by_identifier( + (DOMAIN, CHANNEL_ID), entry.entry_id + ) + is not None + ) + assert ( + device_registry.async_get_device_by_identifier( + (DOMAIN, LINUS_CHANNEL_ID), entry.entry_id + ) + is not None + ) + + diagnostics = await async_get_config_entry_diagnostics(hass, entry) + assert diagnostics[CHANNEL_ID]["title"] == "Google for Developers" + assert LINUS_CHANNEL_ID not in diagnostics + + +async def test_more_channels_than_the_api_limit_are_chunked( + hass: HomeAssistant, + expires_at: int, + scopes: list[str], +) -> None: + """Test channel ids are fetched in chunks of at most 50.""" + channel_ids = [f"UC_channel_{i}" for i in range(51)] + entry = MockConfigEntry( + domain=DOMAIN, + title=TITLE, + unique_id=CHANNEL_ID, + version=2, + data=mock_entry_data(expires_at, scopes), + subentries_data=[ + ConfigSubentryData( + data={CONF_CHANNEL_ID: channel_id}, + subentry_id=f"channel_{i}", + subentry_type=SUBENTRY_TYPE_CHANNEL, + title=f"Channel {i}", + unique_id=channel_id, + ) + for i, channel_id in enumerate(channel_ids) + ], + ) + entry.add_to_hass(hass) + + mock = MockYouTube(hass) + requested_id_chunks: list[list[str]] = [] + mock_get_channels = mock.get_channels + + async def get_channels(channel_ids: list[str]) -> AsyncGenerator[YouTubeChannel]: + requested_id_chunks.append(list(channel_ids)) + async for channel in mock_get_channels(channel_ids): + yield channel + + mock.get_channels = get_channels + + # None of the channels is known to the API + with patch( + "homeassistant.components.youtube.api.YouTube", + return_value=mock, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert requested_id_chunks == [channel_ids[:50], channel_ids[50:]] + assert entry.runtime_data.data == {} + state = hass.states.get("sensor.channel_0_subscribers") + assert state is not None + assert state.state == "unavailable" + + +async def test_migration_channel_missing_from_api( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + expires_at: int, + scopes: list[str], +) -> None: + """Test a migrated channel keeps its name when it is not available.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=TITLE, + unique_id=CHANNEL_ID, + version=1, + data=mock_entry_data(expires_at, scopes), + options={CONF_CHANNELS: [CHANNEL_ID]}, + ) + entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + entry_type=dr.DeviceEntryType.SERVICE, + identifiers={(DOMAIN, f"{entry.entry_id}_{CHANNEL_ID}")}, + manufacturer="Google, Inc.", + name="Google for Developers", + ) + + with patch( + "homeassistant.components.youtube.api.YouTube", + return_value=MockYouTube(hass, channel_fixture="get_no_channel.json"), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert len(entry.subentries) == 1 + subentry = next(iter(entry.subentries.values())) + assert subentry.title == "Google for Developers" + state = hass.states.get("sensor.google_for_developers_subscribers") + assert state is not None + assert state.state == "unavailable" + device = device_registry.async_get_device_by_identifier( + (DOMAIN, CHANNEL_ID), entry.entry_id + ) + assert device is not None + assert device.name == "Google for Developers" + + +async def test_entry_data_update_does_not_reload( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test token refreshes and other entry data updates do not reload.""" + await setup_integration() + entry = hass.config_entries.async_entries(DOMAIN)[0] + coordinator = entry.runtime_data + + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + "token": {**entry.data["token"], "access_token": "updated-access-token"}, + }, + ) + await hass.async_block_till_done() + + assert entry.runtime_data is coordinator + + async def test_oauth_implementation_not_available( hass: HomeAssistant, setup_integration: ComponentSetup ) -> None: