feedleware/feedleware/twitch/twitch.py

219 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import gzip
import http
import json
import logging
import re
import time
import urllib
import urllib.request
from typing import Any, Dict, Optional
from cachetools import cached, TTLCache
HTTPRequest = urllib.request.Request
HTTPResponse = http.client.HTTPResponse
HTTPException = http.client.HTTPException
logger = logging.getLogger(__name__)
def now():
"""Get current epoch in seconds."""
return int(time.time())
class NoSuchUser(Exception):
"""Raised when an unknown user is queried."""
class APIClient:
"""Client for the Twitch API app endpoints."""
# Expression that matches allowed logins
CHANNEL_FILTER = re.compile("^[a-zA-Z0-9_]{2,25}$")
def __init__(self, client_id: str = "", secret: str = "", retries: int = 3):
"""
Create a Twitch API client.
See <https://dev.twitch.tv/docs/authentication> for details.
:param client_id: client ID of the app
:param secret: app secret
:param retries: number of times to retry each request in case of failure
"""
self.client_id: str = client_id
self.secret: str = secret
self.retries: int = 3
self.oauth_token: str = ""
self.oauth_expire_epoch: int = 0
def _send_with_retry(self, request: HTTPRequest) -> HTTPResponse:
"""
Send an HTTP request and retry in case of failure.
The number of retries is configured by `self.retries`.
:param request: request to try sending
:returns: response sent by the server
:throws HTTPException: if the number of retries is exceeded
"""
retries = self.retries
last_err = HTTPException()
while retries:
try:
return urllib.request.urlopen(request, timeout=3)
except HTTPException as err:
logger.warning("HTTP error: %s", err)
retries -= 1
last_err = err
raise last_err
def authorize(self) -> bool:
"""
Refresh the current OAuth app access token if needed.
This uses the Twitch OAuth client credentials flow. See:
<https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#oauth-client-credentials-flow>
:returns: true if the OAuth app access token was refreshed,
false if the existing one was still valid
:throws HTTPException: if the request fails
"""
if now() < self.oauth_expire_epoch:
return False
logger.debug("Refreshing the OAuth app access token")
payload = {
"client_id": self.client_id,
"client_secret": self.secret,
"grant_type": "client_credentials",
}
request = HTTPRequest(
url="https://id.twitch.tv/oauth2/token",
data=urllib.parse.urlencode(payload).encode("utf-8"),
method="POST",
)
http_response = self._send_with_retry(request)
response = json.loads(http_response.read().decode("utf-8"))
self.oauth_token = response["access_token"]
self.oauth_expire_epoch = now() + int(response["expires_in"])
return True
def _query(
self,
url: str,
method: str = "GET",
data: Optional[Dict[str, str]] = None
) -> Any:
"""
Low-level method to make an authenticated query to the API.
:param url: URL to query
:param method: HTTP method to use
:param data: payload dictionary to send
:returns: JSON data
:throws HTTPException: if the query fails
"""
self.authorize()
logger.debug("Querying %s %s %s", method, url, data)
headers = {
"Authorization": f"Bearer {self.oauth_token}",
"Client-Id": self.client_id,
"Accept-Encoding": "gzip",
}
payload = data if data is not None else {}
request = HTTPRequest(
url=f"{url}?{urllib.parse.urlencode(payload)}",
headers=headers,
method=method,
)
http_response = self._send_with_retry(request)
if http_response.info().get("Content-Encoding") == "gzip":
return json.loads(gzip.decompress(http_response.read()))
else:
return json.loads(http_response.read())
@cached(cache=TTLCache(maxsize=1000, ttl=24 * 60 * 60))
def user(self, login: str) -> Any:
"""
Get information about a user.
:param login: user login
:returns: user information
:throws HTTPException: if the query fails
:throws NoSuchUser: if the user doesnt exist
"""
if not self.CHANNEL_FILTER.match(login):
raise NoSuchUser(f"Login '{login}' is invalid")
response = self._query(
url="https://api.twitch.tv/helix/users",
method="GET",
data={"login": login},
)
assert type(response) == dict
if "data" not in response or not response["data"]:
raise NoSuchUser(f"User '{login}' does not exist")
assert type(response["data"]) == list
assert type(response["data"][0]) == dict
return response["data"][0]
@cached(cache=TTLCache(maxsize=1000, ttl=10 * 60))
def videos(self, channel_id: str) -> Any:
"""
Get the list of videos from a channel.
:param channel_id: channel ID
:returns: list of videos
:throws HTTPException: if the query fails
:throws RuntimeError: if the server response is malformed
"""
response = self._query(
url="https://api.twitch.tv/helix/videos",
method="GET",
data={"user_id": channel_id},
)
if "data" not in response:
raise RuntimeError("Unexpected response from Twitch API")
return response["data"]
@cached(cache=TTLCache(maxsize=1000, ttl=10 * 60))
def stream(self, channel_id: str) -> Optional[Any]:
"""
Get the information about the stream currently active on a channel.
:param channel_id: channel ID
:returns: stream information or None
:throws HTTPException: if the query fails
:throws RuntimeError: if the server response is malformed
"""
response = self._query(
url="https://api.twitch.tv/helix/streams",
method="GET",
data={"user_id": channel_id},
)
if "data" not in response or not response["data"]:
return None
if response["data"][0]["type"] != "live":
return None
return response["data"][0]