From fcd4d3e2dff551ee64e89f2f82a833172a795db8 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 29 Mar 2025 00:59:24 +0100 Subject: [PATCH] Add ability to subscribe to own YouTube channels (#141693) --- .../components/youtube/config_flow.py | 77 ++++++-- tests/components/youtube/test_config_flow.py | 181 +++++++++++++++++- 2 files changed, 244 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/youtube/config_flow.py b/homeassistant/components/youtube/config_flow.py index 48336422585..76d74965b34 100644 --- a/homeassistant/components/youtube/config_flow.py +++ b/homeassistant/components/youtube/config_flow.py @@ -7,7 +7,6 @@ import logging from typing import Any import voluptuous as vol -from youtubeaio.helper import first from youtubeaio.types import AuthScope, ForbiddenError from youtubeaio.youtube import YouTube @@ -96,8 +95,12 @@ class OAuth2FlowHandler( """Create an entry for the flow, or update existing entry.""" try: 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: + 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}, @@ -111,10 +114,10 @@ class OAuth2FlowHandler( except Exception as ex: # noqa: BLE001 LOGGER.error("Unknown error occurred: %s", ex.args) return self.async_abort(reason="unknown") - self._title = own_channel.snippet.title + self._title = own_channels[0].snippet.title self._data = data - await self.async_set_unique_id(own_channel.channel_id) + await self.async_set_unique_id(own_channels[0].channel_id) if self.source != SOURCE_REAUTH: self._abort_if_unique_id_configured() @@ -138,13 +141,39 @@ class OAuth2FlowHandler( options=user_input, ) 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}, + ) + + # Start with user's own channels selectable_channels = [ SelectOptionDict( - value=subscription.snippet.channel_id, - label=subscription.snippet.title, + value=channel.channel_id, + label=f"{channel.snippet.title} (Your Channel)", ) - async for subscription in youtube.get_user_subscriptions() + 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") return self.async_show_form( @@ -175,13 +204,39 @@ class YouTubeOptionsFlowHandler(OptionsFlow): 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: + return self.async_abort( + reason="no_channel", + description_placeholders={"support_url": CHANNEL_CREATION_HELP_URL}, + ) + + # Start with user's own channels selectable_channels = [ SelectOptionDict( - value=subscription.snippet.channel_id, - label=subscription.snippet.title, + value=channel.channel_id, + label=f"{channel.snippet.title} (Your Channel)", ) - async for subscription in youtube.get_user_subscriptions() + 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() + ] + ) + return self.async_show_form( step_id="init", data_schema=self.add_suggested_values_to_schema( diff --git a/tests/components/youtube/test_config_flow.py b/tests/components/youtube/test_config_flow.py index 73652d9b239..2cfb970928d 100644 --- a/tests/components/youtube/test_config_flow.py +++ b/tests/components/youtube/test_config_flow.py @@ -131,7 +131,51 @@ async def test_flow_abort_without_subscriptions( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, ) -> None: - """Check abort flow if user has no subscriptions.""" + """Check abort flow if user has no subscriptions and no own channel.""" + result = await hass.config_entries.flow.async_init( + "youtube", 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["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" + ) + + 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" + + service = MockYouTube( + channel_fixture="youtube/get_no_channel.json", + subscriptions_fixture="youtube/get_no_subscriptions.json", + ) + with ( + patch("homeassistant.components.youtube.async_setup_entry", return_value=True), + patch( + "homeassistant.components.youtube.config_flow.YouTube", return_value=service + ), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_channel" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_flow_without_subscriptions( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Check flow continues even without subscriptions since user has their own channel.""" result = await hass.config_entries.flow.async_init( "youtube", context={"source": config_entries.SOURCE_USER} ) @@ -163,8 +207,30 @@ async def test_flow_abort_without_subscriptions( ), ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_subscriptions" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "channels" + + # Verify the form schema contains only the user's own channel + schema = result["data_schema"] + channels = schema.schema[CONF_CHANNELS].config["options"] + assert len(channels) == 1 + assert channels[0]["value"] == "UC_x5XG1OV2P6uZZ5FSM9Ttw" + 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"]}, + ) + + 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"]} @pytest.mark.usefixtures("current_request_with_host") @@ -373,3 +439,112 @@ async def test_options_flow( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == {CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]} + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_own_channel_included( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test that the user's own channel is included in the list of selectable channels.""" + result = await hass.config_entries.flow.async_init( + "youtube", 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["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" + ) + + 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" + + with ( + patch( + "homeassistant.components.youtube.async_setup_entry", return_value=True + ) as mock_setup, + patch( + "homeassistant.components.youtube.config_flow.YouTube", + return_value=MockYouTube(), + ), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "channels" + + # 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 + ) + + # 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"] + }, + ) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + 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"] + } + + +async def test_options_flow_own_channel( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test the options flow includes the user's own channel.""" + await setup_integration() + with patch( + "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) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + # 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 + ) + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]}