Skip to content
Merged
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
12 changes: 9 additions & 3 deletions addon.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<addon id="plugin.video.twitch" version="3.0.2" name="Twitch" provider-name="anxdpanic, A Talented Community">
<addon id="plugin.video.twitch" version="3.0.3" name="Twitch" provider-name="anxdpanic, A Talented Community">
<requires>
<import addon="xbmc.python" version="3.0.1"/>
<import addon="script.module.requests" version="2.9.1"/>
Expand All @@ -15,8 +15,14 @@
<fanart>resources/media/fanart.jpg</fanart>
</assets>
<news>
[fix] Following Channels
[lang] updated translations from Weblate
[add] OAuth device-code login with automatic background token refresh
[add] second device-code login for ad-free playback (Turbo/subscriber)
[add] website (GQL) search backend with fuzzy/relevance ranking (+ Helix fallback)
[add] optional "Supported codecs" setting (H.265/HEVC for Enhanced Broadcasting 1440p)
[fix] search hanging at "Working..." after stopping playback started from search results
[fix] hide emoji/symbols the skin font cannot render (tofu boxes) in titles and plots
[upd] InputStream Adaptive: headers via properties, allow up to 1440p, drop sub-720p variants
[rem] IRC chat integration and the obsolete browser-based token flow
</news>
<platform>all</platform>
<source>https://github.com/anxdpanic/plugin.video.twitch</source>
Expand Down
21 changes: 21 additions & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,24 @@
3.0.3
[add] OAuth device-code login with automatic background token refresh (Settings -> "Login (device code)")
- tokens kept in addon_data/oauth_tokens.json with atomic writes and a cross-process lock,
so the rotating single-use refresh token survives Kodi's multi-process settings race
- confidential client ids (no silent refresh possible) are detected once and fall back
to manual login with a one-time hint
[add] second device-code login for ad-free playback (Turbo/subscriber) with its own
auto-refreshed token store; manual private token keeps working as fallback
[add] website (GQL) search backend with fuzzy/relevance ranking, selectable via
"Search method" with automatic Helix fallback
[add] optional "Supported codecs" setting: request H.265 (HEVC) so Enhanced Broadcasting
1440p variants are offered (requires script.module.python.twitch >= 3.0.3; silently
ignored with older library versions)
[fix] search hanging at "Working..." / keyboard re-prompt after stopping playback that was
started from search results
[fix] hide emoji/symbols the skin font cannot render (tofu boxes) in titles, plots and info
[upd] InputStream Adaptive: pass headers via stream/manifest_headers properties instead of
the URL pipe; allow up to 1440p (chooser_resolution_max); drop sub-720p variants from
quality selection; default to Adaptive quality with InputStream Adaptive enabled
[rem] IRC chat integration (script.ircchat) and the obsolete browser-based token flow

2.6.0
[update] Update Twitch API usage from v5 to Helix
- This change will require you to generate a new oauth token from the add-on settings
Expand Down
68 changes: 68 additions & 0 deletions resources/language/resource.language.en_gb/strings.po
Original file line number Diff line number Diff line change
Expand Up @@ -1005,3 +1005,71 @@ msgstr ""
msgctxt "#30273"
msgid "OAuth Token is expired or invalid"
msgstr ""

msgctxt "#30300"
msgid "Login (device code)"
msgstr ""

msgctxt "#30301"
msgid "Go to [B]%s[/B] and enter this code:[CR][CR][B]%s[/B]"
msgstr ""

msgctxt "#30302"
msgid "Waiting for authorization..."
msgstr ""

msgctxt "#30303"
msgid "Login successful. The access token will now be refreshed automatically."
msgstr ""

msgctxt "#30304"
msgid "Login failed or timed out."
msgstr ""

msgctxt "#30305"
msgid "Login cancelled."
msgstr ""

msgctxt "#30306"
msgid "Device login is not available for this Client-ID.[CR]Register your own application (Public type) at dev.twitch.tv/console/apps and set its Client-ID in the add-on settings, then try again."
msgstr ""

msgctxt "#30307"
msgid "Supported codecs"
msgstr ""

msgctxt "#30308"
msgid "Twitch default"
msgstr ""

msgctxt "#30309"
msgid "H.265 (HEVC)"
msgstr ""

msgctxt "#30310"
msgid "Automatic token refresh needs a public Client-ID.[CR]Register your own application (Public type) at dev.twitch.tv/console/apps and set its Client-ID in the add-on settings."
msgstr ""

msgctxt "#30311"
msgid "Login: ad-free playback / Turbo (device code)"
msgstr ""

