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: 12 additions & 0 deletions resources/language/resource.language.de_de/strings.po
Original file line number Diff line number Diff line change
Expand Up @@ -1177,6 +1177,18 @@ msgctxt "#30332"
msgid "Automatic (best playable)"
msgstr "Automatisch (beste abspielbare Qualität)"

msgctxt "#30333"
msgid "Search method"
msgstr "Suchmethode"

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

msgctxt "#30335"
msgid "Helix API"
msgstr "Helix-API"

#~ msgctxt "#30132"
#~ msgid "OAuth token is required for access to authorized user functions."
#~ msgstr "Für den Zugriff auf geschützte Benutzerfunktionen wird ein OAuth-Token benötigt."
Expand Down
12 changes: 12 additions & 0 deletions resources/language/resource.language.en_gb/strings.po
Original file line number Diff line number Diff line change
Expand Up @@ -1224,3 +1224,15 @@ msgstr ""
msgctxt "#30332"
msgid "Automatic (best playable)"
msgstr ""

msgctxt "#30333"
msgid "Search method"
msgstr ""

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

msgctxt "#30335"
msgid "Helix API"
msgstr ""
36 changes: 30 additions & 6 deletions resources/lib/twitch_addon/addon/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import sys
import os

from . import cache, utils
from . import cache, gql_search, utils
from .common import kodi, log_utils
from .common.cache import invalidate_cache_for_function
from .constants import Keys, SCOPES
Expand Down Expand Up @@ -160,14 +160,14 @@ def valid_token(self, client_id, token, scopes): # client_id, token used for un
if self.client_id and token_check['client_id'] != self.client_id:
log_utils.log('Token client_id mismatch: token=%s, configured=%s. Clearing stale token.' % (
token_check['client_id'], self.client_id), log_utils.LOGWARNING)
# Token was obtained with a different client_id it won't work with Helix
# Token was obtained with a different client_id, so it won't work with Helix
kodi.set_setting('oauth_token_helix', '')
kodi.set_setting('device_refresh_token', '')
kodi.set_setting('device_token_expires_at', '')
kodi.set_setting('is_device_authenticated', 'false')
return False
elif not self.client_id:
# No client_id configured adopt the token's client_id
# No client_id configured, so adopt the token's client_id
log_utils.log('No client_id configured, adopting from token: %s' % token_check['client_id'], log_utils.LOGDEBUG)
self.client_id = token_check['client_id']
self.queries.CLIENT_ID = self.client_id
Expand Down Expand Up @@ -304,22 +304,46 @@ def get_game_streams(self, game_id=None, language=Language.ALL, after='MA==', be
return self.error_check(results)

@api_error_handler
@cache.cache_method(cache_limit=cache.limit)
def get_channel_search(self, search_query, after='MA==', first=20):
backend = utils.get_search_backend()
return self._get_channel_search(search_query, after, first, backend)

@cache.cache_method(cache_limit=cache.limit)
def _get_channel_search(self, search_query, after, first, backend):
if backend == 0 and after == 'MA==':
results = gql_search.search(search_query, 'channels')
if results is not None:
return results
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):
backend = utils.get_search_backend()
return self._get_stream_search(search_query, after, first, backend)

@cache.cache_method(cache_limit=cache.limit)
def _get_stream_search(self, search_query, after, first, backend):
if backend == 0 and after == 'MA==':
results = gql_search.search(search_query, 'streams')
if results is not None:
return results
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):
backend = utils.get_search_backend()
return self._get_game_search(search_query, after, first, backend)

@cache.cache_method(cache_limit=cache.limit)
def _get_game_search(self, search_query, after, first, backend):
if backend == 0 and after == 'MA==':
results = gql_search.search(search_query, 'games')
if results is not None:
return results
results = self.api.search.get_categories(search_query=search_query, after=after, first=first)
return self.error_check(results)

Expand Down
2 changes: 1 addition & 1 deletion resources/lib/twitch_addon/addon/device_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ def auto_refresh_token():
error_msg = str(e)
log_utils.log('Token auto-refresh failed: %s' % error_msg, log_utils.LOGWARNING)
# Only clear refresh token if Twitch says it's definitively invalid.
# Do NOT clear on network errors next startup might succeed.
# Do NOT clear on network errors because the next startup might succeed.
if any(keyword in error_msg.lower() for keyword in ['invalid refresh token', 'invalid_grant', 'invalid grant']):
log_utils.log('Refresh token is invalid, clearing device tokens', log_utils.LOGWARNING)
kodi.set_setting('device_refresh_token', '')
Expand Down
173 changes: 173 additions & 0 deletions resources/lib/twitch_addon/addon/gql_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# -*- coding: utf-8 -*-
"""
Website search via Twitch's GQL backend.

Adapted from anxdpanic/plugin.video.twitch PR #706.

Results are adapted to the existing Helix search shape so routes and
converters remain unchanged. Returns None on failure so callers can fall
back to Helix.

SPDX-License-Identifier: GPL-3.0-only
See LICENSES/GPL-3.0-only for more information.
"""

