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
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 @@ -1005,3 +1005,15 @@ msgstr ""
msgctxt "#30273"
msgid "OAuth Token is expired or invalid"
msgstr ""

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

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

msgctxt "#30332"
msgid "Helix API"
msgstr ""
14 changes: 13 additions & 1 deletion 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 Down Expand Up @@ -230,20 +230,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
107 changes: 107 additions & 0 deletions resources/lib/twitch_addon/addon/gql_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# -*- coding: utf-8 -*-
"""
Website-grade search via Twitch's GQL backend (gql.twitch.tv) — the same
Elasticsearch-backed search the Twitch website uses. Gives proper fuzzy
matching + relevance ranking (and live ordering) that the Helix
search/channels endpoint does not. Public/anonymous (web client id, no OAuth).

Results are adapted to the same shape the Helix search returns
({'data': [...]} with the addon's Keys) so the existing converters and
routes keep working unchanged. Returns None on any failure so the caller
can fall back to the Helix search.

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

from .constants import Keys
from .common import log_utils

GQL_URL = 'https://gql.twitch.tv/gql'
WEB_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko' # Twitch public web client (same as the website)
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}}]
r = requests.post(GQL_URL, json=body, headers={'Client-ID': WEB_CLIENT_ID}, timeout=TIMEOUT)
data = r.json()
if isinstance(data, list):
data = data[0] if data else {}
if data.get('errors'):
log_utils.log('gql_search: GQL errors |%s|' % data['errors'], log_utils.LOGWARNING)
return None
return (data.get('data') or {}).get('searchFor') or {}


def _channel_item(item):
stream = item.get('stream') or {}
game = stream.get('game') or {}
profile = item.get('profileImageURL', '')
return {
Keys.ID: item.get('id'),
Keys.BROADCASTER_LOGIN: item.get('login'),
Keys.DISPLAY_NAME: item.get('displayName'),
Keys.BROADCASTER_LANGUAGE: (item.get('broadcastSettings') or {}).get('language', ''),
Keys.TITLE: (item.get('broadcastSettings') or {}).get('title', ''),
Keys.OFFLINE_IMAGE_URL: profile,
Keys.THUMBNAIL_URL: stream.get('previewImageURL') or profile,
Keys.VIEWER_COUNT: stream.get('viewersCount', 0) if item.get('stream') else 0,
Keys.GAME_NAME: game.get('name', ''),
Keys.GAME_ID: game.get('id', ''),
}


def search(search_query, kind):
"""kind: 'streams' (live only) | 'channels' (all) | 'games'.
Returns {'data': [...]} (Helix-shaped) on success, or None on failure (-> caller falls back to Helix)."""
try:
if kind == 'games':
sf = _post(_GAME_QUERY, search_query)
if sf is None:
return None
edges = ((sf.get('games') or {}).get('edges')) or []
items = [{Keys.ID: e['item'].get('id'),
Keys.NAME: e['item'].get('name') or e['item'].get('displayName'),
Keys.BOX_ART_URL: e['item'].get('boxArtURL', '')}
for e in edges if e.get('item')]
return {Keys.DATA: items}

sf = _post(_CHANNEL_QUERY, search_query)
if sf is None:
return None
edges = ((sf.get('channels') or {}).get('edges')) or []
items = []
for e in edges:
item = e.get('item')
if not item:
continue
if kind == 'streams' and not item.get('stream'):
continue # streams branch == live channels only
items.append(_channel_item(item))
return {Keys.DATA: items}
except Exception as e:
log_utils.log('gql_search.search error |%s|' % e, log_utils.LOGWARNING)
return None
8 changes: 8 additions & 0 deletions resources/lib/twitch_addon/addon/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,14 @@ def get_search_history_size():
return int(kodi.get_setting('search_history_size'))


def use_gql_search():
# search_backend: 0 = Website (GQL, fuzzy/relevance ranking), 1 = Helix (search/channels)
try:
return int(kodi.get_setting('search_backend')) == 0
except (ValueError, TypeError):
return True # default to the website (GQL) search


def get_search_history(search_type):
history = None
history_size = get_search_history_size()
Expand Down
2 changes: 2 additions & 0 deletions resources/settings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@

<setting label="30123" type="slider" id="items_per_page" default="25" range="10,100" option="int"/>
<setting label="30236" type="slider" id="search_history_size" default="25" range="0,200" option="int"/>
<!-- Search backend: 0 = Website (GQL, fuzzy/relevance ranking like twitch.tv), 1 = Helix (search/channels) -->
<setting id="search_backend" type="enum" label="30330" lvalues="30331|30332" default="0"/>

<setting label="30210" id="live_reconnect" type="bool" default="false"/>

Expand Down