msgctxt "#30312"
msgid "Go to [B]%s[/B] with your [B]Turbo/subscriber account[/B] and enter this code:[CR][CR][B]%s[/B]"
msgstr ""

msgctxt "#30313"
msgid "Turbo / ad-free login successful. The token is renewed automatically. Ad-free playback only applies if the logged-in account has Turbo or is subscribed to the channel."
msgstr ""

msgctxt "#30330"
msgid "Search method"
msgstr ""

msgctxt "#30331"
msgid "Website (twitch.tv, fuzzy)"
msgstr ""

msgctxt "#30332"
msgid "Helix API"
msgstr ""
49 changes: 39 additions & 10 deletions resources/lib/twitch_addon/addon/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import json
import sys

from . import cache, utils
from . import cache, utils, gql_search
from .common import kodi, log_utils
from .constants import Keys, SCOPES
from .error_handling import api_error_handler
Expand All @@ -24,6 +24,13 @@
from twitch.api import helix as twitch
from twitch.api.parameters import Language, Boolean, VideoSort, PeriodHelix

try:
from inspect import signature as _signature
# Older library versions don't accept the supported_codecs keyword; degrade gracefully.
_USHER_SUPPORTS_CODECS = 'supported_codecs' in _signature(usher.live_request).parameters
except Exception:
_USHER_SUPPORTS_CODECS = False

i18n = utils.i18n


Expand All @@ -38,6 +45,9 @@ class Twitch:
required_scopes = SCOPES