import requests

from . import utils
from .common import log_utils
from .constants import Keys


GQL_URL = 'https://gql.twitch.tv/gql'
TIMEOUT = 15

_CHANNEL_QUERY = (
'query Search($q: String!) {'
' searchFor(userQuery: $q, platform: "web", target: {index: CHANNEL}) {'
' channels { edges { item { ... on User {'
' id login displayName'
' broadcastSettings { language title }'
' profileImageURL(width: 300)'
' stream { id viewersCount previewImageURL game { id name displayName } }'
' } } } }'
' }'
'}'
)

_GAME_QUERY = (
'query Search($q: String!) {'
' searchFor(userQuery: $q, platform: "web", target: {index: GAME}) {'
' games { edges { item { ... on Game {'
' id name displayName boxArtURL(width: 285, height: 380)'
' } } } }'
' }'
'}'
)


def _post(query, search_query):
body = [{
'operationName': 'Search',
'query': query,
'variables': {'q': search_query},
}]
response = requests.post(
GQL_URL,
json=body,
headers={'Client-ID': utils.get_private_client_id()},
timeout=TIMEOUT,
)
response.raise_for_status()
envelope = response.json()
if isinstance(envelope, list):
if len(envelope) != 1:
return None
envelope = envelope[0]
if not isinstance(envelope, dict) or envelope.get('errors'):
return None
data = envelope.get('data')
if not isinstance(data, dict):
return None
search_for = data.get('searchFor')
if not isinstance(search_for, dict):
return None
return search_for


def _channel_item(item):
if not isinstance(item, dict):
return None
if not item.get('id') or not item.get('login') or not item.get('displayName'):
return None

settings = item.get('broadcastSettings')
if not isinstance(settings, dict):
settings = {}
stream = item.get('stream')
if not isinstance(stream, dict):
stream = {}
game = stream.get('game')
if not isinstance(game, dict):
game = {}
profile = item.get('profileImageURL') or ''

return {
Keys.ID: item['id'],
Keys.BROADCASTER_LOGIN: item['login'],
Keys.DISPLAY_NAME: item['displayName'],
Keys.BROADCASTER_LANGUAGE: settings.get('language') or '',
Keys.TITLE: settings.get('title') or '',
Keys.OFFLINE_IMAGE_URL: profile,
Keys.THUMBNAIL_URL: stream.get('previewImageURL') or profile,
Keys.VIEWER_COUNT: stream.get('viewersCount') or 0,
Keys.GAME_NAME: game.get('name') or game.get('displayName') or '',
Keys.GAME_ID: game.get('id') or '',
}


def _game_item(item):
if not isinstance(item, dict):
return None
name = item.get('name') or item.get('displayName')
if not item.get('id') or not name:
return None
return {
Keys.ID: item['id'],
Keys.NAME: name,
Keys.BOX_ART_URL: item.get('boxArtURL') or '',
}


def _get_edges(search_for, container_name):
container = search_for.get(container_name)
if not isinstance(container, dict):
return None
edges = container.get('edges')
if not isinstance(edges, list) or not edges:
return None
return edges


def search(search_query, kind):
"""Return Helix-shaped search data, or None to request Helix fallback."""
try:
if kind == 'games':
search_for = _post(_GAME_QUERY, search_query)
container_name = 'games'
adapter = _game_item
elif kind in ('channels', 'streams'):
search_for = _post(_CHANNEL_QUERY, search_query)
container_name = 'channels'
adapter = _channel_item
else:
return None

if search_for is None:
return None
edges = _get_edges(search_for, container_name)
if edges is None:
return None

items = []
for edge in edges:
if not isinstance(edge, dict):
continue
source = edge.get('item')
if kind == 'streams' and (
not isinstance(source, dict)
or not isinstance(source.get('stream'), dict)
or not source.get('stream')):
continue
item = adapter(source)
if item is not None:
items.append(item)
if not items:
return None
return {Keys.DATA: items}
except Exception as error:
log_utils.log(
'gql_search failed: %s' % error.__class__.__name__,
log_utils.LOGWARNING,
)
return None
7 changes: 7 additions & 0 deletions resources/lib/twitch_addon/addon/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,13 @@ def get_search_history_size():
return int(kodi.get_setting('search_history_size'))


def get_search_backend():
backend = kodi.get_setting('search_backend')
if isinstance(backend, bool):
return 0
return 1 if backend in (1, '1') else 0


def get_search_history(search_type):
history = None
history_size = get_search_history_size()
Expand Down
11 changes: 11 additions & 0 deletions resources/settings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,17 @@
</constraints>
<control type="slider" format="integer"/>
</setting>
<setting id="search_backend" type="integer" label="30333" help="">
<level>0</level>
<default>0</default>
<constraints>
<options>
<option label="30334">0</option>
<option label="30335">1</option>
</options>
</constraints>
<control type="spinner" format="string"/>
</setting>
<setting id="watch_history_size" type="integer" label="30284" help="">
<level>0</level>
<default>50</default>
Expand Down
Loading
Loading