Fix Twitch component to use new API (#67153)

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
This commit is contained in:
Mark Dietzer 2022-02-25 09:15:49 -08:00 committed by GitHub
parent a4ba71408b
commit 07a792019e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 164 additions and 102 deletions

View file

@ -1 +1 @@
"""The twitch component.""" """The Twitch component."""

View file

@ -2,7 +2,7 @@
"domain": "twitch", "domain": "twitch",
"name": "Twitch", "name": "Twitch",
"documentation": "https://www.home-assistant.io/integrations/twitch", "documentation": "https://www.home-assistant.io/integrations/twitch",
"requirements": ["python-twitch-client==0.6.0"], "requirements": ["twitchAPI==2.5.2"],
"codeowners": [], "codeowners": [],
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["twitch"] "loggers": ["twitch"]

View file

@ -3,12 +3,18 @@ from __future__ import annotations
import logging import logging
from requests.exceptions import HTTPError from twitchAPI.twitch import (
from twitch import TwitchClient AuthScope,
AuthType,
InvalidTokenException,
MissingScopeException,
Twitch,
TwitchAuthorizationException,
)
import voluptuous as vol import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity
from homeassistant.const import CONF_CLIENT_ID, CONF_TOKEN from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_TOKEN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -33,9 +39,12 @@ ICON = "mdi:twitch"
STATE_OFFLINE = "offline" STATE_OFFLINE = "offline"
STATE_STREAMING = "streaming" STATE_STREAMING = "streaming"
OAUTH_SCOPES = [AuthScope.USER_READ_SUBSCRIPTIONS]
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{ {
vol.Required(CONF_CLIENT_ID): cv.string, vol.Required(CONF_CLIENT_ID): cv.string,
vol.Required(CONF_CLIENT_SECRET): cv.string,
vol.Required(CONF_CHANNELS): vol.All(cv.ensure_list, [cv.string]), vol.Required(CONF_CHANNELS): vol.All(cv.ensure_list, [cv.string]),
vol.Optional(CONF_TOKEN): cv.string, vol.Optional(CONF_TOKEN): cv.string,
} }
@ -51,28 +60,45 @@ def setup_platform(
"""Set up the Twitch platform.""" """Set up the Twitch platform."""
channels = config[CONF_CHANNELS] channels = config[CONF_CHANNELS]
client_id = config[CONF_CLIENT_ID] client_id = config[CONF_CLIENT_ID]
client_secret = config[CONF_CLIENT_SECRET]
oauth_token = config.get(CONF_TOKEN) oauth_token = config.get(CONF_TOKEN)
client = TwitchClient(client_id, oauth_token) client = Twitch(app_id=client_id, app_secret=client_secret)
client.auto_refresh_auth = False
try: try:
client.ingests.get_server_list() client.authenticate_app(scope=OAUTH_SCOPES)
except HTTPError: except TwitchAuthorizationException:
_LOGGER.error("Client ID or OAuth token is not valid") _LOGGER.error("INvalid client ID or client secret")
return return
channel_ids = client.users.translate_usernames_to_ids(channels) if oauth_token:
try:
client.set_user_authentication(
token=oauth_token, scope=OAUTH_SCOPES, validate=True
)
except MissingScopeException:
_LOGGER.error("OAuth token is missing required scope")
return
except InvalidTokenException:
_LOGGER.error("OAuth token is invalid")
return
add_entities([TwitchSensor(channel_id, client) for channel_id in channel_ids], True) channels = client.get_users(logins=channels)
add_entities(
[TwitchSensor(channel=channel, client=client) for channel in channels["data"]],
True,
)
class TwitchSensor(SensorEntity): class TwitchSensor(SensorEntity):
"""Representation of an Twitch channel.""" """Representation of an Twitch channel."""
def __init__(self, channel, client): def __init__(self, channel, client: Twitch):
"""Initialize the sensor.""" """Initialize the sensor."""
self._client = client self._client = client
self._channel = channel self._channel = channel
self._oauth_enabled = client._oauth_token is not None self._enable_user_auth = client.has_required_auth(AuthType.USER, OAUTH_SCOPES)
self._state = None self._state = None
self._preview = None self._preview = None
self._game = None self._game = None
@ -84,7 +110,7 @@ class TwitchSensor(SensorEntity):
@property @property
def name(self): def name(self):
"""Return the name of the sensor.""" """Return the name of the sensor."""
return self._channel.display_name return self._channel["display_name"]
@property @property
def native_value(self): def native_value(self):
@ -101,7 +127,7 @@ class TwitchSensor(SensorEntity):
"""Return the state attributes.""" """Return the state attributes."""
attr = dict(self._statistics) attr = dict(self._statistics)
if self._oauth_enabled: if self._enable_user_auth:
attr.update(self._subscription) attr.update(self._subscription)
attr.update(self._follow) attr.update(self._follow)
@ -112,7 +138,7 @@ class TwitchSensor(SensorEntity):
@property @property
def unique_id(self): def unique_id(self):
"""Return unique ID for this sensor.""" """Return unique ID for this sensor."""
return self._channel.id return self._channel["id"]
@property @property
def icon(self): def icon(self):
@ -122,41 +148,51 @@ class TwitchSensor(SensorEntity):
def update(self): def update(self):
"""Update device state.""" """Update device state."""
channel = self._client.channels.get_by_id(self._channel.id) followers = self._client.get_users_follows(to_id=self._channel["id"])["total"]
channel = self._client.get_users(user_ids=[self._channel["id"]])["data"][0]
self._statistics = { self._statistics = {
ATTR_FOLLOWING: channel.followers, ATTR_FOLLOWING: followers,
ATTR_VIEWS: channel.views, ATTR_VIEWS: channel["view_count"],
} }
if self._oauth_enabled: if self._enable_user_auth:
user = self._client.users.get() user = self._client.get_users()["data"][0]
try: subs = self._client.check_user_subscription(
sub = self._client.users.check_subscribed_to_channel( user_id=user["id"], broadcaster_id=self._channel["id"]
user.id, self._channel.id )
) if "data" in subs:
self._subscription = { self._subscription = {
ATTR_SUBSCRIPTION: True, ATTR_SUBSCRIPTION: True,
ATTR_SUBSCRIPTION_SINCE: sub.created_at, ATTR_SUBSCRIPTION_GIFTED: subs["data"][0]["is_gift"],
ATTR_SUBSCRIPTION_GIFTED: sub.is_gift,
} }
except HTTPError: elif "status" in subs and subs["status"] == 404:
self._subscription = {ATTR_SUBSCRIPTION: False} self._subscription = {ATTR_SUBSCRIPTION: False}
elif "error" in subs:
try: raise Exception(
follow = self._client.users.check_follows_channel( f"Error response on check_user_subscription: {subs['error']}"
user.id, self._channel.id
) )
self._follow = {ATTR_FOLLOW: True, ATTR_FOLLOW_SINCE: follow.created_at} else:
except HTTPError: raise Exception("Unknown error response on check_user_subscription")
follows = self._client.get_users_follows(
from_id=user["id"], to_id=self._channel["id"]
)["data"]
if len(follows) > 0:
self._follow = {
ATTR_FOLLOW: True,
ATTR_FOLLOW_SINCE: follows[0]["followed_at"],
}
else:
self._follow = {ATTR_FOLLOW: False} self._follow = {ATTR_FOLLOW: False}
stream = self._client.streams.get_stream_by_user(self._channel.id) streams = self._client.get_streams(user_id=[self._channel["id"]])["data"]
if stream: if len(streams) > 0:
self._game = stream.channel.get("game") stream = streams[0]
self._title = stream.channel.get("status") self._game = stream["game_name"]
self._preview = stream.preview.get("medium") self._title = stream["title"]
self._preview = stream["thumbnail_url"]
self._state = STATE_STREAMING self._state = STATE_STREAMING
else: else:
self._preview = self._channel.logo self._preview = channel["offline_image_url"]
self._state = STATE_OFFLINE self._state = STATE_OFFLINE

View file

@ -1954,9 +1954,6 @@ python-tado==0.12.0
# homeassistant.components.telegram_bot # homeassistant.components.telegram_bot
python-telegram-bot==13.1 python-telegram-bot==13.1
# homeassistant.components.twitch
python-twitch-client==0.6.0
# homeassistant.components.vlc # homeassistant.components.vlc
python-vlc==1.1.2 python-vlc==1.1.2
@ -2360,6 +2357,9 @@ twentemilieu==0.5.0
# homeassistant.components.twilio # homeassistant.components.twilio
twilio==6.32.0 twilio==6.32.0
# homeassistant.components.twitch
twitchAPI==2.5.2
# homeassistant.components.rainforest_eagle # homeassistant.components.rainforest_eagle
uEagle==0.0.2 uEagle==0.0.2

View file

@ -1235,9 +1235,6 @@ python-songpal==0.14
# homeassistant.components.tado # homeassistant.components.tado
python-tado==0.12.0 python-tado==0.12.0
# homeassistant.components.twitch
python-twitch-client==0.6.0
# homeassistant.components.awair # homeassistant.components.awair
python_awair==0.2.1 python_awair==0.2.1
@ -1467,6 +1464,9 @@ twentemilieu==0.5.0
# homeassistant.components.twilio # homeassistant.components.twilio
twilio==6.32.0 twilio==6.32.0
# homeassistant.components.twitch
twitchAPI==2.5.2
# homeassistant.components.rainforest_eagle # homeassistant.components.rainforest_eagle
uEagle==0.0.2 uEagle==0.0.2

View file

@ -1,11 +1,8 @@
"""The tests for an update of the Twitch component.""" """The tests for an update of the Twitch component."""
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from requests import HTTPError
from twitch.resources import Channel, Follow, Stream, Subscription, User
from homeassistant.components import sensor from homeassistant.components import sensor
from homeassistant.const import CONF_CLIENT_ID from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
ENTITY_ID = "sensor.channel123" ENTITY_ID = "sensor.channel123"
@ -13,6 +10,7 @@ CONFIG = {
sensor.DOMAIN: { sensor.DOMAIN: {
"platform": "twitch", "platform": "twitch",
CONF_CLIENT_ID: "1234", CONF_CLIENT_ID: "1234",
CONF_CLIENT_SECRET: " abcd",
"channels": ["channel123"], "channels": ["channel123"],
} }
} }
@ -20,39 +18,46 @@ CONFIG_WITH_OAUTH = {
sensor.DOMAIN: { sensor.DOMAIN: {
"platform": "twitch", "platform": "twitch",
CONF_CLIENT_ID: "1234", CONF_CLIENT_ID: "1234",
CONF_CLIENT_SECRET: "abcd",
"channels": ["channel123"], "channels": ["channel123"],
"token": "9876", "token": "9876",
} }
} }
USER_ID = User({"id": 123, "display_name": "channel123", "logo": "logo.png"}) USER_OBJECT = {
STREAM_OBJECT_ONLINE = Stream( "id": 123,
{ "display_name": "channel123",
"channel": {"game": "Good Game", "status": "Title"}, "offline_image_url": "logo.png",
"preview": {"medium": "stream-medium.png"}, "view_count": 42,
} }
) STREAM_OBJECT_ONLINE = {
CHANNEL_OBJECT = Channel({"followers": 42, "views": 24}) "game_name": "Good Game",
OAUTH_USER_ID = User({"id": 987}) "title": "Title",
SUB_ACTIVE = Subscription({"created_at": "2020-01-20T21:22:42", "is_gift": False}) "thumbnail_url": "stream-medium.png",
FOLLOW_ACTIVE = Follow({"created_at": "2020-01-20T21:22:42"}) }
FOLLOWERS_OBJECT = [{"followed_at": "2020-01-20T21:22:42"}] * 24
OAUTH_USER_ID = {"id": 987}
SUB_ACTIVE = {"is_gift": False}
FOLLOW_ACTIVE = {"followed_at": "2020-01-20T21:22:42"}
def make_data(data):
"""Create a data object."""
return {"data": data, "total": len(data)}
async def test_init(hass): async def test_init(hass):
"""Test initial config.""" """Test initial config."""
channels = MagicMock()
channels.get_by_id.return_value = CHANNEL_OBJECT
streams = MagicMock()
streams.get_stream_by_user.return_value = None
twitch_mock = MagicMock() twitch_mock = MagicMock()
twitch_mock.users.translate_usernames_to_ids.return_value = [USER_ID] twitch_mock.get_streams.return_value = make_data([])
twitch_mock.channels = channels twitch_mock.get_users.return_value = make_data([USER_OBJECT])
twitch_mock.streams = streams twitch_mock.get_users_follows.return_value = make_data(FOLLOWERS_OBJECT)
twitch_mock.has_required_auth.return_value = False
with patch( with patch(
"homeassistant.components.twitch.sensor.TwitchClient", return_value=twitch_mock "homeassistant.components.twitch.sensor.Twitch", return_value=twitch_mock
): ):
assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True
await hass.async_block_till_done() await hass.async_block_till_done()
@ -62,20 +67,21 @@ async def test_init(hass):
assert sensor_state.name == "channel123" assert sensor_state.name == "channel123"
assert sensor_state.attributes["icon"] == "mdi:twitch" assert sensor_state.attributes["icon"] == "mdi:twitch"
assert sensor_state.attributes["friendly_name"] == "channel123" assert sensor_state.attributes["friendly_name"] == "channel123"
assert sensor_state.attributes["views"] == 24 assert sensor_state.attributes["views"] == 42
assert sensor_state.attributes["followers"] == 42 assert sensor_state.attributes["followers"] == 24
async def test_offline(hass): async def test_offline(hass):
"""Test offline state.""" """Test offline state."""
twitch_mock = MagicMock() twitch_mock = MagicMock()
twitch_mock.users.translate_usernames_to_ids.return_value = [USER_ID] twitch_mock.get_streams.return_value = make_data([])
twitch_mock.channels.get_by_id.return_value = CHANNEL_OBJECT twitch_mock.get_users.return_value = make_data([USER_OBJECT])
twitch_mock.streams.get_stream_by_user.return_value = None twitch_mock.get_users_follows.return_value = make_data(FOLLOWERS_OBJECT)
twitch_mock.has_required_auth.return_value = False
with patch( with patch(
"homeassistant.components.twitch.sensor.TwitchClient", "homeassistant.components.twitch.sensor.Twitch",
return_value=twitch_mock, return_value=twitch_mock,
): ):
assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True
@ -90,12 +96,13 @@ async def test_streaming(hass):
"""Test streaming state.""" """Test streaming state."""
twitch_mock = MagicMock() twitch_mock = MagicMock()
twitch_mock.users.translate_usernames_to_ids.return_value = [USER_ID] twitch_mock.get_users.return_value = make_data([USER_OBJECT])
twitch_mock.channels.get_by_id.return_value = CHANNEL_OBJECT twitch_mock.get_users_follows.return_value = make_data(FOLLOWERS_OBJECT)
twitch_mock.streams.get_stream_by_user.return_value = STREAM_OBJECT_ONLINE twitch_mock.get_streams.return_value = make_data([STREAM_OBJECT_ONLINE])
twitch_mock.has_required_auth.return_value = False
with patch( with patch(
"homeassistant.components.twitch.sensor.TwitchClient", "homeassistant.components.twitch.sensor.Twitch",
return_value=twitch_mock, return_value=twitch_mock,
): ):
assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True
@ -112,15 +119,21 @@ async def test_oauth_without_sub_and_follow(hass):
"""Test state with oauth.""" """Test state with oauth."""
twitch_mock = MagicMock() twitch_mock = MagicMock()
twitch_mock.users.translate_usernames_to_ids.return_value = [USER_ID] twitch_mock.get_streams.return_value = make_data([])
twitch_mock.channels.get_by_id.return_value = CHANNEL_OBJECT twitch_mock.get_users.side_effect = [
twitch_mock._oauth_token = True # A replacement for the token make_data([USER_OBJECT]),
twitch_mock.users.get.return_value = OAUTH_USER_ID make_data([USER_OBJECT]),
twitch_mock.users.check_subscribed_to_channel.side_effect = HTTPError() make_data([OAUTH_USER_ID]),
twitch_mock.users.check_follows_channel.side_effect = HTTPError() ]
twitch_mock.get_users_follows.side_effect = [
make_data(FOLLOWERS_OBJECT),
make_data([]),
]
twitch_mock.has_required_auth.return_value = True
twitch_mock.check_user_subscription.return_value = {"status": 404}
with patch( with patch(
"homeassistant.components.twitch.sensor.TwitchClient", "homeassistant.components.twitch.sensor.Twitch",
return_value=twitch_mock, return_value=twitch_mock,
): ):
assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH)
@ -135,15 +148,23 @@ async def test_oauth_with_sub(hass):
"""Test state with oauth and sub.""" """Test state with oauth and sub."""
twitch_mock = MagicMock() twitch_mock = MagicMock()
twitch_mock.users.translate_usernames_to_ids.return_value = [USER_ID] twitch_mock.get_streams.return_value = make_data([])
twitch_mock.channels.get_by_id.return_value = CHANNEL_OBJECT twitch_mock.get_users.side_effect = [
twitch_mock._oauth_token = True # A replacement for the token make_data([USER_OBJECT]),
twitch_mock.users.get.return_value = OAUTH_USER_ID make_data([USER_OBJECT]),
twitch_mock.users.check_subscribed_to_channel.return_value = SUB_ACTIVE make_data([OAUTH_USER_ID]),
twitch_mock.users.check_follows_channel.side_effect = HTTPError() ]
twitch_mock.get_users_follows.side_effect = [
make_data(FOLLOWERS_OBJECT),
make_data([]),
]
twitch_mock.has_required_auth.return_value = True
# This function does not return an array so use make_data
twitch_mock.check_user_subscription.return_value = make_data([SUB_ACTIVE])
with patch( with patch(
"homeassistant.components.twitch.sensor.TwitchClient", "homeassistant.components.twitch.sensor.Twitch",
return_value=twitch_mock, return_value=twitch_mock,
): ):
assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH)
@ -151,7 +172,6 @@ async def test_oauth_with_sub(hass):
sensor_state = hass.states.get(ENTITY_ID) sensor_state = hass.states.get(ENTITY_ID)
assert sensor_state.attributes["subscribed"] is True assert sensor_state.attributes["subscribed"] is True
assert sensor_state.attributes["subscribed_since"] == "2020-01-20T21:22:42"
assert sensor_state.attributes["subscription_is_gifted"] is False assert sensor_state.attributes["subscription_is_gifted"] is False
assert sensor_state.attributes["following"] is False assert sensor_state.attributes["following"] is False
@ -160,15 +180,21 @@ async def test_oauth_with_follow(hass):
"""Test state with oauth and follow.""" """Test state with oauth and follow."""
twitch_mock = MagicMock() twitch_mock = MagicMock()
twitch_mock.users.translate_usernames_to_ids.return_value = [USER_ID] twitch_mock.get_streams.return_value = make_data([])
twitch_mock.channels.get_by_id.return_value = CHANNEL_OBJECT twitch_mock.get_users.side_effect = [
twitch_mock._oauth_token = True # A replacement for the token make_data([USER_OBJECT]),
twitch_mock.users.get.return_value = OAUTH_USER_ID make_data([USER_OBJECT]),
twitch_mock.users.check_subscribed_to_channel.side_effect = HTTPError() make_data([OAUTH_USER_ID]),
twitch_mock.users.check_follows_channel.return_value = FOLLOW_ACTIVE ]
twitch_mock.get_users_follows.side_effect = [
make_data(FOLLOWERS_OBJECT),
make_data([FOLLOW_ACTIVE]),
]
twitch_mock.has_required_auth.return_value = True
twitch_mock.check_user_subscription.return_value = {"status": 404}
with patch( with patch(
"homeassistant.components.twitch.sensor.TwitchClient", "homeassistant.components.twitch.sensor.Twitch",
return_value=twitch_mock, return_value=twitch_mock,
): ):
assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH)