Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
*.pyc
multitwitch.egg-info
development.ini.local
production.ini.local
10 changes: 10 additions & 0 deletions .idea/.gitignore

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions README.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,29 @@ Multitwtich -- Multiple twitch streams on one page.
The code of this project is free to use.

Contact me at brian.c.hamrick AT gmail.com

Configuration
-------------

Keep public-safe placeholders in development.ini and production.ini.
Put real local values in an ignored development.ini.local or
production.ini.local file.

Local development
-----------------

Create development.ini.local:

[app:main]
multitwitch.session_secret = change-me
multitwitch.twitch_client_id = change-me
multitwitch.twitch_client_secret = change-me
multitwitch.twitch_redirect_uri = http://localhost:6543/auth/twitch/callback
multitwitch.twitch_scope = user:read:follows

Start the app:

source .venv/bin/activate
pserve development.ini

The app automatically loads development.ini.local overrides.
7 changes: 6 additions & 1 deletion development.ini
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
[app:main]
use = egg:multitwitch
multitwitch.session_secret = change-me-in-development
multitwitch.twitch_client_id = change-me
multitwitch.twitch_client_secret = change-me
multitwitch.twitch_redirect_uri = http://localhost:6543/auth/twitch/callback
multitwitch.twitch_scope = user:read:follows

pyramid.reload_templates = true
pyramid.debug_authorization = false
Expand All @@ -12,7 +17,7 @@ pyramid.includes =

[server:main]
use = egg:waitress#main
host = 0.0.0.0
host = localhost
port = 6543

# Begin logging configuration
Expand Down
33 changes: 31 additions & 2 deletions multitwitch/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,40 @@
import configparser
from pathlib import Path

from pyramid.config import Configurator
from pyramid.session import SignedCookieSessionFactory

from .config import routes


def _apply_local_ini_overrides(global_config, settings):
config_path = global_config.get('__file__')
if not config_path:
return
local_path = Path(config_path + '.local')
if not local_path.exists():
return

parser = configparser.RawConfigParser()
parser.read(str(local_path))
if not parser.has_section('app:main'):
return

for key, value in parser.items('app:main'):
settings[key] = value

def main(global_config, **settings):
""" This function returns a Pyramid WSGI application.
"""
config = Configurator(settings=settings)
_apply_local_ini_overrides(global_config, settings)
session_secret = settings.get(
'multitwitch.session_secret',
'dev-only-session-secret-change-me',
)
session_factory = SignedCookieSessionFactory(session_secret)
config = Configurator(
settings=settings,
session_factory=session_factory,
)
config.include(routes)
return config.make_wsgi_app()

19 changes: 17 additions & 2 deletions multitwitch/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,20 @@ def routes(config):
config.add_route('favicon', '/favicon.ico')
config.add_view(WebView.favicon, route_name='favicon')

config.add_route('root', '*streams')
config.add_view(WebView.home, route_name='root')
config.add_route('home', '/')
config.add_view(WebView.home, route_name='home')

config.add_route('auth_twitch_login', '/auth/twitch/login')
config.add_view(WebView.twitch_login, route_name='auth_twitch_login')

config.add_route('auth_twitch_callback', '/auth/twitch/callback')
config.add_view(WebView.twitch_callback, route_name='auth_twitch_callback')

config.add_route('auth_twitch_logout', '/auth/twitch/logout')
config.add_view(WebView.twitch_logout, route_name='auth_twitch_logout')

config.add_route('api_followed_channels', '/api/followed-channels')
config.add_view(WebView.followed_channels, route_name='api_followed_channels')

config.add_route('watch', '/*streams')
config.add_view(WebView.home, route_name='watch')
13 changes: 9 additions & 4 deletions multitwitch/lib/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ def wrapper(request):
body = tmpl.render(data)
else:
body = data
return Response(body, content_type=content_type)
return Response(
body=body.encode('utf-8'),
content_type=content_type,
charset='utf-8',
)
return staticmethod(wrapper)
return decorator

Expand All @@ -36,8 +40,9 @@ def decorator(f):
def wrapper(request):
retval = f(request)
return Response(
body=json.dumps(retval),
content_type='application/json'
)
body=json.dumps(retval).encode('utf-8'),
content_type='application/json',
charset='utf-8',
)
return staticmethod(wrapper)
return decorator
168 changes: 168 additions & 0 deletions multitwitch/lib/twitch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import json
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid


