Add ability to subscribe to own YouTube channels (#141693)

This commit is contained in:
Franck Nijhof 2025-03-29 00:59:24 +01:00 committed by GitHub
parent 42d6bd3839
commit fcd4d3e2df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 244 additions and 14 deletions

View File

@ -7,7 +7,6 @@ import logging
from typing import Any from typing import Any
import voluptuous as vol import voluptuous as vol
from youtubeaio.helper import first
from youtubeaio.types import AuthScope, ForbiddenError from youtubeaio.types import AuthScope, ForbiddenError
from youtubeaio.youtube import YouTube from youtubeaio.youtube import YouTube
@ -96,8 +95,12 @@ class OAuth2FlowHandler(
"""Create an entry for the flow, or update existing entry.""" """Create an entry for the flow, or update existing entry."""
try: try:
youtube = await self.get_resource(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) youtube = await self.get_resource(data[CONF_TOKEN][CONF_ACCESS_TOKEN])
own_channel = await first(youtube.get_user_channels()) own_channels = [
if own_channel is None or own_channel.snippet is None: channel
async for channel in youtube.get_user_channels()
if channel.snippet is not None
]
if not own_channels:
return self.async_abort( return self.async_abort(
reason="no_channel", reason="no_channel",
description_placeholders={"support_url": CHANNEL_CREATION_HELP_URL}, description_placeholders={"support_url": CHANNEL_CREATION_HELP_URL},
@ -111,10 +114,10 @@ class OAuth2FlowHandler(
except Exception as ex: # noqa: BLE001 except Exception as ex: # noqa: BLE001
LOGGER.error("Unknown error occurred: %s", ex.args) LOGGER.error("Unknown error occurred: %s", ex.args)
return self.async_abort(reason="unknown") return self.async_abort(reason="unknown")
self._title = own_channel.snippet.title self._title = own_channels[0].snippet.title
self._data = data 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: if self.source != SOURCE_REAUTH:
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
@ -138,13 +141,39 @@ class OAuth2FlowHandler(
options=user_input, options=user_input,
) )
youtube = await self.get_resource(self._data[CONF_TOKEN][CONF_ACCESS_TOKEN]) 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 = [ selectable_channels = [
SelectOptionDict(
value=channel.channel_id,
label=f"{channel.snippet.title} (Your Channel)",
)
for channel in own_channels
]
# Add subscribed channels
selectable_channels.extend(
[
SelectOptionDict( SelectOptionDict(
value=subscription.snippet.channel_id, value=subscription.snippet.channel_id,
label=subscription.snippet.title, label=subscription.snippet.title,
) )
async for subscription in youtube.get_user_subscriptions() async for subscription in youtube.get_user_subscriptions()
] ]
)
if not selectable_channels: if not selectable_channels:
return self.async_abort(reason="no_subscriptions") return self.async_abort(reason="no_subscriptions")
return self.async_show_form( return self.async_show_form(
@ -175,13 +204,39 @@ class YouTubeOptionsFlowHandler(OptionsFlow):
await youtube.set_user_authentication( await youtube.set_user_authentication(
self.config_entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN], [AuthScope.READ_ONLY] 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 = [ selectable_channels = [
SelectOptionDict(
value=channel.channel_id,
label=f"{channel.snippet.title} (Your Channel)",
)
for channel in own_channels
]
# Add subscribed channels
selectable_channels.extend(
[
SelectOptionDict( SelectOptionDict(
value=subscription.snippet.channel_id, value=subscription.snippet.channel_id,
label=subscription.snippet.title, label=subscription.snippet.title,
) )
async for subscription in youtube.get_user_subscriptions() async for subscription in youtube.get_user_subscriptions()
] ]
)
return self.async_show_form( return self.async_show_form(
step_id="init", step_id="init",
data_schema=self.add_suggested_values_to_schema( data_schema=self.add_suggested_values_to_schema(

View File

@ -131,7 +131,51 @@ async def test_flow_abort_without_subscriptions(
hass: HomeAssistant, hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator, hass_client_no_auth: ClientSessionGenerator,
) -> None: ) -> 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( result = await hass.config_entries.flow.async_init(
"youtube", context={"source": config_entries.SOURCE_USER} "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"]) result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT assert result["type"] is FlowResultType.FORM
assert result["reason"] == "no_subscriptions" 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") @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["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]} 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"]}