def __init__(self):
# Silently refresh the Helix OAuth token if it is (near) expired (Device Code Flow).
utils.ensure_valid_token()
self.access_token = utils.get_oauth_token(token_only=True, required=False)
self.queries.CLIENT_ID = self.client_id
self.queries.CLIENT_SECRET = self.client_secret
self.queries.OAUTH_TOKEN = self.access_token
Expand Down Expand Up @@ -72,7 +82,7 @@ def valid_token(self, client_id, token, scopes): # client_id, token used for un
'[CR]'.join([
i18n('missing_scopes') % missing_scopes,
i18n('get_new_oauth_token') %
(i18n('settings'), i18n('login'), i18n('get_oauth_token'))
(i18n('settings'), i18n('login'), i18n('device_login'))
])
)
log_utils.log('Error: Current OAuth token is missing required scopes |%s|' % missing_scopes,
Expand Down Expand Up @@ -100,7 +110,7 @@ def valid_token(self, client_id, token, scopes): # client_id, token used for un
'[CR]'.join([
i18n('client_id_mismatch'),
i18n('get_new_oauth_token') %
(i18n('settings'), i18n('login'), i18n('get_oauth_token'))
(i18n('settings'), i18n('login'), i18n('device_login'))
])
)
return False
Expand Down Expand Up @@ -230,20 +240,32 @@ def get_game_streams(self, game_id=None, language=Language.ALL, after='MA==', be
@api_error_handler
@cache.cache_method(cache_limit=cache.limit)
def get_channel_search(self, search_query, after='MA==', first=20):
if utils.use_gql_search():
gql = gql_search.search(search_query, 'channels')
if gql is not None:
return gql # GQL ok -> use it; else fall back to Helix below
results = self.api.search.get_channels(search_query=search_query, after=after, first=first,
live_only=Boolean.FALSE)
return self.error_check(results)

@api_error_handler
@cache.cache_method(cache_limit=cache.limit)
def get_stream_search(self, search_query, after='MA==', first=20):
if utils.use_gql_search():
gql = gql_search.search(search_query, 'streams')
if gql is not None:
return gql
results = self.api.search.get_channels(search_query=search_query, after=after, first=first,
live_only=Boolean.TRUE)
return self.error_check(results)

@api_error_handler
@cache.cache_method(cache_limit=cache.limit)
def get_game_search(self, search_query, after='MA==', first=20):
if utils.use_gql_search():
gql = gql_search.search(search_query, 'games')
if gql is not None:
return gql
results = self.api.search.get_categories(search_query=search_query, after=after, first=first)
return self.error_check(results)

Expand Down Expand Up @@ -329,10 +351,17 @@ def get_followed_streams(self, user_id, after='MA==', first=20):
results = self.error_check(results)
return results

@staticmethod
def _codec_kwargs():
codecs = utils.get_supported_codecs()
if codecs and _USHER_SUPPORTS_CODECS:
return {'supported_codecs': codecs}
return {}

@api_error_handler
@cache.cache_method(cache_limit=cache.limit)
def get_vod(self, video_id):
results = self.usher.video(video_id, headers=self.get_private_credential_headers())
results = self.usher.video(video_id, headers=self.get_private_credential_headers(), **self._codec_kwargs())
return self.error_check(results, private=True)

@api_error_handler
Expand All @@ -343,25 +372,25 @@ def get_clip(self, slug):
@api_error_handler
@cache.cache_method(cache_limit=cache.limit)
def get_live(self, name):
results = self.usher.live(name, headers=self.get_private_credential_headers())
results = self.usher.live(name, headers=self.get_private_credential_headers(), **self._codec_kwargs())
return self.error_check(results, private=True)

@api_error_handler
@cache.cache_method(cache_limit=cache.limit)
def live_request(self, name):
if not utils.inputstream_adpative_supports('EXT-X-DISCONTINUITY'):
results = self.usher.live_request(name, platform='ps4', headers=self.get_private_credential_headers())
results = self.usher.live_request(name, platform='ps4', headers=self.get_private_credential_headers(), **self._codec_kwargs())
else:
results = self.usher.live_request(name, headers=self.get_private_credential_headers())
results = self.usher.live_request(name, headers=self.get_private_credential_headers(), **self._codec_kwargs())
return self.error_check(results, private=True)

@api_error_handler
@cache.cache_method(cache_limit=cache.limit)
def video_request(self, video_id):
if not utils.inputstream_adpative_supports('EXT-X-DISCONTINUITY'):
results = self.usher.video_request(video_id, platform='ps4', headers=self.get_private_credential_headers())
results = self.usher.video_request(video_id, platform='ps4', headers=self.get_private_credential_headers(), **self._codec_kwargs())
else:
results = self.usher.video_request(video_id, headers=self.get_private_credential_headers())
results = self.usher.video_request(video_id, headers=self.get_private_credential_headers(), **self._codec_kwargs())
return self.error_check(results, private=True)

@staticmethod
Expand All @@ -377,7 +406,7 @@ def error_check(results, private=False):
if not private:
_ = kodi.Dialog().ok(
i18n('oauth_heading'),
i18n('oauth_message') % (i18n('settings'), i18n('login'), i18n('get_oauth_token'))
i18n('oauth_message') % (i18n('settings'), i18n('login'), i18n('device_login'))
)
else:
_ = kodi.Dialog().ok(
Expand Down
6 changes: 3 additions & 3 deletions resources/lib/twitch_addon/addon/common/kodi.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,11 +171,11 @@ class KodiVersion(object):
if ('result' in _json_query) and ('name' in _json_query['result']):
application = decode_utf8(_json_query['result']['name'])
version = decode_utf8(xbmc.getInfoLabel('System.BuildVersion'))
match = re.search('([0-9]+)\.([0-9]+)', version)
match = re.search(r'([0-9]+)\.([0-9]+)', version)
if match: major, minor = match.groups()
match = re.search('-([a-zA-Z]+)([0-9]*)', version)
match = re.search(r'-([a-zA-Z]+)([0-9]*)', version)
if match: tag, tag_version = match.groups()
match = re.search('\w+:(\w+-\w+)', version)
match = re.search(r'\w+:(\w+-\w+)', version)
if match: revision = match.group(1)

try:
Expand Down
8 changes: 3 additions & 5 deletions resources/lib/twitch_addon/addon/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,14 @@ def __enum(**enums):
CHANNELVIDEOLIST='channel_video_list',
GAMESTREAMS='game_streams',
RESETCACHE='reset_cache',
INSTALLIRCCHAT='install_ircchat',
DEVICELOGINPRIVATE='device_login_private',
REVOKEPRIVATETOKEN='revoke_private_token',
PLAY='play',
TOKENURL='get_token_url',
DEVICELOGIN='device_login',
EDITFOLLOW='edit_user_follows',
EDITBLOCK='edit_user_blocks',
EDITBLACKLIST='edit_blacklist',
EDITQUALITIES='edit_qualities',
CLEARLIST='clear_list',
COLLECTIONS='collections',
COLLECTIONVIDEOLIST='collection_video_list',
BROWSE='browse',
STREAMLIST='stream_list',
CLIPSLIST='clips_list',
Expand Down
34 changes: 22 additions & 12 deletions resources/lib/twitch_addon/addon/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from . import menu_items
from .common import kodi
from .constants import Keys, Images, MODES, ADAPTIVE_SOURCE_TEMPLATE
from .utils import the_art, TitleBuilder, i18n, get_oauth_token, get_vodcast_color, use_inputstream_adaptive, get_thumbnail_size, get_refresh_stamp, to_string, get_private_oauth_token, convert_duration
from .utils import the_art, TitleBuilder, i18n, get_oauth_token, get_vodcast_color, use_inputstream_adaptive, get_thumbnail_size, get_refresh_stamp, to_string, get_private_oauth_token, convert_duration, filter_qualities, strip_tofu


class PlaylistConverter(object):
Expand Down Expand Up @@ -403,6 +403,12 @@ def _format_key(key, headings, info):
value = item_template.format(head=val_heading, info=val_info)
return value

@staticmethod
def _clean_info(info):
# Strip emoji/symbols Kodi's skin font cannot render from the free-text fields
# (title/description) -- affects the text below the thumb (tagline) + info dialog (plot).
return {key: strip_tofu(value) for key, value in info.items()}

def get_plot_for_search(self, search, include_title=True):
headings = {Keys.GAME: i18n('game'),
Keys.BROADCASTER_LANGUAGE: i18n('language')}
Expand All @@ -421,7 +427,7 @@ def get_plot_for_search(self, search, include_title=True):
plot = plot_template.format(title=title, game=self._format_key(Keys.GAME, headings, info),
broadcaster_language=self._format_key(Keys.BROADCASTER_LANGUAGE, headings, info))

return {u'plot': plot, u'plotoutline': plot, u'tagline': _title.rstrip('\r\n')}
return self._clean_info({u'plot': plot, u'plotoutline': plot, u'tagline': _title.rstrip('\r\n')})

def get_plot_for_stream(self, stream, include_title=True):
headings = {Keys.GAME: i18n('game'),
Expand All @@ -446,7 +452,7 @@ def get_plot_for_stream(self, stream, include_title=True):
broadcaster_language=self._format_key(Keys.BROADCASTER_LANGUAGE, headings, info),
mature=self._format_key(Keys.MATURE, headings, info))

return {u'plot': plot, u'plotoutline': plot, u'tagline': _title.rstrip('\r\n')}
return self._clean_info({u'plot': plot, u'plotoutline': plot, u'tagline': _title.rstrip('\r\n')})

def get_plot_for_channel(self, channel):
headings = {Keys.VIEWS: i18n('views'),
Expand All @@ -468,7 +474,7 @@ def get_plot_for_channel(self, channel):
broadcaster_type=broadcaster_type + '\r\n',
date=date)

return {u'plot': plot, u'plotoutline': plot, u'tagline': title.rstrip('\r\n')}
return self._clean_info({u'plot': plot, u'plotoutline': plot, u'tagline': title.rstrip('\r\n')})

def get_plot_for_clip(self, clip, include_title=True):
headings = {Keys.VIEWS: i18n('views'),
Expand All @@ -495,7 +501,7 @@ def get_plot_for_clip(self, clip, include_title=True):
curator=self._format_key(Keys.CURATOR, headings, info),
date=date)

return {u'plot': plot, u'plotoutline': plot, u'tagline': _title.rstrip('\r\n')}
return self._clean_info({u'plot': plot, u'plotoutline': plot, u'tagline': _title.rstrip('\r\n')})

def get_plot_for_video(self, video, include_title=True):
headings = {Keys.VIEWS: i18n('views'),
Expand All @@ -519,12 +525,13 @@ def get_plot_for_video(self, video, include_title=True):
if video.get(Keys.DESCRIPTION) else title,
date=date)

return {u'plot': plot, u'plotoutline': plot, u'tagline': _title.rstrip('\r\n')}
return self._clean_info({u'plot': plot, u'plotoutline': plot, u'tagline': _title.rstrip('\r\n')})

def get_video_for_quality(self, videos, ask=True, quality=None, clip=False):
use_ia = use_inputstream_adaptive()
if use_ia and not any(v['name'] == 'Adaptive' for v in videos) and not clip:
videos.append(ADAPTIVE_SOURCE_TEMPLATE)
videos = filter_qualities(videos) # drop sub-720p variants (keep Source/720p+/audio_only/Adaptive)
if ask is True:
return self.select_video_for_quality(videos)
else:
Expand Down Expand Up @@ -574,12 +581,15 @@ def get_video_for_quality(self, videos, ask=True, quality=None, clip=False):
bwidth = int(video['bandwidth'])
if bwidth <= bandwidth_value:
bandwidths.append(bwidth)
best_match = max(bandwidths)
try:
index = next(idx for idx, video in enumerate(videos) if int(video['bandwidth']) == best_match)
return videos[index]
except:
pass
# may be empty: with sub-720p variants filtered out, every remaining
# variant can exceed the configured limit -> fall through to the dialog
if bandwidths:
best_match = max(bandwidths)
try:
index = next(idx for idx, video in enumerate(videos) if int(video['bandwidth']) == best_match)
return videos[index]
except:
pass
return self.select_video_for_quality(videos)

@staticmethod
Expand Down
Loading
Loading