diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..3aa2999
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,34 @@
+name: CI
+
+on:
+ push:
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
+ with:
+ python-version: "3.12"
+
+ - name: Sanity-check Python sources
+ run: python3 -m compileall -q default.py plugin.py service.py lib
+
+ # No Kodi here, and none of these modules may import a third-party test
+ # runner (the addon ships with zero third-party dependencies, by the
+ # repo's own rule - see AutoCompletion.py's module docstring). tests/
+ # fakes xbmc/xbmcaddon/xbmcvfs in sys.modules before import instead of
+ # mocking a function call, because AutoCompletion.py resolves settings
+ # and paths at IMPORT time, not inside a function a test could patch.
+ - name: Run unit tests
+ run: python3 -m unittest discover -s tests -v
diff --git a/lib/vendor/AutoCompletion.py b/lib/vendor/AutoCompletion.py
index 408b68f..354ca4a 100644
--- a/lib/vendor/AutoCompletion.py
+++ b/lib/vendor/AutoCompletion.py
@@ -37,22 +37,56 @@ def get_autocomplete_items(search_str, limit=10, provider=None):
if xbmc.getCondVisibility("System.HasHiddenInput"):
return []
+ language = resolve_language(search_str)
setting = SETTING("autocomplete_provider").lower()
if setting == "youtube":
- provider = GoogleProvider(youtube=True, limit=limit)
+ provider = GoogleProvider(youtube=True, limit=limit, language=language)
elif setting == "google":
- provider = GoogleProvider(limit=limit)
+ provider = GoogleProvider(limit=limit, language=language)
elif setting == "bing":
- provider = BingProvider(limit=limit)
+ provider = BingProvider(limit=limit, language=language)
elif setting == "tmdb":
- provider = TmdbProvider(limit=limit)
+ provider = TmdbProvider(limit=limit, language=language)
else:
- provider = LocalDictProvider(limit=limit)
+ provider = LocalDictProvider(limit=limit, language=language)
provider.limit = limit
return provider.get_predictions(search_str)
+def detect_language(text):
+ """he/en from the query text alone: one Hebrew character (U+0590-U+05FF)
+ makes it Hebrew. That block is the same one `prep_search_str` below
+ already tests against (1488-1514 is its letter subset), reused here per
+ the request rather than invented fresh. Digits and Latin punctuation sit
+ outside the block, so neither affects the result; a mixed string with
+ even one Hebrew character returns "he" — the detector runs on the whole
+ query, not its first token, so "שובר bad" is Hebrew exactly like "שובר
+ שורות" is.
+ """
+ for char in text or "":
+ if "" <= char <= "":
+ return "he"
+ return "en"
+
+
+def resolve_language(search_str):
+ """The effective language for this one request.
+
+ `autocomplete_lang` stays a fixed value for everyone who has not touched
+ the new "auto" option — existing installs keep behaving exactly as
+ before. Picking "auto" is what turns detection on, and the result is
+ returned per call rather than written back into any setting or global:
+ the configured value needs to survive being read again next time
+ unchanged, and a fixed value must never be overwritten by whichever
+ language happened to be typed last.
+ """
+ configured = (SETTING("autocomplete_lang") or "").strip()
+ if configured.lower() == "auto":
+ return detect_language(search_str)
+ return configured
+
+
def prep_search_str(text):
for char in text:
if 1488 <= ord(char) <= 1514:
@@ -66,7 +100,11 @@ class BaseProvider(ABC):
def __init__(self, *args, **kwargs):
self.limit = kwargs.get("limit", 10)
- self.language = SETTING("autocomplete_lang")
+ # `language` is passed per call by get_autocomplete_items (the already
+ # resolved value, auto-detected or fixed). The SETTING() fallback is
+ # for the provider classes' own stated contract of being constructible
+ # on their own; nothing in this codebase instantiates one without it.
+ self.language = kwargs.get("language") or SETTING("autocomplete_lang")
@abstractmethod
def build_url(self, query):
diff --git a/resources/settings.xml b/resources/settings.xml
index f0cf5c6..2080bef 100644
--- a/resources/settings.xml
+++ b/resources/settings.xml
@@ -26,6 +26,11 @@
en
+
+
diff --git a/tests/kodistubs.py b/tests/kodistubs.py
new file mode 100644
index 0000000..b7a390e
--- /dev/null
+++ b/tests/kodistubs.py
@@ -0,0 +1,71 @@
+# SPDX-License-Identifier: GPL-2.0-or-later
+"""Minimal fake `xbmc`/`xbmcaddon`/`xbmcvfs` so `lib/vendor/AutoCompletion.py`
+can be imported outside Kodi.
+
+That module reads addon settings and resolves paths at IMPORT time (module-
+level `PLUGIN_ADDON = xbmcaddon.Addon(PLUGIN_ID)`), so a test cannot simply
+mock a function call — the fakes have to exist in `sys.modules` before the
+`import AutoCompletion` line runs. `install()` does exactly that and nothing
+else: no pip install, no real Kodi, consistent with this repo's own
+zero-third-party-dependency rule (the module docstring: "requests replaced
+with stdlib urllib").
+
+Call `install()` once at the top of a test module, before importing
+AutoCompletion. `settings` is a plain dict a test can mutate directly to
+change what `SETTING("autocomplete_lang")` returns mid-test.
+"""
+from __future__ import annotations
+
+import sys
+import types
+
+settings: dict[str, str] = {"autocomplete_lang": "en", "autocomplete_provider": "Google"}
+
+
+class _FakeAddon:
+ def getSetting(self, key):
+ return settings.get(key, "")
+
+ def getAddonInfo(self, key):
+ return {"path": "/addon", "id": "plugin.program.autocompletion",
+ "profile": "/profile"}.get(key, "")
+
+
+def _fake_xbmcaddon():
+ module = types.ModuleType("xbmcaddon")
+ module.Addon = lambda *a, **k: _FakeAddon()
+ return module
+
+
+def _fake_xbmcvfs():
+ module = types.ModuleType("xbmcvfs")
+ module.translatePath = lambda path: path
+ module.exists = lambda path: False
+ module.mkdirs = lambda path: None
+ module.File = lambda *a, **k: None
+ return module
+
+
+def _fake_xbmc():
+ module = types.ModuleType("xbmc")
+ module.getCondVisibility = lambda condition: False
+ module.log = lambda msg="", level=0: None
+ module.LOGDEBUG = 0
+
+ class _Monitor:
+ def abortRequested(self):
+ return False
+
+ def waitForAbort(self, seconds):
+ return False
+
+ module.Monitor = _Monitor
+ return module
+
+
+def install():
+ """Idempotent: safe to call from every test module that needs it."""
+ for name, factory in (("xbmc", _fake_xbmc), ("xbmcaddon", _fake_xbmcaddon),
+ ("xbmcvfs", _fake_xbmcvfs)):
+ if name not in sys.modules:
+ sys.modules[name] = factory()
diff --git a/tests/test_language.py b/tests/test_language.py
new file mode 100644
index 0000000..20cbef5
--- /dev/null
+++ b/tests/test_language.py
@@ -0,0 +1,126 @@
+# SPDX-License-Identifier: GPL-2.0-or-later
+"""Hebrew/English auto-detection: `detect_language`, `resolve_language`, and
+the providers that build a URL from whichever language came out of them.
+
+The failure mode this guards against is not "detection is wrong" in the
+abstract — it is "a fixed install with `autocomplete_lang=en` quietly starts
+behaving differently", or "the Google/TMDb URL gets `hl=auto`/`language=auto`
+sent to the real API because the sentinel leaked past the point meant to
+resolve it".
+"""
+import os
+import sys
+import unittest
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "lib", "vendor"))
+sys.path.insert(0, os.path.dirname(__file__))
+
+import kodistubs # noqa: E402
+
+kodistubs.install()
+
+import AutoCompletion # noqa: E402
+
+
+class TestDetectLanguage(unittest.TestCase):
+ def test_the_examples_from_the_spec(self):
+ cases = {
+ "brea": "en",
+ "שובר": "he",
+ "breaking": "en",
+ "breaking bad": "en",
+ "שובר שורות": "he",
+ "game of thrones": "en",
+ "משחקי הכס": "he",
+ "matrix 2": "en",
+ "מטריקס 2": "he",
+ }
+ for query, expected in cases.items():
+ self.assertEqual(AutoCompletion.detect_language(query), expected, query)
+
+ def test_mixed_text_is_hebrew_if_any_hebrew_character_is_present(self):
+ """A single Hebrew character anywhere in the string is enough — the
+ detector reads the whole query, not just its first word."""
+ self.assertEqual(AutoCompletion.detect_language("שובר bad"), "he")
+ self.assertEqual(AutoCompletion.detect_language("bad שובר"), "he")
+
+ def test_numbers_and_punctuation_never_flip_the_result(self):
+ self.assertEqual(AutoCompletion.detect_language("matrix 2!?"), "en")
+ self.assertEqual(AutoCompletion.detect_language("מטריקס 2!?"), "he")
+ self.assertEqual(AutoCompletion.detect_language("123 456"), "en")
+
+ def test_empty_or_none_is_english_rather_than_an_exception(self):
+ """A query comes from a live keyboard; an empty string arrives, and
+ must not be the thing that turns a normal search into a traceback."""
+ self.assertEqual(AutoCompletion.detect_language(""), "en")
+ self.assertEqual(AutoCompletion.detect_language(None), "en")
+
+
+class TestResolveLanguage(unittest.TestCase):
+ def setUp(self):
+ kodistubs.settings["autocomplete_lang"] = "en"
+
+ def test_auto_detects_from_the_query(self):
+ kodistubs.settings["autocomplete_lang"] = "auto"
+ self.assertEqual(AutoCompletion.resolve_language("שובר"), "he")
+ self.assertEqual(AutoCompletion.resolve_language("breaking"), "en")
+
+ def test_auto_is_matched_case_and_space_insensitively(self):
+ """A value typed by hand into an advanced setting, or carried over
+ from an older skin, should not silently stop auto-detecting."""
+ for value in ("Auto", "AUTO", " auto ", "auto"):
+ kodistubs.settings["autocomplete_lang"] = value
+ self.assertEqual(AutoCompletion.resolve_language("שובר"), "he")
+
+ def test_a_fixed_language_is_returned_unchanged_no_matter_what_was_typed(self):
+ """This is the backward-compatibility guarantee: an existing install
+ with a real language code set must behave byte-for-byte as before -
+ Hebrew text typed into an `en`-fixed install still resolves to `en`."""
+ for fixed in ("en", "fr", "he", "ja"):
+ kodistubs.settings["autocomplete_lang"] = fixed
+ self.assertEqual(AutoCompletion.resolve_language("שובר"), fixed)
+ self.assertEqual(AutoCompletion.resolve_language("breaking"), fixed)
+
+ def test_an_empty_setting_does_not_crash_and_is_not_treated_as_auto(self):
+ kodistubs.settings["autocomplete_lang"] = ""
+ self.assertEqual(AutoCompletion.resolve_language("שובר"), "")
+
+
+class TestProvidersUseTheResolvedLanguage(unittest.TestCase):
+ """The sentinel "auto" must never reach a real request URL."""
+
+ def test_google_hl_parameter(self):
+ url = AutoCompletion.GoogleProvider(language="he").build_url("x")
+ self.assertIn("hl=he", url)
+ self.assertNotIn("auto", url)
+
+ def test_tmdb_language_parameter(self):
+ url = AutoCompletion.TmdbProvider(language="he").build_url("x")
+ self.assertIn("language=he", url)
+ self.assertNotIn("auto", url)
+
+ def test_bing_is_untouched_because_it_never_used_the_language_setting(self):
+ """Bing's build_url never referenced self.language before this change
+ and must not start now - the spec says explicitly not to touch a
+ provider that doesn't already use the setting."""
+ url = AutoCompletion.BingProvider(language="he").build_url("x")
+ self.assertNotIn("he", url)
+ self.assertNotIn("language", url)
+
+ def test_local_dict_provider_keeps_its_own_separate_setting(self):
+ """LocalDictProvider has always read `autocomplete_lang_local`, a
+ different setting, and must keep doing so even though it now also
+ receives a `language` kwarg it does not ask for."""
+ kodistubs.settings["autocomplete_lang_local"] = "fr"
+ provider = AutoCompletion.LocalDictProvider(language="he")
+ self.assertEqual(provider.language, "fr")
+
+ def test_a_provider_built_with_no_language_kwarg_falls_back_to_the_setting(self):
+ """Nothing in this codebase constructs a provider this way anymore,
+ but the class is still a public contract on its own."""
+ kodistubs.settings["autocomplete_lang"] = "de"
+ self.assertEqual(AutoCompletion.GoogleProvider().language, "de")
+
+
+if __name__ == "__main__":
+ unittest.main()