From ac7561520a9d21281105fbabd47289b3c284e1dc Mon Sep 17 00:00:00 2001 From: einanderson <289327658+einanderson@users.noreply.github.com> Date: Sat, 6 Jun 2026 00:20:35 +0200 Subject: [PATCH] Add a website (GQL) search backend with fuzzy/relevance ranking Adds an optional Search backend using Twitch's GQL searchFor (the same search twitch.tv uses) for proper fuzzy matching + relevance/live ranking, which the Helix search/channels endpoint lacks. The result is mapped into the existing Helix search shape so routes and converters are unchanged. A new "Search method" setting selects it (default) or the Helix search, and it falls back to Helix automatically if GQL errors/empties. Co-Authored-By: Claude Opus 4.8 --- .../resource.language.en_gb/strings.po | 12 ++ resources/lib/twitch_addon/addon/api.py | 14 ++- .../lib/twitch_addon/addon/gql_search.py | 107 ++++++++++++++++++ resources/lib/twitch_addon/addon/utils.py | 8 ++ resources/settings.xml | 2 + 5 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 resources/lib/twitch_addon/addon/gql_search.py diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index b55dfaa9..707b4e2c 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -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 "" diff --git a/resources/lib/twitch_addon/addon/api.py b/resources/lib/twitch_addon/addon/api.py index b1eee1d2..85c106db 100644 --- a/resources/lib/twitch_addon/addon/api.py +++ b/resources/lib/twitch_addon/addon/api.py @@ -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 @@ -230,6 +230,10 @@ 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) @@ -237,6 +241,10 @@ def get_channel_search(self, search_query, after='MA==', first=20): @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) @@ -244,6 +252,10 @@ def get_stream_search(self, search_query, after='MA==', first=20): @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) diff --git a/resources/lib/twitch_addon/addon/gql_search.py b/resources/lib/twitch_addon/addon/gql_search.py new file mode 100644 index 00000000..2aa2d94a --- /dev/null +++ b/resources/lib/twitch_addon/addon/gql_search.py @@ -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 diff --git a/resources/lib/twitch_addon/addon/utils.py b/resources/lib/twitch_addon/addon/utils.py index 1a7aeab1..9122e7ce 100644 --- a/resources/lib/twitch_addon/addon/utils.py +++ b/resources/lib/twitch_addon/addon/utils.py @@ -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() diff --git a/resources/settings.xml b/resources/settings.xml index a6f09d46..67391be4 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -49,6 +49,8 @@ + +