AUTHORIZE_URL = 'https://id.twitch.tv/oauth2/authorize'
TOKEN_URL = 'https://id.twitch.tv/oauth2/token'
VALIDATE_URL = 'https://id.twitch.tv/oauth2/validate'
FOLLOWED_CHANNELS_URL = 'https://api.twitch.tv/helix/channels/followed'
FOLLOWED_STREAMS_URL = 'https://api.twitch.tv/helix/streams/followed'


class TwitchAPIError(Exception):
def __init__(self, message, status_code=None):
Exception.__init__(self, message)
self.status_code = status_code


class TwitchClient(object):
def __init__(self, settings):
self.client_id = settings.get('multitwitch.twitch_client_id', '').strip()
self.client_secret = settings.get('multitwitch.twitch_client_secret', '').strip()
self.redirect_uri = settings.get('multitwitch.twitch_redirect_uri', '').strip()
self.scope = settings.get(
'multitwitch.twitch_scope',
'user:read:follows',
).strip()

def is_configured(self):
return bool(self.client_id and self.client_secret and self.redirect_uri)

def create_authorize_url(self, state):
query = urllib.parse.urlencode({
'client_id': self.client_id,
'redirect_uri': self.redirect_uri,
'response_type': 'code',
'scope': self.scope,
'state': state,
})
return '%s?%s' % (AUTHORIZE_URL, query)

def generate_state(self):
return uuid.uuid4().hex

def exchange_code(self, code):
return self._post_form(TOKEN_URL, {
'client_id': self.client_id,
'client_secret': self.client_secret,
'code': code,
'grant_type': 'authorization_code',
'redirect_uri': self.redirect_uri,
})

def refresh_token(self, refresh_token):
return self._post_form(TOKEN_URL, {
'grant_type': 'refresh_token',
'refresh_token': refresh_token,
'client_id': self.client_id,
'client_secret': self.client_secret,
})

def validate_token(self, access_token):
request = urllib.request.Request(
VALIDATE_URL,
headers={
'Authorization': 'OAuth %s' % access_token,
},
)
return self._load_json(request)

def get_followed_channels(self, access_token, user_id):
channels = []
cursor = None
while True:
params = {
'user_id': user_id,
'first': '100',
}
if cursor:
params['after'] = cursor
request = urllib.request.Request(
'%s?%s' % (
FOLLOWED_CHANNELS_URL,
urllib.parse.urlencode(params),
),
headers=self._helix_headers(access_token),
)
payload = self._load_json(request)
channels.extend(payload.get('data', []))
cursor = payload.get('pagination', {}).get('cursor')
if not cursor:
break
return channels

def get_followed_live_streams(self, access_token, user_id):
streams = []
cursor = None
while True:
params = {
'user_id': user_id,
'first': '100',
}
if cursor:
params['after'] = cursor
request = urllib.request.Request(
'%s?%s' % (
FOLLOWED_STREAMS_URL,
urllib.parse.urlencode(params),
),
headers=self._helix_headers(access_token),
)
payload = self._load_json(request)
streams.extend(payload.get('data', []))
cursor = payload.get('pagination', {}).get('cursor')
if not cursor:
break
return streams

def build_auth_record(self, token_payload, validation_payload):
expires_at = int(time.time()) + int(token_payload.get('expires_in', 0))
scopes = token_payload.get('scope') or validation_payload.get('scopes') or []
return {
'access_token': token_payload.get('access_token'),
'refresh_token': token_payload.get('refresh_token'),
'expires_at': expires_at,
'scope': scopes,
}

def build_user_record(self, validation_payload):
return {
'id': validation_payload.get('user_id'),
'login': validation_payload.get('login'),
'name': validation_payload.get('login'),
'scopes': validation_payload.get('scopes', []),
}

def _helix_headers(self, access_token):
return {
'Authorization': 'Bearer %s' % access_token,
'Client-Id': self.client_id,
}

def _post_form(self, url, payload):
body = urllib.parse.urlencode(payload).encode('utf-8')
request = urllib.request.Request(url, data=body)
return self._load_json(request)

def _load_json(self, request):
try:
response = urllib.request.urlopen(request)
try:
return json.loads(response.read().decode('utf-8'))
finally:
response.close()
except urllib.error.HTTPError as error:
status_code = getattr(error, 'code', None)
raw_body = error.read().decode('utf-8')
try:
payload = json.loads(raw_body)
message = payload.get('message') or payload.get('error') or raw_body
except ValueError:
message = raw_body or str(error)
raise TwitchAPIError(message, status_code=status_code)
except urllib.error.URLError as error:
raise TwitchAPIError(str(error))
Loading