From 297898230f18ee4c3098e4c465b82a145f4add8d Mon Sep 17 00:00:00 2001 From: anxdpanic Date: Wed, 29 Mar 2017 12:46:04 -0400 Subject: [PATCH 1/4] correct Cursor validation --- resources/lib/twitch/api/parameters.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/resources/lib/twitch/api/parameters.py b/resources/lib/twitch/api/parameters.py index 2982058..48de704 100644 --- a/resources/lib/twitch/api/parameters.py +++ b/resources/lib/twitch/api/parameters.py @@ -76,7 +76,9 @@ class Cursor(_Parameter): @classmethod def validate(cls, value): try: - decoded = int(b64decode(value)) + padding = (4 - len(value) % 4) % 4 + padding *= '=' + decoded = b64decode(value + padding) return value except ValueError: raise ValueError(value) From ade540c1b48034c71910f57eb012e274fb19605a Mon Sep 17 00:00:00 2001 From: anxdpanic Date: Wed, 29 Mar 2017 14:28:52 -0400 Subject: [PATCH 2/4] add undocumented platform parameters for streams.get_all --- resources/lib/twitch/api/parameters.py | 8 ++++++++ resources/lib/twitch/api/v5/streams.py | 10 ++++++++-- resources/lib/twitch/keys.py | 2 ++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/resources/lib/twitch/api/parameters.py b/resources/lib/twitch/api/parameters.py index 48de704..fd42d4a 100644 --- a/resources/lib/twitch/api/parameters.py +++ b/resources/lib/twitch/api/parameters.py @@ -72,6 +72,14 @@ class StreamType(_Parameter): _valid = [LIVE, PLAYLIST, ALL] +class Platform(_Parameter): + XBOX_ONE = 'xbox_one' + PS4 = 'ps4' + ALL = 'all' + + _valid = [XBOX_ONE, PS4, ALL] + + class Cursor(_Parameter): @classmethod def validate(cls, value): diff --git a/resources/lib/twitch/api/v5/streams.py b/resources/lib/twitch/api/v5/streams.py index 0043d78..5b59a27 100644 --- a/resources/lib/twitch/api/v5/streams.py +++ b/resources/lib/twitch/api/v5/streams.py @@ -2,7 +2,7 @@ # https://dev.twitch.tv/docs/v5/reference/streams/ from twitch import keys -from twitch.api.parameters import StreamType, Language +from twitch.api.parameters import Boolean, StreamType, Language, Platform from twitch.queries import V5Query as Qry from twitch.queries import query @@ -17,15 +17,21 @@ def by_id(channel_id, stream_type=StreamType.LIVE): # required scope: none +# platform undocumented / unsupported @query def get_all(game=None, channel_ids=None, community_id=None, language=Language.ALL, - stream_type=StreamType.LIVE, limit=25, offset=0): + stream_type=StreamType.LIVE, platform=Platform.ALL, limit=25, offset=0): q = Qry('streams') q.add_param(keys.GAME, game) q.add_param(keys.CHANNEL, channel_ids) q.add_param(keys.COMMUNITY_ID, community_id) q.add_param(keys.LANGUAGE, Language.validate(language), Language.ALL) q.add_param(keys.STREAM_TYPE, StreamType.validate(stream_type), StreamType.LIVE) + platform = Platform.validate(platform) + if platform == Platform.XBOX_ONE: + q.add_param(keys.XBOX_HEARTBEAT, Boolean.TRUE) + elif platform == Platform.PS4: + q.add_param(keys.SCE_PLATFORM, 'PS4') q.add_param(keys.LIMIT, limit, 25) q.add_param(keys.OFFSET, offset, 0) return q diff --git a/resources/lib/twitch/keys.py b/resources/lib/twitch/keys.py index fb1bfad..b541ef0 100644 --- a/resources/lib/twitch/keys.py +++ b/resources/lib/twitch/keys.py @@ -55,6 +55,7 @@ QUERY = 'query' REASON = 'reason' RULES = 'rules' +SCE_PLATFORM = 'sce_platform' SHARE = 'share' SIG = 'sig' SORT = 'sort' @@ -75,3 +76,4 @@ USER_ID = 'user_id' VIDEO_ID = 'video_id' VOD = 'vod' +XBOX_HEARTBEAT = 'xbox_heartbeat' From 01fd3cbbaf15fa0c40f88d37aaf20e0fa68c174a Mon Sep 17 00:00:00 2001 From: anxdpanic Date: Wed, 29 Mar 2017 17:50:16 -0400 Subject: [PATCH 3/4] add clips discovery endpoints v4 --- resources/lib/twitch/api/__init__.py | 3 +- resources/lib/twitch/api/parameters.py | 8 ++++++ resources/lib/twitch/api/usher.py | 17 ++++++++--- resources/lib/twitch/api/v4/__init__.py | 4 +++ resources/lib/twitch/api/v4/clips.py | 38 +++++++++++++++++++++++++ resources/lib/twitch/api/v5/__init__.py | 1 + resources/lib/twitch/keys.py | 3 ++ resources/lib/twitch/parser.py | 34 ++++++++++++++++++---- resources/lib/twitch/queries.py | 24 ++++++++++++---- 9 files changed, 115 insertions(+), 17 deletions(-) create mode 100644 resources/lib/twitch/api/v4/__init__.py create mode 100644 resources/lib/twitch/api/v4/clips.py diff --git a/resources/lib/twitch/api/__init__.py b/resources/lib/twitch/api/__init__.py index e125123..8afde4f 100644 --- a/resources/lib/twitch/api/__init__.py +++ b/resources/lib/twitch/api/__init__.py @@ -1,6 +1,7 @@ # -*- encoding: utf-8 -*- +from twitch.api import v4 from twitch.api import v5 from twitch.api import v5 as default -__all__ = ['v5', 'default'] +__all__ = ['v4', 'v5', 'default'] diff --git a/resources/lib/twitch/api/parameters.py b/resources/lib/twitch/api/parameters.py index fd42d4a..4a442d4 100644 --- a/resources/lib/twitch/api/parameters.py +++ b/resources/lib/twitch/api/parameters.py @@ -19,6 +19,14 @@ class Period(_Parameter): _valid = [WEEK, MONTH, ALL] +class ClipPeriod(_Parameter): + DAY = 'day' + WEEK = 'week' + MONTH = 'month' + ALL = 'all' + _valid = [DAY, WEEK, MONTH, ALL] + + class Boolean(_Parameter): TRUE = 'true' FALSE = 'false' diff --git a/resources/lib/twitch/api/usher.py b/resources/lib/twitch/api/usher.py index 35e409f..b2bde24 100644 --- a/resources/lib/twitch/api/usher.py +++ b/resources/lib/twitch/api/usher.py @@ -1,12 +1,13 @@ # -*- encoding: utf-8 -*- -from twitch.logging import log # NOQA -log.warning('By using this module you are violating the Twitch TOS') # NOQA +from twitch.logging import log # NOQA + +log.warning('By using this module you are violating the Twitch TOS') # NOQA from twitch import keys from twitch.api.parameters import Boolean -from twitch.parser import m3u8 -from twitch.queries import HiddenApiQuery, UsherQuery +from twitch.parser import m3u8, clip_embed +from twitch.queries import ClipsQuery, HiddenApiQuery, UsherQuery from twitch.queries import query @@ -72,3 +73,11 @@ def video(video_id): return _legacy_video(video_id) else: raise NotImplementedError('Unknown Video Type') + + +@clip_embed +@query +def clip(slug): + q = ClipsQuery('embed') + q.add_param(keys.CLIP, slug) + return q diff --git a/resources/lib/twitch/api/v4/__init__.py b/resources/lib/twitch/api/v4/__init__.py new file mode 100644 index 0000000..59dcec9 --- /dev/null +++ b/resources/lib/twitch/api/v4/__init__.py @@ -0,0 +1,4 @@ +# -*- encoding: utf-8 -*- +# https://dev.twitch.tv/docs/v5/guides/clips-discovery/ + +from twitch.api.v4 import clips # NOQA diff --git a/resources/lib/twitch/api/v4/clips.py b/resources/lib/twitch/api/v4/clips.py new file mode 100644 index 0000000..bd87efa --- /dev/null +++ b/resources/lib/twitch/api/v4/clips.py @@ -0,0 +1,38 @@ +# -*- encoding: utf-8 -*- +# https://dev.twitch.tv/docs/v5/guides/clips-discovery/#clips-discovery-api-reference + +from twitch import keys +from twitch.api.parameters import Boolean, ClipPeriod, Cursor +from twitch.queries import V4Query as Qry +from twitch.queries import query + + +# required scope: None +@query +def by_slug(slug): + q = Qry('clips/{slug}') + q.add_urlkw(keys.SLUG, slug) + return q + + +# required scope: None +@query +def get_top(channels=None, games=None, period=ClipPeriod.WEEK, trending=Boolean.FALSE, cursor='MA==', limit=10): + q = Qry('clips/top') + q.add_param(keys.CHANNEL, channels, None) + q.add_param(keys.GAME, games, None) + q.add_param(keys.PERIOD, ClipPeriod.validate(period), ClipPeriod.WEEK) + q.add_param(keys.TRENDING, Boolean.validate(trending), Boolean.FALSE) + q.add_param(keys.LIMIT, limit, 10) + q.add_param(keys.CURSOR, Cursor.validate(cursor), 'MA==') + return q + + +# required scope: user_read +@query +def get_followed(trending=Boolean.FALSE, cursor='MA==', limit=10): + q = Qry('clips/followed') + q.add_param(keys.TRENDING, Boolean.validate(trending), Boolean.FALSE) + q.add_param(keys.LIMIT, limit, 10) + q.add_param(keys.CURSOR, Cursor.validate(cursor), 'MA==') + return q diff --git a/resources/lib/twitch/api/v5/__init__.py b/resources/lib/twitch/api/v5/__init__.py index cc094ad..f4d1818 100644 --- a/resources/lib/twitch/api/v5/__init__.py +++ b/resources/lib/twitch/api/v5/__init__.py @@ -5,6 +5,7 @@ from twitch.api.v5 import channel_feed # NOQA from twitch.api.v5 import channels # NOQA from twitch.api.v5 import chat # NOQA +from twitch.api.v4 import clips # NOQA from twitch.api.v5 import collections # NOQA from twitch.api.v5 import communities # NOQA from twitch.api.v5 import games # NOQA diff --git a/resources/lib/twitch/keys.py b/resources/lib/twitch/keys.py index b541ef0..a656893 100644 --- a/resources/lib/twitch/keys.py +++ b/resources/lib/twitch/keys.py @@ -13,6 +13,7 @@ CHANNEL = 'channel' CHANNEL_FEED_ENABLED = 'channel_feed_enabled' CHANNEL_ID = 'channel_id' +CLIP = 'clip' COLLECTION_ID = 'collection_id' COMMENT_ID = 'comment_id' COMMENTS = 'comments' @@ -58,6 +59,7 @@ SCE_PLATFORM = 'sce_platform' SHARE = 'share' SIG = 'sig' +SLUG = 'slug' SORT = 'sort' SORT_BY = 'sortby' STATUS = 'status' @@ -67,6 +69,7 @@ TEAM = 'team' TITLE = 'title' TOKEN = 'token' +TRENDING = 'trending' TYPE = 'type' USER = 'user' USERNAME = 'username' diff --git a/resources/lib/twitch/parser.py b/resources/lib/twitch/parser.py index 197e1a9..fb50750 100644 --- a/resources/lib/twitch/parser.py +++ b/resources/lib/twitch/parser.py @@ -1,22 +1,31 @@ # -*- encoding: utf-8 -*- import re - from twitch.logging import log _m3u_pattern = re.compile( - r'#EXT-X-MEDIA:TYPE=VIDEO.*' - r'GROUP-ID="(?P[^"]*)",' - r'NAME="(?P[^"]*)"[,=\w]*\n' - r'#EXT-X-STREAM-INF:.*\n(' - r'?Phttp.*)') + r'#EXT-X-MEDIA:TYPE=VIDEO.*' + r'GROUP-ID="(?P[^"]*)",' + r'NAME="(?P[^"]*)"[,=\w]*\n' + r'#EXT-X-STREAM-INF:.*\n(' + r'?Phttp.*)') + +_clip_embed_pattern = re.compile(r'quality_options:\s*(?P\[[^\]]+?\])') def m3u8(f): def m3u8_wrapper(*args, **kwargs): return m3u8_to_list(f(*args, **kwargs)) + return m3u8_wrapper +def clip_embed(f): + def clip_embed_wrapper(*args, **kwargs): + return clip_embed_to_list(f(*args, **kwargs)) + + return clip_embed_wrapper + + def m3u8_to_dict(string): log.debug('m3u8_to_dict called for:\n{}'.format(string)) d = dict() @@ -44,3 +53,16 @@ def m3u8_to_list(string): log.debug('m3u8_to_list result:\n{}'.format(l)) return l + + +def clip_embed_to_list(string): + log.debug('clip_embed_to_list called for:\n{}'.format(string)) + match = re.search(_clip_embed_pattern, string) + l = list() + if match: + match = eval(match.group('qualities')) + l = [(item['quality'], item['source']) for item in match] + l.insert(0, ('Source', l[0][1])) + + log.debug('clip_embed_to_list result:\n{}'.format(l)) + return l diff --git a/resources/lib/twitch/queries.py b/resources/lib/twitch/queries.py index 2375898..45b105c 100644 --- a/resources/lib/twitch/queries.py +++ b/resources/lib/twitch/queries.py @@ -11,7 +11,9 @@ _kraken_baseurl = 'https://api.twitch.tv/kraken/' _hidden_baseurl = 'https://api.twitch.tv/api/' _usher_baseurl = 'https://usher.ttvnw.net/' +_clips_baseurl = 'https://clips.twitch.tv/' +_v4_headers = {'ACCEPT': 'application/vnd.twitchtv.v4+json'} _v5_headers = {'ACCEPT': 'application/vnd.twitchtv.v5+json'} @@ -72,8 +74,8 @@ def add_urlkw(self, kw, replacement): return self def __str__(self): - return '{method} Query to {url}, params {params}, data {data}, headers {headers}'.format( - url=self.url, params=self.params, headers=self.headers, data=self.data, method=self.method) + return '{method} Query to {url}, params {params}, data {data}, headers {headers}'\ + .format(url=self.url, params=self.params, headers=self.headers, data=self.data, method=self.method) def execute(self, f): try: @@ -121,6 +123,17 @@ def __init__(self, path, headers={}, data={}, method=methods.GET): self.add_path(path) +class ClipsQuery(DownloadQuery): + def __init__(self, path, headers={}, data={}, method=methods.GET): + super(ClipsQuery, self).__init__(_clips_baseurl, headers, data, method) + self.add_path(path) + + +class V4Query(ApiQuery): + def __init__(self, path, method=methods.GET): + super(V4Query, self).__init__(path, _v4_headers, method=method) + + class V5Query(ApiQuery): def __init__(self, path, method=methods.GET): super(V5Query, self).__init__(path, _v5_headers, method=method) @@ -129,8 +142,7 @@ def __init__(self, path, method=methods.GET): def assert_new(d, k): if k in d: v = d.get(k) - raise ValueError("Key '{}' already set to '{}'".format( - k, v)) + raise ValueError("Key '{}' already set to '{}'".format(k, v)) # TODO maybe rename @@ -138,10 +150,10 @@ def query(f): def wrapper(*args, **kwargs): qry = f(*args, **kwargs) if not isinstance(qry, _Query): - raise ValueError('{} did not return a Query, was: {}'.format( - f.__name__, repr(qry))) + raise ValueError('{} did not return a Query, was: {}'.format(f.__name__, repr(qry))) log.debug('%s QUERY: url: %s, params: %s, data: %s, ' 'headers: %r, target_func: %r', qry.method, qry.url, qry.params, qry.data, qry.headers, f.__name__) return qry.execute() + return wrapper From 1518f182e8701fb66592ec7698ae0f2b45c8e31e Mon Sep 17 00:00:00 2001 From: anxdpanic Date: Wed, 29 Mar 2017 21:57:15 -0400 Subject: [PATCH 4/4] alpha5 bump --- addon.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addon.xml b/addon.xml index ae3b00b..99f7a87 100644 --- a/addon.xml +++ b/addon.xml @@ -1,5 +1,5 @@ - +