Code cleanup and make service generic
This commit is contained in:
parent
031671f94a
commit
9c1aa9d346
|
@ -1,17 +0,0 @@
|
|||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
|
||||
# Custom for Visual Studio
|
||||
*.cs diff=csharp
|
||||
|
||||
# Standard to msysgit
|
||||
*.doc diff=astextplain
|
||||
*.DOC diff=astextplain
|
||||
*.docx diff=astextplain
|
||||
*.DOCX diff=astextplain
|
||||
*.dot diff=astextplain
|
||||
*.DOT diff=astextplain
|
||||
*.pdf diff=astextplain
|
||||
*.PDF diff=astextplain
|
||||
*.rtf diff=astextplain
|
||||
*.RTF diff=astextplain
|
|
@ -1,104 +1,3 @@
|
|||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
venv/
|
||||
env/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
# lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# =========================
|
||||
# Operating System Files
|
||||
# =========================
|
||||
|
||||
# OSX
|
||||
# =========================
|
||||
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear on external disk
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
# Windows
|
||||
# =========================
|
||||
|
||||
# Windows image file caches
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
|
||||
# Folder config file
|
||||
Desktop.ini
|
||||
|
||||
# Recycle Bin used on file shares
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Windows Installer files
|
||||
*.cab
|
||||
*.msi
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
*.gcloudignore
|
||||
.venv
|
||||
.mypy_cache
|
||||
config.ini
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
Copyright 2015 Laszlo Zeke
|
||||
Copyright 2021 Mattéo Delabre <https://forge.delab.re/matteo/feedleware>
|
||||
Copyright 2021 Mattia Di Eleuterio <https://github.com/madiele/TwitchToPodcastRSS>
|
||||
Copyright 2015 Laszlo Zeke <https://github.com/lzeke0/TwitchRSS>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
|
27
README.md
27
README.md
|
@ -1,25 +1,6 @@
|
|||
## Twitch RSS Webapp for Google App Engine
|
||||
This project is a very small web application for serving RSS feed for broadcasts
|
||||
in Twitch. It fetches data from [Twitch API](https://dev.twitch.tv/docs) and caches in Memcache.
|
||||
The engine is webapp2.
|
||||
# feedleware
|
||||
|
||||
A running version can be tried out at:
|
||||
https://twitchrss.appspot.com/vod/twitch
|
||||
This is a lightweight Python application that generates RSS feeds for services that don’t provide usable feeds.
|
||||
Currently supported services:
|
||||
|
||||
There is also a VOD only endpoint if you don't want to see ongoing streams which are known to break some readers:
|
||||
https://twitchrss.appspot.com/vodonly/twitch
|
||||
|
||||
### Caching requests
|
||||
This service caches requests from twitch for 10 minutes meaning that you will only get new answers once in
|
||||
10 minutes. Please keep this in mind when polling the service.
|
||||
|
||||
### Deployment
|
||||
First you should set your own Twitch API client ID in the app.yaml.
|
||||
See how to deploy on [Google App Engine](https://cloud.google.com/appengine/docs/standard/python3).
|
||||
|
||||
### Other things
|
||||
The project uses a slightly modified [Feedformatter](https://code.google.com/p/feedformatter/) to support
|
||||
more tags and time zone in pubDate tag.
|
||||
|
||||
### About
|
||||
The project has been developed by László Zeke.
|
||||
* Twitch
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
runtime: python38
|
||||
|
||||
entrypoint: gunicorn -b :$PORT -k gthread --threads 2 twitchrss:app
|
||||
|
||||
env_variables:
|
||||
TWITCH_CLIENT_ID: __INSERT_TWITCH_CLIENT_ID_HERE__
|
||||
|
||||
handlers:
|
||||
- url: /favicon\.ico
|
||||
static_files: favicon.ico
|
||||
upload: favicon\.ico
|
||||
|
||||
- url: /
|
||||
static_files: index.html
|
||||
upload: index\.html
|
||||
|
||||
- url: /.+
|
||||
script: auto
|
||||
|
||||
automatic_scaling:
|
||||
max_instances: 1
|
Binary file not shown.
Before Width: | Height: | Size: 7.0 KiB |
|
@ -1,17 +0,0 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>Twitch stream RSS generator</title>
|
||||
</head>
|
||||
<body>
|
||||
<p style="font-family: helvetica; font-size:20pt; padding: 20px;">
|
||||
Twitch stream RSS generator
|
||||
</p>
|
||||
<p style="font-family: helvetica; font-size:12pt; padding: 20px;">
|
||||
You can get RSS of broadcasts by subscribing to https://twitchrss.appspot.com/vod/<channel name><br/>
|
||||
For example: <a href="https://twitchrss.appspot.com/vod/riotgames">https://twitchrss.appspot.com/vod/riotgames</a><br/><br/>
|
||||
You can use the /vodonly handle to get only vods without ongoing streams.
|
||||
Not endorsed by Twitch.tv, just a fun project.<br/>
|
||||
<a href="https://github.com/lzeke0/TwitchRSS">Project home</a>
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
|
@ -1,31 +0,0 @@
|
|||
appdirs==1.4.3
|
||||
CacheControl==0.12.6
|
||||
cachetools==4.1.1
|
||||
certifi==2019.11.28
|
||||
chardet==3.0.4
|
||||
click==7.1.2
|
||||
colorama==0.4.3
|
||||
contextlib2==0.6.0
|
||||
distlib==0.3.0
|
||||
distro==1.4.0
|
||||
Flask==1.1.2
|
||||
gunicorn==20.0.4
|
||||
html5lib==1.0.1
|
||||
idna==2.8
|
||||
ipaddr==2.2.0
|
||||
itsdangerous==1.1.0
|
||||
Jinja2==2.11.2
|
||||
lockfile==0.12.2
|
||||
MarkupSafe==1.1.1
|
||||
msgpack==0.6.2
|
||||
packaging==20.3
|
||||
pep517==0.8.2
|
||||
progress==1.5
|
||||
pyparsing==2.4.6
|
||||
pytoml==0.1.21
|
||||
requests==2.22.0
|
||||
retrying==1.3.3
|
||||
six==1.14.0
|
||||
urllib3==1.25.8
|
||||
webencodings==0.5.1
|
||||
Werkzeug==1.0.1
|
|
@ -1,219 +0,0 @@
|
|||
#
|
||||
# Copyright 2020 Laszlo Zeke
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
from cachetools import cached, TTLCache, LRUCache
|
||||
from feedformatter import Feed
|
||||
from flask import abort, Flask, request
|
||||
from io import BytesIO
|
||||
from os import environ
|
||||
import datetime
|
||||
import gzip
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import urllib
|
||||
|
||||
|
||||
VOD_URL_TEMPLATE = 'https://api.twitch.tv/helix/videos?user_id=%s&type=all'
|
||||
USERID_URL_TEMPLATE = 'https://api.twitch.tv/helix/users?login=%s'
|
||||
VODCACHE_LIFETIME = 10 * 60
|
||||
USERIDCACHE_LIFETIME = 24 * 60 * 60
|
||||
CHANNEL_FILTER = re.compile("^[a-zA-Z0-9_]{2,25}$")
|
||||
TWITCH_CLIENT_ID = environ.get("TWITCH_CLIENT_ID")
|
||||
TWITCH_SECRET = environ.get("TWITCH_SECRET")
|
||||
TWITCH_OAUTH_TOKEN = ""
|
||||
TWITCH_OAUTH_EXPIRE_EPOCH = 0
|
||||
logging.basicConfig(level=logging.DEBUG if environ.get('DEBUG') else logging.INFO)
|
||||
|
||||
if not TWITCH_CLIENT_ID:
|
||||
raise Exception("Twitch API client id is not set.")
|
||||
if not TWITCH_SECRET:
|
||||
raise Exception("Twitch API secret env variable not set.")
|
||||
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
def authorize():
|
||||
global TWITCH_OAUTH_TOKEN
|
||||
global TWITCH_OAUTH_EXPIRE_EPOCH
|
||||
|
||||
# return if token has not expired
|
||||
if (TWITCH_OAUTH_EXPIRE_EPOCH >= round(time.time())):
|
||||
return
|
||||
|
||||
logging.debug("requesting a new oauth token")
|
||||
data = {
|
||||
'client_id': TWITCH_CLIENT_ID,
|
||||
'client_secret': TWITCH_SECRET,
|
||||
'grant_type': 'client_credentials',
|
||||
}
|
||||
url = 'https://id.twitch.tv/oauth2/token'
|
||||
request = urllib.request.Request(url, data=urllib.parse.urlencode(data).encode("utf-8"), method='POST')
|
||||
retries = 0
|
||||
while retries < 3:
|
||||
try:
|
||||
result = urllib.request.urlopen(request, timeout=3)
|
||||
r = json.loads(result.read().decode("utf-8"))
|
||||
TWITCH_OAUTH_TOKEN = r['access_token']
|
||||
TWITCH_OAUTH_EXPIRE_EPOCH = int(r['expires_in']) + round(time.time())
|
||||
logging.debug("oauth token aquired")
|
||||
return
|
||||
except Exception as e:
|
||||
logging.warning("Fetch exception caught: %s" % e)
|
||||
retries += 1
|
||||
abort(503)
|
||||
|
||||
@app.route('/vod/<string:channel>', methods=['GET', 'HEAD'])
|
||||
def vod(channel):
|
||||
if CHANNEL_FILTER.match(channel):
|
||||
return get_inner(channel)
|
||||
else:
|
||||
abort(404)
|
||||
|
||||
|
||||
@app.route('/vodonly/<string:channel>', methods=['GET', 'HEAD'])
|
||||
def vodonly(channel):
|
||||
if CHANNEL_FILTER.match(channel):
|
||||
return get_inner(channel, add_live=False)
|
||||
else:
|
||||
abort(404)
|
||||
|
||||
|
||||
def get_inner(channel, add_live=True):
|
||||
userid_json = fetch_userid(channel)
|
||||
if not userid_json:
|
||||
abort(404)
|
||||
|
||||
(channel_display_name, channel_id) = extract_userid(json.loads(userid_json)['data'][0])
|
||||
|
||||
channel_json = fetch_vods(channel_id)
|
||||
if not channel_json:
|
||||
abort(404)
|
||||
|
||||
decoded_json = json.loads(channel_json)['data']
|
||||
rss_data = construct_rss(channel, decoded_json, channel_display_name, add_live)
|
||||
headers = {'Content-Type': 'application/rss+xml'}
|
||||
|
||||
if 'gzip' in request.headers.get("Accept-Encoding", ''):
|
||||
headers['Content-Encoding'] = 'gzip'
|
||||
rss_data = gzip.compress(rss_data)
|
||||
|
||||
return rss_data, headers
|
||||
|
||||
|
||||
@cached(cache=TTLCache(maxsize=3000, ttl=USERIDCACHE_LIFETIME))
|
||||
def fetch_userid(channel_name):
|
||||
return fetch_json(channel_name, USERID_URL_TEMPLATE)
|
||||
|
||||
|
||||
@cached(cache=TTLCache(maxsize=500, ttl=VODCACHE_LIFETIME))
|
||||
def fetch_vods(channel_id):
|
||||
return fetch_json(channel_id, VOD_URL_TEMPLATE)
|
||||
|
||||
|
||||
def fetch_json(id, url_template):
|
||||
#update the oauth token
|
||||
authorize()
|
||||
|
||||
url = url_template % id
|
||||
headers = {
|
||||
'Authorization': 'Bearer '+TWITCH_OAUTH_TOKEN,
|
||||
'Client-Id': TWITCH_CLIENT_ID,
|
||||
'Accept-Encoding': 'gzip'
|
||||
}
|
||||
request = urllib.request.Request(url, headers=headers)
|
||||
retries = 0
|
||||
while retries < 3:
|
||||
try:
|
||||
result = urllib.request.urlopen(request, timeout=3)
|
||||
logging.debug('Fetch from twitch for %s with code %s' % (id, result.getcode()))
|
||||
if result.info().get('Content-Encoding') == 'gzip':
|
||||
logging.debug('Fetched gzip content')
|
||||
return gzip.decompress(result.read())
|
||||
return result.read()
|
||||
except Exception as e:
|
||||
logging.warning("Fetch exception caught: %s" % e)
|
||||
retries += 1
|
||||
abort(503)
|
||||
|
||||
|
||||
def extract_userid(user_info):
|
||||
# Get the first id in the list
|
||||
userid = user_info['id']
|
||||
username = user_info['display_name']
|
||||
if username and userid:
|
||||
return username, userid
|
||||
else:
|
||||
logging.warning('Userid is not found in %s' % user_info)
|
||||
abort(404)
|
||||
|
||||
|
||||
def construct_rss(channel_name, vods_info, display_name, add_live=True):
|
||||
feed = Feed()
|
||||
|
||||
# Set the feed/channel level properties
|
||||
feed.feed["title"] = "%s's Twitch video RSS" % display_name
|
||||
feed.feed["link"] = "https://twitchrss.appspot.com/"
|
||||
feed.feed["author"] = "Twitch RSS Generated"
|
||||
feed.feed["description"] = "The RSS Feed of %s's videos on Twitch" % display_name
|
||||
feed.feed["ttl"] = '10'
|
||||
|
||||
# Create an item
|
||||
try:
|
||||
if vods_info:
|
||||
for vod in vods_info:
|
||||
item = {}
|
||||
|
||||
# @madiele: in twitch new API the current stream now it's not bundled in the same request
|
||||
# maybe to be re-implemented later on
|
||||
|
||||
#if vod["status"] == "recording":
|
||||
# if not add_live:
|
||||
# continue
|
||||
# link = "http://www.twitch.tv/%s" % channel_name
|
||||
# item["title"] = "%s - LIVE" % vod['title']
|
||||
# item["category"] = "live"
|
||||
#else:
|
||||
link = vod['url']
|
||||
item["title"] = vod['title']
|
||||
item["category"] = vod['type']
|
||||
|
||||
item["link"] = link
|
||||
item["description"] = "<a href=\"%s\"><img src=\"%s\" /></a>" % (link, vod['thumbnail_url'].replace("%{width}", "512").replace("%{height}","288"))
|
||||
|
||||
#@madiele: for some reason the new API does not have the game field anymore...
|
||||
#if vod.get('game'):
|
||||
# item["description"] += "<br/>" + vod['game']
|
||||
|
||||
if vod.get('description'):
|
||||
item["description"] += "<br/>" + vod['description']
|
||||
d = datetime.datetime.strptime(vod['created_at'], '%Y-%m-%dT%H:%M:%SZ')
|
||||
item["pubDate"] = d.timetuple()
|
||||
item["guid"] = vod['id']
|
||||
#if vod["status"] == "recording": # To show a different news item when recording is over
|
||||
# item["guid"] += "_live"
|
||||
feed.items.append(item)
|
||||
except KeyError as e:
|
||||
logging.warning('Issue with json: %s\nException: %s' % (vods_info, e))
|
||||
abort(404)
|
||||
|
||||
return feed.format_rss2_string()
|
||||
|
||||
|
||||
# For debug
|
||||
if __name__ == "__main__":
|
||||
app.run(host='127.0.0.1', port=8080, debug=True)
|
|
@ -0,0 +1,41 @@
|
|||
import configparser
|
||||
import logging
|
||||
import sys
|
||||
from os import environ
|
||||
from flask import Flask
|
||||
from . import twitch
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
blueprints = {
|
||||
"twitch": twitch,
|
||||
}
|
||||
|
||||
|
||||
def create_app():
|
||||
"""Read the app configuration and instantiate service blueprints."""
|
||||
debug = environ.get("FLASK_ENV", "production") == "development"
|
||||
level = logging.DEBUG if debug else logging.INFO
|
||||
logging.basicConfig(level=level)
|
||||
|
||||
config_path = environ.get("FEEDLEWARE_CONFIG")
|
||||
|
||||
if not config_path:
|
||||
print(
|
||||
"Please set the FEEDLEWARE_CONFIG environment variable",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read(config_path)
|
||||
app = Flask(__name__)
|
||||
|
||||
for section in config.sections():
|
||||
if section in blueprints:
|
||||
blueprint = blueprints[section].create_blueprint(config[section])
|
||||
app.register_blueprint(blueprint, url_prefix="/" + section)
|
||||
else:
|
||||
logger.warning("Unknown service '%s'", section)
|
||||
|
||||
return app
|
|
@ -176,9 +176,9 @@ def _format_datetime(feed_type, time):
|
|||
time = _convert_datetime(time)
|
||||
|
||||
# Then, convert that to the appropriate string
|
||||
if feed_type is "rss2":
|
||||
if feed_type == "rss2":
|
||||
return strftime("%a, %d %b %Y %H:%M:%S UT", time)
|
||||
elif feed_type is "atom":
|
||||
elif feed_type == "atom":
|
||||
return strftime("%Y-%m-%dT%H:%M:%S", time) + _get_tz_offset();
|
||||
|
||||
def _atomise_link(link):
|
|
@ -0,0 +1,21 @@
|
|||
from flask import abort, Blueprint
|
||||
from .twitch import APIClient, NoSuchUser
|
||||
from .feed import construct_rss
|
||||
|
||||
|
||||
def create_blueprint(config):
|
||||
"""Create a Twitch endpoint blueprint."""
|
||||
client = APIClient(config["client_id"], config["secret"])
|
||||
twitch = Blueprint("twitch", __name__)
|
||||
|
||||
@twitch.route("/<string:login>", methods=["GET", "HEAD"])
|
||||
def vod(login: str):
|
||||
try:
|
||||
return (
|
||||
construct_rss(client, login),
|
||||
{"Content-Type": "application/rss+xml"},
|
||||
)
|
||||
except NoSuchUser:
|
||||
abort(404)
|
||||
|
||||
return twitch
|
|
@ -0,0 +1,73 @@
|
|||
from datetime import datetime
|
||||
from ..feedformatter import Feed
|
||||
from .twitch import APIClient
|
||||
|
||||
|
||||
def parse_iso(iso: str) -> datetime:
|
||||
return datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
||||
|
||||
|
||||
def construct_rss(client: APIClient, login: str) -> str:
|
||||
"""
|
||||
Build a RSS stream for a Twitch user.
|
||||
|
||||
:param client: Twitch API client
|
||||
:param login: user login
|
||||
:returns: RSS stream
|
||||
:raises HTTPException: if one of the requests fail
|
||||
:raises NoSuchUser: if the user does not exist
|
||||
"""
|
||||
user_info = client.user(login)
|
||||
channel_id = user_info["id"]
|
||||
videos = client.videos(channel_id)
|
||||
stream = client.stream(channel_id)
|
||||
|
||||
feed = Feed()
|
||||
user_url = f"https://www.twitch.tv/{user_info['login']}"
|
||||
|
||||
# Set the feed/channel level properties
|
||||
feed.feed["title"] = user_info["display_name"]
|
||||
feed.feed["link"] = user_url
|
||||
feed.feed["author"] = "Twitch RSS Generated"
|
||||
feed.feed["description"] = user_info["description"]
|
||||
feed.feed["ttl"] = '10'
|
||||
|
||||
if stream is not None:
|
||||
item = {}
|
||||
item["guid"] = stream["id"]
|
||||
item["title"] = "[⏺️ Live] " + stream.get("title", "Untitled Stream")
|
||||
item["link"] = user_url
|
||||
item["description"] = stream.get("game_name", "")
|
||||
item["pubDate"] = parse_iso(stream["started_at"]).timetuple()
|
||||
|
||||
feed.items.append(item)
|
||||
|
||||
for video in videos:
|
||||
if video.get("viewable", "public") != "public":
|
||||
continue
|
||||
|
||||
item = {}
|
||||
|
||||
if video.get("stream_id") is not None:
|
||||
if stream is not None and stream["id"] == video["stream_id"]:
|
||||
# Do not add a second item for the active stream
|
||||
continue
|
||||
|
||||
item["guid"] = video["stream_id"]
|
||||
else:
|
||||
item["guid"] = video["id"]
|
||||
|
||||
item["title"] = video.get("title", "Untitled Video")
|
||||
|
||||
link = video.get("url", user_url)
|
||||
thumbnail = video["thumbnail_url"] \
|
||||
.replace("%{width}", "600") \
|
||||
.replace("%{height}", "400")
|
||||
|
||||
item["link"] = link
|
||||
item["description"] = f'<a href="{link}"><img src="{thumbnail}" /></a>'
|
||||
item["pubDate"] = parse_iso(video["published_at"]).timetuple()
|
||||
|
||||
feed.items.append(item)
|
||||
|
||||
return feed.format_rss2_string()
|
|
@ -0,0 +1,218 @@
|
|||
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 doesn’t 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]
|
Loading…
Reference in New Issue