From dcd4ef58d0cb3ae3d5c88f50736f7716f0c5d893 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Kacprzak?= Date: Mon, 3 Aug 2026 01:06:47 +0200 Subject: [PATCH] Detect GUI language from browser settings --- frigate_camera_control_bridge/app.py | 50 ++++++++++++++++++++++++---- tests/test_language.py | 27 +++++++++++++++ 2 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 tests/test_language.py diff --git a/frigate_camera_control_bridge/app.py b/frigate_camera_control_bridge/app.py index 1215bc3..5a092b0 100644 --- a/frigate_camera_control_bridge/app.py +++ b/frigate_camera_control_bridge/app.py @@ -20,7 +20,7 @@ DEFAULT_CONFIG = "/etc/frigate-camera-control-bridge.json" LOG = logging.getLogger("frigate-camera-control-bridge") STREAM_URL_RE = re.compile(r"(?:rtsp|rtsps|http|https)://[^\s'\"<>]+", re.IGNORECASE) -from .i18n import DEFAULT_LANGUAGE, normalize_language, text_for +from .i18n import DEFAULT_LANGUAGE, available_languages, normalize_language, text_for from .ui import read_static_file, render_page @@ -113,6 +113,39 @@ def env_secret(name): raise RuntimeError(f"failed to read {name}_FILE={path}: {exc}") from exc +def language_from_accept_header(value): + # HTTP Accept-Language can contain regional tags and q weights. The GUI + # ships language catalogs by base code, so pl-PL and pl both resolve to pl. + if not value: + return DEFAULT_LANGUAGE + supported = available_languages() + matches = [] + for index, raw_item in enumerate(value.split(",")): + parts = [item.strip() for item in raw_item.split(";") if item.strip()] + if not parts: + continue + tag = parts[0].lower().replace("_", "-") + if tag == "*": + continue + quality = 1.0 + for param in parts[1:]: + if not param.lower().startswith("q="): + continue + try: + quality = float(param.split("=", 1)[1]) + except ValueError: + quality = 0.0 + if quality <= 0: + continue + base = tag.split("-", 1)[0] + if base in supported: + matches.append((-quality, index, base)) + if not matches: + return DEFAULT_LANGUAGE + matches.sort() + return matches[0][2] + + def config_from_env(): # Env mode is the preferred sidecar mode. It needs only a Frigate API URL, # MQTT connection parameters, and camera credentials. The old JSON config is @@ -1582,7 +1615,7 @@ class CameraControlBridge: class Handler(BaseHTTPRequestHandler): def do_GET(self): parsed = urllib.parse.urlsplit(self.path) - lang = bridge.request_language(parsed.query) + lang = bridge.request_language(parsed.query, self.headers) if parsed.path in ("/healthz", "/readyz"): self.send_json({"ok": True, "actions": bridge.action_count()}) return @@ -1622,7 +1655,7 @@ class CameraControlBridge: def do_POST(self): parsed = urllib.parse.urlsplit(self.path) - lang = bridge.request_language(parsed.query) + lang = bridge.request_language(parsed.query, self.headers) if not bridge.http_authorized(self.headers, parsed.query): self.send_response(401) self.end_headers() @@ -1697,12 +1730,15 @@ class CameraControlBridge: def html_page(self, lang=None, token_param=None): with self.action_lock: actions = list(self.actions.values()) - return render_page(actions, lang or self.default_language, token_param) + return render_page(actions, lang or DEFAULT_LANGUAGE, token_param) - def request_language(self, query): + def request_language(self, query, headers=None): values = urllib.parse.parse_qs(query) - requested = values.get("lang", [self.default_language])[0] - return normalize_language(requested) + requested = values.get("lang", [None])[0] + if requested: + return normalize_language(requested) + accept_language = headers.get("Accept-Language") if headers else None + return language_from_accept_header(accept_language) def _handle_signal(self, signum, _frame): LOG.info("received signal %s, stopping", signum) diff --git a/tests/test_language.py b/tests/test_language.py new file mode 100644 index 0000000..a85bf56 --- /dev/null +++ b/tests/test_language.py @@ -0,0 +1,27 @@ +import unittest + +from frigate_camera_control_bridge.app import language_from_accept_header + + +class LanguageDetectionTest(unittest.TestCase): + def test_browser_language_uses_supported_base_code(self): + self.assertEqual( + language_from_accept_header("pl-PL,pl;q=0.9,en-US;q=0.8"), + "pl", + ) + + def test_quality_weight_wins_over_order(self): + self.assertEqual( + language_from_accept_header("en-US;q=0.4,pl-PL;q=0.9"), + "pl", + ) + + def test_unsupported_languages_fall_back_to_english(self): + self.assertEqual(language_from_accept_header("fr-FR,de;q=0.8"), "en") + + def test_empty_header_falls_back_to_english(self): + self.assertEqual(language_from_accept_header(None), "en") + + +if __name__ == "__main__": + unittest.main()