From f9dbc13166a5779f90171ac1ae2957eefd7da055 Mon Sep 17 00:00:00 2001 From: Pierre Date: Tue, 3 Nov 2026 19:00:22 +0100 Subject: [PATCH 1/5] feat(i18n): add language selection and translation infrastructure - Flask-Babel with English as the source language - language per request: chosen language (sv_lang cookie), else the best Accept-Language match (regional tags fall back to the base language), else English; 12 supported locales - POST /language stores the choice ("Automatic" clears it), with a safe same-site redirect back - reusable language switcher; follows the locale - JS strings: N_() marks them for extraction, components/js_i18n.html exposes window.svT() backed by the current catalog - login page converted as the reference pattern - babel.cfg for pybabel extraction Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + WEBUI/i18n.py | 117 ++++++++++++++++++ WEBUI/static/css/tube_design_system.css | 29 +++++ WEBUI/templates/components/js_i18n.html | 7 ++ .../components/language_switcher.html | 12 ++ WEBUI/templates/login.html | 27 ++-- WEBUI/webui.py | 23 ++++ babel.cfg | 5 + requirements.txt | 1 + tests/test_recorder_reliability.py | 48 +++++++ 10 files changed, 258 insertions(+), 12 deletions(-) create mode 100644 WEBUI/i18n.py create mode 100644 WEBUI/templates/components/js_i18n.html create mode 100644 WEBUI/templates/components/language_switcher.html create mode 100644 babel.cfg diff --git a/.gitignore b/.gitignore index 53c5274..1416ed6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ *.env *.token *.cfg +!/babel.cfg *.key *.pem *.crt diff --git a/WEBUI/i18n.py b/WEBUI/i18n.py new file mode 100644 index 0000000..fb991d5 --- /dev/null +++ b/WEBUI/i18n.py @@ -0,0 +1,117 @@ +"""UI language selection and translation setup (Flask-Babel / gettext). + +Source strings are English. A request's language is, in order: the language the +user picked (``sv_lang`` cookie), the best match for the browser's +``Accept-Language`` header, then English. +""" + +from __future__ import annotations + +import os + +from flask import request +from flask_babel import Babel, get_locale, gettext + +LANG_COOKIE = "sv_lang" +DEFAULT_LOCALE = "en" +TRANSLATIONS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "translations") + +# Babel locale id -> (BCP 47 tag for , native name for the switcher) +LOCALES = { + "en": ("en", "English"), + "de": ("de", "Deutsch"), + "fr": ("fr", "Français"), + "es": ("es", "Español"), + "it": ("it", "Italiano"), + "pt_BR": ("pt-BR", "Português (Brasil)"), + "nl": ("nl", "Nederlands"), + "pl": ("pl", "Polski"), + "tr": ("tr", "Türkçe"), + "ja": ("ja", "日本語"), + "zh_Hans": ("zh-Hans", "简体中文"), + "ko": ("ko", "한국어"), +} + +# Accept-Language tags (lower-case) -> Babel locale id. Plain "pt" and "zh" +# fall back to the only variant we ship. +_TAG_TO_LOCALE = { + "en": "en", "de": "de", "fr": "fr", "es": "es", "it": "it", + "pt-br": "pt_BR", "pt": "pt_BR", "nl": "nl", "pl": "pl", "tr": "tr", + "ja": "ja", "ko": "ko", + "zh-hans": "zh_Hans", "zh-cn": "zh_Hans", "zh-sg": "zh_Hans", "zh": "zh_Hans", +} + + +def normalize_locale(value): + """Map a cookie value or language tag to a supported Babel locale id, else None.""" + raw = str(value or "").strip() + if raw in LOCALES: + return raw + return _TAG_TO_LOCALE.get(raw.replace("_", "-").lower()) + + +def locale_from_accept_language(accept_languages): + """Best supported locale for a werkzeug ``LanguageAccept`` value, else None. + + Tags are tried in the client's quality order; a region the app does not ship + (e.g. ``de-AT``) falls back to its base language. + """ + for tag, _quality in accept_languages: + locale = normalize_locale(tag) + if locale is None and "-" in tag: + locale = normalize_locale(tag.split("-", 1)[0]) + if locale: + return locale + return None + + +def select_locale(): + chosen = normalize_locale(request.cookies.get(LANG_COOKIE)) + if chosen: + return chosen + return locale_from_accept_language(request.accept_languages) or DEFAULT_LOCALE + + +def current_locale_id(): + locale = get_locale() + if locale is None: + return DEFAULT_LOCALE + ident = str(locale) + if ident.startswith("zh"): + return "zh_Hans" + return ident if ident in LOCALES else DEFAULT_LOCALE + + +def N_(text): + """Mark a string for extraction only (pybabel -k N_); translated at runtime.""" + return text + + +# Strings used by static JavaScript. JS looks them up with svT("English text"), +# which returns the translation from window.SV_I18N or the English text itself. +# Add every string a static .js file shows to the user here. +JS_MESSAGES = ( + N_("Loading…"), +) + + +def js_catalog(): + """English source -> translation for the current request's language.""" + return {text: gettext(text) for text in JS_MESSAGES} + + +def template_context(): + locale_id = current_locale_id() + return { + "current_locale": locale_id, + "html_lang": LOCALES[locale_id][0], + "available_locales": [(key, name) for key, (_tag, name) in LOCALES.items()], + "js_i18n": js_catalog, + } + + +def init_app(app): + app.config.setdefault("BABEL_DEFAULT_LOCALE", DEFAULT_LOCALE) + app.config.setdefault("BABEL_TRANSLATION_DIRECTORIES", TRANSLATIONS_DIR) + Babel(app, locale_selector=select_locale) + app.context_processor(template_context) diff --git a/WEBUI/static/css/tube_design_system.css b/WEBUI/static/css/tube_design_system.css index 208c487..a66f19d 100644 --- a/WEBUI/static/css/tube_design_system.css +++ b/WEBUI/static/css/tube_design_system.css @@ -4867,3 +4867,32 @@ body.page-mobile-streamer .ns-pagination .pagination-page.is-current { animation: vodCardPulse 1.2s ease-out; border-color: rgba(77, 99, 255, 0.7) !important; } + + +/* Language switcher (login page, sidebar footer) */ +.ns-lang-switch { + display: flex; + justify-content: center; + margin: 0; +} + +.ns-lang-switch select { + min-height: 44px; + max-width: 100%; + padding: 0 32px 0 12px; + border: 1px solid var(--nebula-border, #26344F); + border-radius: 8px; + background: rgba(11, 18, 32, 0.8); + color: var(--nebula-text-secondary, #A2AEC8); + font-size: 13px; + cursor: pointer; +} + +.ns-lang-switch select:focus-visible { + outline: 2px solid #4D63FF; + outline-offset: 2px; +} + +body.page-login .nebula-login-lang { + margin-top: 16px; +} \ No newline at end of file diff --git a/WEBUI/templates/components/js_i18n.html b/WEBUI/templates/components/js_i18n.html new file mode 100644 index 0000000..4392815 --- /dev/null +++ b/WEBUI/templates/components/js_i18n.html @@ -0,0 +1,7 @@ + diff --git a/WEBUI/templates/components/language_switcher.html b/WEBUI/templates/components/language_switcher.html new file mode 100644 index 0000000..f24d537 --- /dev/null +++ b/WEBUI/templates/components/language_switcher.html @@ -0,0 +1,12 @@ +
+ + + + + +
diff --git a/WEBUI/templates/login.html b/WEBUI/templates/login.html index 1ada33b..28c337b 100644 --- a/WEBUI/templates/login.html +++ b/WEBUI/templates/login.html @@ -1,9 +1,9 @@ - + - Anmelden · StreamVault + {{ _('Sign in') }} · StreamVault @@ -15,22 +15,22 @@ - +

StreamVault

- + {% if error %} @@ -40,10 +40,13 @@

StreamVault

{% endif %} + @@ -60,4 +63,4 @@

StreamVault

})(); - \ No newline at end of file + diff --git a/WEBUI/webui.py b/WEBUI/webui.py index f0abc0c..85e5e8f 100644 --- a/WEBUI/webui.py +++ b/WEBUI/webui.py @@ -58,8 +58,12 @@ from werkzeug.security import check_password_hash, generate_password_hash from werkzeug.utils import safe_join, secure_filename +# `_` is used as a throwaway name throughout this module, so translations use the +# full names (both are extracted by pybabel by default). +from flask_babel import gettext, lazy_gettext from recorder import chat_events from recorder import clip_engine +from WEBUI import i18n as ui_i18n from core import config from core import database from core import twitch_api @@ -367,6 +371,7 @@ def load_secret_key(): PERMANENT_SESSION_LIFETIME=timedelta(seconds=SESSION_COOKIE_MAX_AGE_SEC), SESSION_REFRESH_EACH_REQUEST=True, ) +ui_i18n.init_app(app) # ============ SECURITY: Flask-Limiter Setup ============ # Rate limiting to prevent brute force and DoS attacks @@ -7480,6 +7485,24 @@ def logout(): return redirect(url_for("login")) +@app.route("/language", methods=["POST"]) +@limiter.limit("60 per hour") +def language_update(): + """Remember the chosen UI language; also reachable from the login page.""" + nxt = request.form.get("next") or "" + target = nxt if is_safe_next(nxt) else url_for("index") + resp = redirect(target) + locale = ui_i18n.normalize_locale(request.form.get("lang")) + if locale: + resp.set_cookie( + ui_i18n.LANG_COOKIE, locale, max_age=365 * 86400, samesite="Lax", + secure=app.config["SESSION_COOKIE_SECURE"], httponly=True, + ) + else: + resp.delete_cookie(ui_i18n.LANG_COOKIE) # "automatic": follow the browser again + return resp + + @app.route("/settings/theme", methods=["POST"]) @login_required @limiter.limit("120 per hour") diff --git a/babel.cfg b/babel.cfg new file mode 100644 index 0000000..e4da587 --- /dev/null +++ b/babel.cfg @@ -0,0 +1,5 @@ +# pybabel extraction config. Regenerate the catalog template with: +# pybabel extract -F babel.cfg -k lazy_gettext -k N_ -o WEBUI/translations/messages.pot . +[python: WEBUI/**.py] +[python: core/**.py] +[jinja2: WEBUI/templates/**.html] diff --git a/requirements.txt b/requirements.txt index d6133f9..8ec16de 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ # StreamVault - Core Requirements Flask>=3.0.0,<3.2.0 Flask-Limiter>=3.5.0 +Flask-Babel>=4.0.0,<5 Werkzeug>=3.0.0,<3.2.0 Jinja2>=3.1.2 requests>=2.31.0 diff --git a/tests/test_recorder_reliability.py b/tests/test_recorder_reliability.py index ddf86d3..445454f 100644 --- a/tests/test_recorder_reliability.py +++ b/tests/test_recorder_reliability.py @@ -713,6 +713,54 @@ def test_backup_create_without_script_reports_missing_config(self): self.assertIn("VOD_BACKUP_SCRIPT", urllib.parse.unquote(response.headers["Location"])) +class LanguageSelectionTests(unittest.TestCase): + """UI language: explicit choice (cookie) > browser Accept-Language > English.""" + + @classmethod + def setUpClass(cls): + from WEBUI import i18n, webui + cls.i18n, cls.webui = i18n, webui + webui.app.config.update(TESTING=True) + + def _locale(self, accept=None, cookie=None): + headers = {"Accept-Language": accept} if accept else {} + if cookie: + headers["Cookie"] = f"{self.i18n.LANG_COOKIE}={cookie}" + with self.webui.app.test_request_context("/", headers=headers): + return self.i18n.select_locale() + + def test_browser_language_is_used(self): + self.assertEqual(self._locale("fr-FR,fr;q=0.9,en;q=0.8"), "fr") + self.assertEqual(self._locale("pt-BR,pt;q=0.9"), "pt_BR") + self.assertEqual(self._locale("zh-CN,zh;q=0.9"), "zh_Hans") + + def test_unsupported_region_falls_back_to_base_language(self): + self.assertEqual(self._locale("de-AT"), "de") + + def test_quality_order_is_respected(self): + self.assertEqual(self._locale("sv-SE,ja;q=0.7,de;q=0.5"), "ja") + + def test_unsupported_or_missing_language_falls_back_to_english(self): + self.assertEqual(self._locale("sv-SE,fi;q=0.8"), "en") + self.assertEqual(self._locale(), "en") + + def test_cookie_overrides_browser(self): + self.assertEqual(self._locale("fr-FR", cookie="de"), "de") + self.assertEqual(self._locale("fr-FR", cookie="bogus"), "fr") + + def test_language_route_sets_cookie_and_redirects_safely(self): + client = self.webui.app.test_client() + client.get("/login") + with client.session_transaction() as sess: + token = sess["_csrf_token"] + resp = client.post("/language", data={"lang": "ko", "next": "/login", "csrf_token": token}) + self.assertEqual(resp.status_code, 302) + self.assertTrue(resp.headers["Location"].endswith("/login")) + self.assertIn(f"{self.i18n.LANG_COOKIE}=ko", resp.headers.get("Set-Cookie", "")) + resp = client.post("/language", data={"lang": "ko", "next": "https://evil.example", "csrf_token": token}) + self.assertFalse(resp.headers["Location"].startswith("https://evil")) + + class AssetVersionTests(IsolatedBaseTest): """?v= must change whenever a static file changes, without a manual bump.""" From 2007be1aad6ba9a9bf6ae782f8a11124984c9548 Mon Sep 17 00:00:00 2001 From: Pierre Date: Tue, 3 Nov 2026 19:03:55 +0100 Subject: [PATCH 2/5] feat(i18n): serve terms, privacy policy and house rules in DE and EN The German documents stay legally binding. German UI shows them; every other UI language shows the English translation (content/*.en.md and tube_rules.RULES_EN). A translation is only used when it carries the same termsVersion/privacyVersion as the original, so the version users accept never depends on their language. Discord output and version checks keep using the German original. The consent phrase is shown per language (AKZEPTIERE / ACCEPT); either is accepted. Co-Authored-By: Claude Opus 5.5 --- content/agb.en.md | 29 ++++++++ content/datenschutz.en.md | 18 +++++ core/tube_rules.py | 40 +++++++++-- core/tube_terms.py | 108 +++++++++++++++++++++-------- tests/test_recorder_reliability.py | 17 +++++ 5 files changed, 177 insertions(+), 35 deletions(-) create mode 100644 content/agb.en.md create mode 100644 content/datenschutz.en.md diff --git a/content/agb.en.md b/content/agb.en.md new file mode 100644 index 0000000..2e057e8 --- /dev/null +++ b/content/agb.en.md @@ -0,0 +1,29 @@ +--- +termsVersion: 2026-09-09-v3 +effectiveDate: 2026-09-09 +title: Terms of Use – StreamVault +--- + +## Note + +This is an English translation provided for convenience. The German version is legally binding. + +These terms of use govern the relationship between the operator and the users of this private instance. + +## §1 Scope + +(1) These terms of use apply to the use of this platform and its related accounts and features (VODs, chat archive, live viewing, clips). + +(2) The service is a closed system for authorised users. + +(3) Access is granted only after approval by an administrator. + +## §2 Access and account + +(1) Login details and share links must not be passed on to unauthorised third parties. + +(2) The operator may revoke access at any time in case of misuse. + +## §3 User obligations + +(1) The system must not be misused in a way that overloads the server's resources. diff --git a/content/datenschutz.en.md b/content/datenschutz.en.md new file mode 100644 index 0000000..261e04f --- /dev/null +++ b/content/datenschutz.en.md @@ -0,0 +1,18 @@ +--- +privacyVersion: 2026-11-03-v1 +effectiveDate: 2026-11-03 +title: Privacy Policy – StreamVault +--- + +## Note + +This is an English translation provided for convenience. The German version is legally binding. + +## 1. Collection and storage of data +When this system is used, access data (IP address, time of access, pages requested) is recorded in local log files to keep the system secure and running without disruption. + +## 2. User accounts +For registered users, the username, password hash and role membership are stored in the local SQLite database. + +## 3. Disclosure to third parties +Personal data is not disclosed to third parties. diff --git a/core/tube_rules.py b/core/tube_rules.py index 52bfeb2..e756e09 100644 --- a/core/tube_rules.py +++ b/core/tube_rules.py @@ -37,12 +37,35 @@ "**Verstoß = sofortiger Entzug.** ACL weg, ggf. Kick/Ban.", ] +# English translation for the web UI in every non-German language. The German +# text above stays binding; keep both lists in the same order and length. +RULES_EN = { + "title": "StreamVault access: binding rules", + "accept_phrase": "ACCEPT", + "footer": "Note: these rules are house rules for this group. They do not replace third-party rights.", + "items": [ + "**Access for approved members only.** Do not share accounts, invites or login details. Anyone who wants to bring someone asks the approver first; no self-invites.", + "**No public traces.** No screenshots, clips, links or mentions on other Discord servers, Reddit, Twitter/X, Twitch chat, forums and so on.", + "**Links stay internal.** StreamVault links, share tokens and invites stay on this server and in approved channels. No forwarding to non-members.", + "**No sharing of files.** VODs, downloads, chat exports and screen recordings must not go to cloud services, other servers or third parties.", + "**No bulk downloads, no mirrors.** No mass downloading, no second archive, no re-hosting.", + "**No monetisation.** No donations, ads, paid access or giveaways with access as a prize.", + "**No \"official\" framing.** Do not claim the project is authorised or tolerated by the streamer.", + "**Do not share technical details.** No hosting, bot, admin or token details outside the admin group.", + "**Report problems.** Suspected leak or public post: tell the admins immediately.", + "**Violation = immediate removal.** Access revoked, possibly kick/ban.", + ], +} + def current_version() -> str: return RULES_VERSION -def accept_phrase() -> str: +def accept_phrase(lang: str | None = None) -> str: + """Phrase shown to the user; German in German, English in every other language.""" + if lang and lang != "de": + return RULES_EN["accept_phrase"] return RULES_ACCEPT_PHRASE @@ -58,8 +81,9 @@ def rules_plain_lines() -> list[str]: return lines -def rules_web_context(*, site_url: str = "") -> dict[str, Any]: +def rules_web_context(*, site_url: str = "", lang: str | None = None) -> dict[str, Any]: site = (site_url or "").rstrip("/") + english = bool(lang and lang != "de") def _md_bold_to_html(text: str) -> str: import html as _html @@ -68,12 +92,13 @@ def _md_bold_to_html(text: str) -> str: escaped = _html.escape(text) return re.sub(r"\*\*(.+?)\*\*", r"\1", escaped) + items = RULES_EN["items"] if english else RULES_ITEMS return { - "rules_title": RULES_TITLE, - "rules_items": [_md_bold_to_html(item) for item in RULES_ITEMS], - "rules_footer": RULES_FOOTER, + "rules_title": RULES_EN["title"] if english else RULES_TITLE, + "rules_items": [_md_bold_to_html(item) for item in items], + "rules_footer": RULES_EN["footer"] if english else RULES_FOOTER, "rules_version": RULES_VERSION, - "rules_accept_phrase": RULES_ACCEPT_PHRASE, + "rules_accept_phrase": accept_phrase(lang), "rules_url": f"{site}/agb" if site else "/agb", } @@ -109,7 +134,8 @@ def normalize_accept_input(raw: str | None) -> str: def validate_accept_input(raw: str | None) -> None: - if normalize_accept_input(raw) != RULES_ACCEPT_PHRASE: + # Either language's phrase counts: the user may switch the UI language mid-way. + if normalize_accept_input(raw) not in (RULES_ACCEPT_PHRASE, RULES_EN["accept_phrase"]): raise ValueError( f"AGB inkl. Nutzungsregeln nicht akzeptiert. Bitte exakt `{RULES_ACCEPT_PHRASE}` eingeben " f"(siehe #regeln / #agb bzw. {LOGIN_RULES_HINT})." diff --git a/core/tube_terms.py b/core/tube_terms.py index d218567..6fdc266 100644 --- a/core/tube_terms.py +++ b/core/tube_terms.py @@ -11,6 +11,7 @@ from __future__ import annotations import html as html_lib +import logging import os import re import time @@ -21,6 +22,7 @@ from core import tube_rules from core.config import APP_DIR +logger = logging.getLogger(__name__) # Canonical copies live in the repo content/ directory. Do not resolve them # next to this module: core/content only had a partial symlink after the split. AGB_PATH = os.path.join(APP_DIR, "content", "agb.md") @@ -49,13 +51,37 @@ def _parse_frontmatter(raw: str, *, path: str) -> tuple[dict[str, str], str]: return meta, match.group(2).strip() + "\n" -def _load_document() -> dict[str, Any]: - with open(AGB_PATH, encoding="utf-8") as fh: +def display_lang() -> str: + """UI language of the current web request ("de", "en", ...); "de" outside a request. + + The German files are the binding originals. Other UI languages show the + English translation (``.en.md``) when it exists. + """ + try: + from flask import has_request_context + from flask_babel import get_locale + except ImportError: + return "de" + if not has_request_context(): + return "de" + locale = get_locale() + return str(locale).split("_", 1)[0] if locale else "en" + + +def _localized_path(path: str, lang: str | None) -> str: + if not lang or lang == "de": + return path + translated = path[: -len(".md")] + ".en.md" + return translated if os.path.isfile(translated) else path + + +def _load_document(path: str = AGB_PATH) -> dict[str, Any]: + with open(path, encoding="utf-8") as fh: raw = fh.read() - meta, body = _parse_frontmatter(raw, path=AGB_PATH) + meta, body = _parse_frontmatter(raw, path=path) version = str(meta.get("termsVersion") or "").strip() if not version: - raise ValueError("content/agb.md: termsVersion fehlt") + raise ValueError(f"{path}: termsVersion fehlt") lowered = raw.lower() for phrase in _FORBIDDEN_PHRASES: if phrase in lowered: @@ -71,17 +97,29 @@ def _load_document() -> dict[str, Any]: } -@lru_cache(maxsize=1) -def _cached_document(mtime_ns: int) -> dict[str, Any]: - return _load_document() +@lru_cache(maxsize=4) +def _cached_document(path: str, mtime_ns: int) -> dict[str, Any]: + return _load_document(path) + +def load_document(*, force: bool = False, lang: str | None = None) -> dict[str, Any]: + """Terms document; ``lang=None`` is the binding German original. -def load_document(*, force: bool = False) -> dict[str, Any]: + A translation is only shown if it carries the same termsVersion, so the + version users accept is always the one of the German original. + """ if force: _cached_document.cache_clear() try: - mtime_ns = os.stat(AGB_PATH).st_mtime_ns - return _cached_document(mtime_ns) + original = _cached_document(AGB_PATH, os.stat(AGB_PATH).st_mtime_ns) + path = _localized_path(AGB_PATH, lang) + if path == AGB_PATH: + return original + translated = _cached_document(path, os.stat(path).st_mtime_ns) + if translated["terms_version"] != original["terms_version"]: + logger.warning("Ignoring %s: termsVersion differs from %s", path, AGB_PATH) + return original + return translated except OSError: return { "terms_version": "v1.0", @@ -162,8 +200,8 @@ def close_list() -> None: return "\n".join(parts) -def terms_html() -> str: - return markdown_to_html(load_document()["body_md"]) +def terms_html(lang: str | None = None) -> str: + return markdown_to_html(load_document(lang=lang)["body_md"]) def terms_plain_text() -> str: @@ -204,14 +242,14 @@ def terms_discord_messages(*, site_url: str = "") -> list[str]: return [body] -@lru_cache(maxsize=1) -def _cached_privacy(mtime_ns: int) -> dict[str, Any]: - with open(PRIVACY_PATH, encoding="utf-8") as fh: +@lru_cache(maxsize=4) +def _cached_privacy(path: str, mtime_ns: int) -> dict[str, Any]: + with open(path, encoding="utf-8") as fh: raw = fh.read() - meta, body = _parse_frontmatter(raw, path=PRIVACY_PATH) + meta, body = _parse_frontmatter(raw, path=path) version = str(meta.get("privacyVersion") or "").strip() if not version: - raise ValueError("content/datenschutz.md: privacyVersion fehlt") + raise ValueError(f"{path}: privacyVersion fehlt") return { "privacy_version": version, "effective_date": str(meta.get("effectiveDate") or "").strip(), @@ -221,44 +259,58 @@ def _cached_privacy(mtime_ns: int) -> dict[str, Any]: } -def load_privacy_document(*, force: bool = False) -> dict[str, Any]: +def load_privacy_document(*, force: bool = False, lang: str | None = None) -> dict[str, Any]: + """Privacy policy; ``lang=None`` is the German original, see load_document().""" if force: _cached_privacy.cache_clear() try: - mtime_ns = os.stat(PRIVACY_PATH).st_mtime_ns + original = _cached_privacy(PRIVACY_PATH, os.stat(PRIVACY_PATH).st_mtime_ns) except OSError as exc: raise FileNotFoundError(f"Datenschutz-Datei fehlt: {PRIVACY_PATH}") from exc - return _cached_privacy(mtime_ns) + path = _localized_path(PRIVACY_PATH, lang) + if path == PRIVACY_PATH: + return original + try: + translated = _cached_privacy(path, os.stat(path).st_mtime_ns) + except OSError as exc: + logger.warning("Privacy translation %s unreadable, showing original: %s", path, exc) + return original + if translated["privacy_version"] != original["privacy_version"]: + logger.warning("Ignoring %s: privacyVersion differs from %s", path, PRIVACY_PATH) + return original + return translated -def privacy_html() -> str: - return markdown_to_html(load_privacy_document()["body_md"]) +def privacy_html(lang: str | None = None) -> str: + return markdown_to_html(load_privacy_document(lang=lang)["body_md"]) def privacy_web_context(*, site_url: str = "") -> dict[str, Any]: site = (site_url or "").rstrip("/") - doc = load_privacy_document() + lang = display_lang() + doc = load_privacy_document(lang=lang) return { "privacy_title": doc["title"], "privacy_version": doc["privacy_version"], "privacy_effective_date": doc["effective_date"], - "privacy_html": privacy_html(), + "privacy_html": privacy_html(lang), "privacy_url": f"{site}/datenschutz" if site else "/datenschutz", } def terms_web_context(*, site_url: str = "") -> dict[str, Any]: site = (site_url or "").rstrip("/") - doc = load_document() - rules_ctx = tube_rules.rules_web_context(site_url=site) + lang = display_lang() + doc = load_document(lang=lang) + rules_ctx = tube_rules.rules_web_context(site_url=site, lang=lang) privacy = privacy_web_context(site_url=site) return { "terms_title": doc["title"], "terms_version": doc["terms_version"], "terms_effective_date": doc["effective_date"], - "terms_html": terms_html(), + "terms_html": terms_html(lang), "terms_url": f"{site}/agb" if site else "/agb", - "terms_accept_phrase": tube_rules.accept_phrase(), + "terms_accept_phrase": rules_ctx["rules_accept_phrase"], "rules_title": rules_ctx["rules_title"], "rules_items": rules_ctx["rules_items"], "rules_footer": rules_ctx["rules_footer"], diff --git a/tests/test_recorder_reliability.py b/tests/test_recorder_reliability.py index 445454f..d35ac2b 100644 --- a/tests/test_recorder_reliability.py +++ b/tests/test_recorder_reliability.py @@ -831,6 +831,23 @@ def test_shipped_documents_parse(self): self.assertTrue(tube_terms.load_privacy_document(force=True)["privacy_version"]) self.assertIn("privacy_url", tube_terms.terms_web_context(site_url="")) + def test_english_translations_match_binding_versions(self): + from core import tube_rules, tube_terms + content = Path(__file__).resolve().parents[1] / "content" + with mock.patch.object(tube_terms, "AGB_PATH", str(content / "agb.md")), \ + mock.patch.object(tube_terms, "PRIVACY_PATH", str(content / "datenschutz.md")): + de_terms = tube_terms.load_document(force=True) + en_terms = tube_terms.load_document(lang="en") + self.assertEqual(en_terms["terms_version"], de_terms["terms_version"]) + self.assertNotEqual(en_terms["body_md"], de_terms["body_md"]) + de_priv = tube_terms.load_privacy_document(force=True) + fr_priv = tube_terms.load_privacy_document(lang="fr") # non-German -> English + self.assertEqual(fr_priv["privacy_version"], de_priv["privacy_version"]) + self.assertIn("Privacy", fr_priv["title"]) + self.assertEqual(len(tube_rules.RULES_EN["items"]), len(tube_rules.RULES_ITEMS)) + for phrase in ("AKZEPTIERE", "accept"): + tube_rules.validate_accept_input(phrase) # both languages' phrases are valid + def test_legal_pages_render(self): from WEBUI import webui webui.ensure_admin_user() From 5118098461b1a0891bf92b249b59baa92920e746 Mon Sep 17 00:00:00 2001 From: Pierre Date: Tue, 3 Nov 2026 19:31:24 +0100 Subject: [PATCH 3/5] feat(i18n): make the whole UI translatable, English source + German All user-visible text now goes through gettext with English msgids: 36 templates (text, attributes, inline script strings via |tojson), 344 Python messages (gettext at request time, lazy_gettext for module constants), and 52 static JS strings (svT() + JS_MESSAGES). The German catalog restores the previous UI wording: 1083/1083 messages. - WEBUI/translations: messages.pot plus compiled de catalog (.mo is committed so installs need no build step) - scripts/check_translations.py: placeholders must match the source and literal % must be %% (Flask-Babel formats every message) - mobile VOD page counter is found via data-vod-page-counter instead of matching the German word "Seite" - values compared as keys or stored in the database (stream-title sentinel, clip_engine status checks, seeded group descriptions, storage last_error texts asserted by tests) intentionally stay German - tests: the storage page test now requests Accept-Language: de, so its German assertions (incl. assertNotIn) keep their meaning; no assertion was changed Co-Authored-By: Claude Opus 5.5 --- WEBUI/i18n.py | 53 +- WEBUI/static/chat_emotes.js | 6 +- WEBUI/static/dashboard_live.js | 20 +- WEBUI/static/live_clip_overlay.js | 57 +- WEBUI/static/media_session.js | 3 +- WEBUI/static/mobile_landscape_chat.js | 21 +- WEBUI/static/photo_viewer.js | 11 +- WEBUI/static/vod_debug.js | 11 +- WEBUI/static/vod_seek_hover.js | 23 +- WEBUI/templates/admin.html | 194 +- WEBUI/templates/admin_traffic.html | 62 +- WEBUI/templates/admin_users.html | 256 +- WEBUI/templates/agb.html | 41 +- WEBUI/templates/base.html | 37 +- WEBUI/templates/channels.html | 58 +- WEBUI/templates/chat_export.html | 8 +- WEBUI/templates/chat_obs.html | 5 +- WEBUI/templates/clip_editor.html | 30 +- .../templates/components/archive_filters.html | 14 +- WEBUI/templates/components/sidebar.html | 16 +- WEBUI/templates/datenschutz.html | 16 +- WEBUI/templates/files.html | 34 +- WEBUI/templates/hls.html | 19 +- WEBUI/templates/index.html | 70 +- WEBUI/templates/live.html | 71 +- WEBUI/templates/live_obs.html | 4 +- WEBUI/templates/maintenance.html | 20 +- WEBUI/templates/mobile_index.html | 35 +- WEBUI/templates/mobile_player.html | 43 +- WEBUI/templates/mobile_streamer.html | 60 +- WEBUI/templates/regeln.html | 24 +- WEBUI/templates/search.html | 32 +- WEBUI/templates/storage.html | 189 +- WEBUI/templates/streamer.html | 172 +- WEBUI/templates/twitch_live_hub.html | 70 +- WEBUI/templates/twitch_watch.html | 37 +- WEBUI/templates/twitch_watch_chat.js.html | 2 +- WEBUI/templates/twitch_watch_clip_markup.html | 28 +- WEBUI/templates/twitch_watch_mobile.html | 55 +- WEBUI/templates/twitch_watch_proxy.js.html | 34 +- WEBUI/templates/vod.html | 163 +- WEBUI/translations/de/LC_MESSAGES/messages.mo | Bin 0 -> 80716 bytes WEBUI/translations/de/LC_MESSAGES/messages.po | 3418 ++++++++++++++++ WEBUI/translations/messages.pot | 3538 +++++++++++++++++ WEBUI/webui.py | 603 +-- core/database.py | 30 +- core/storage/base.py | 26 +- core/storage/crypto.py | 14 +- core/storage/providers/dropbox.py | 14 +- core/storage/providers/google_drive.py | 22 +- core/storage/providers/onedrive.py | 10 +- core/storage/providers/s3.py | 38 +- core/storage/providers/webdav.py | 30 +- core/storage/registry.py | 10 +- core/storage/service.py | 68 +- core/tube_auth.py | 23 +- core/twitch_api.py | 12 +- core/user_traffic.py | 30 +- core/vod_watch_slots.py | 12 +- scripts/check_translations.py | 80 + tests/test_storage.py | 3 +- 61 files changed, 8614 insertions(+), 1471 deletions(-) create mode 100644 WEBUI/translations/de/LC_MESSAGES/messages.mo create mode 100644 WEBUI/translations/de/LC_MESSAGES/messages.po create mode 100644 WEBUI/translations/messages.pot create mode 100644 scripts/check_translations.py diff --git a/WEBUI/i18n.py b/WEBUI/i18n.py index fb991d5..71cabda 100644 --- a/WEBUI/i18n.py +++ b/WEBUI/i18n.py @@ -91,7 +91,58 @@ def N_(text): # which returns the translation from window.SV_I18N or the English text itself. # Add every string a static .js file shows to the user here. JS_MESSAGES = ( - N_("Loading…"), + N_("Points"), + N_("Redeemed “%(title)s”"), + N_("Page %(page)s / %(pages)s (%(count)s VODs)"), + N_("Page %(page)s of %(pages)s"), + N_("%(count)s VODs in quick access"), + N_("Page %(page)s / %(total)s"), + N_("%(n)s new VODs available"), + N_("1 new VOD available"), + N_("Watch now"), + N_("Open clip"), + N_("Pause preview"), + N_("Play preview"), + N_("Buffer is still short — the clip window grows with it."), + N_("Discard clip? Unsaved changes will be lost."), + N_("✓ CLIP SAVED"), + N_("Clip saved."), + N_("✕ CLIP FAILED"), + N_("Clip failed."), + N_("Clip is taking too long."), + N_("Creating clip…"), + N_("Could not load status."), + N_("Clip URL is missing on this page."), + N_("Clip is already starting…"), + N_("Could not start clip."), + N_("Clip created."), + N_("Could not create clip."), + N_("Invalid server response (%(status)s)."), + N_("Saved."), + N_("Archive"), + N_("Exit fullscreen"), + N_("Fullscreen"), + N_("Collapse chat"), + N_("Expand chat"), + N_("Resume playback"), + N_("Pause playback"), + N_("Play"), + N_("Pause"), + N_("Unmute"), + N_("Mute"), + N_("Muted"), + N_("Sound"), + N_("Photo viewer"), + N_("Image controls"), + N_("Zoom out"), + N_("Zoom in"), + N_("Close"), + N_("Drive quota active — do not press Play again (it extends the block). Seek back into the already loaded part, or wait about %(minutes)s min."), + N_("Video player controls"), + N_("Play / pause"), + N_("Progress"), + N_("Volume"), + N_("Start playback"), ) diff --git a/WEBUI/static/chat_emotes.js b/WEBUI/static/chat_emotes.js index 499d63f..ce92944 100644 --- a/WEBUI/static/chat_emotes.js +++ b/WEBUI/static/chat_emotes.js @@ -1,5 +1,6 @@ (function () { 'use strict'; + var svT = window.svT || function (s) { return s; }; const nativeIdPattern = /^[A-Za-z0-9_-]{1,64}$/; const badgePartPattern = /^[A-Za-z0-9_-]{1,64}$/; @@ -463,8 +464,9 @@ if (!reward || typeof reward !== 'object') return ''; const title = String(reward.title || 'Channel Points').trim() || 'Channel Points'; const cost = Number(reward.cost); - let html = 'Punkte '; - html += `„${escapeHtml(title)}“ eingelöst`; + let html = '' + escapeHtml(svT('Points')) + ' '; + const redeemed = escapeHtml(svT('Redeemed “%(title)s”')).replace('%(title)s', () => escapeHtml(title)); + html += `${redeemed}`; if (Number.isFinite(cost) && cost > 0) { html += ` ${escapeHtml(String(cost))}`; } diff --git a/WEBUI/static/dashboard_live.js b/WEBUI/static/dashboard_live.js index 9a55768..868ce63 100644 --- a/WEBUI/static/dashboard_live.js +++ b/WEBUI/static/dashboard_live.js @@ -4,6 +4,7 @@ */ (function () { "use strict"; + var svT = window.svT || function (s) { return s; }; var pollTimer = null; var isFetching = false; @@ -82,17 +83,24 @@ // Desktop inline header var headerCount = document.querySelector(".ns-pagination-inline span"); if (headerCount) { - headerCount.innerHTML = 'Seite ' + data.page + ' / ' + data.total_pages + ' (' + data.total_count + ' VODs)'; + headerCount.innerHTML = svT('Page %(page)s / %(pages)s (%(count)s VODs)') + .replace('%(page)s', '' + data.page + '') + .replace('%(pages)s', '' + data.total_pages + '') + .replace('%(count)s', data.total_count); } // Desktop bottom nav var bottomNavInfo = document.querySelector(".ns-pagination-bottom .ns-pagination-info"); if (bottomNavInfo) { - bottomNavInfo.innerHTML = 'Seite ' + data.page + ' von ' + data.total_pages + ' • ' + data.total_count + ' VODs im Schnellzugriff'; + bottomNavInfo.innerHTML = svT('Page %(page)s of %(pages)s') + .replace('%(page)s', '' + data.page + '') + .replace('%(pages)s', '' + data.total_pages + '') + + ' • ' + svT('%(count)s VODs in quick access').replace('%(count)s', data.total_count); } // Mobile header counter - var mobileCounter = document.querySelector("section > div > span:nth-child(2)"); - if (mobileCounter && mobileCounter.textContent.indexOf("Seite") !== -1) { - mobileCounter.textContent = 'Seite ' + data.page + ' / ' + data.total_pages; + var mobileCounter = document.querySelector("[data-vod-page-counter]"); + if (mobileCounter) { + mobileCounter.textContent = svT('Page %(page)s / %(total)s') + .replace('%(page)s', data.page).replace('%(total)s', data.total_pages); } } @@ -106,7 +114,7 @@ var pill = document.createElement("div"); pill.id = "ns-vod-update-pill"; pill.style.cssText = "position: sticky; top: 12px; z-index: 100; margin: 0 auto 14px auto; max-width: 320px; background: linear-gradient(135deg, #4D63FF 0%, #754DFF 100%); color: #FFF; padding: 10px 18px; border-radius: 20px; font-size: 13px; font-weight: 600; text-align: center; box-shadow: 0 4px 16px rgba(77,99,255,0.4); cursor: pointer; transition: transform 0.2s ease, opacity 0.2s ease;"; - pill.innerHTML = '✨ ' + (newCount > 1 ? newCount + ' neue VODs' : '1 neues VOD') + ' verfügbar • Jetzt ansehen'; + pill.innerHTML = '✨ ' + (newCount > 1 ? svT('%(n)s new VODs available').replace('%(n)s', newCount) : svT('1 new VOD available')) + ' • ' + svT('Watch now'); pill.addEventListener("click", function () { window.location.href = "/?page=1"; diff --git a/WEBUI/static/live_clip_overlay.js b/WEBUI/static/live_clip_overlay.js index a84831a..8675521 100644 --- a/WEBUI/static/live_clip_overlay.js +++ b/WEBUI/static/live_clip_overlay.js @@ -1,5 +1,6 @@ (function () { "use strict"; + var svT = window.svT || function (s) { return s; }; var CLIP_DEFAULT_DURATION = 30; var CLIP_MAX_DURATION = 300; @@ -106,7 +107,7 @@ if (linkUrl) { link.hidden = false; link.href = linkUrl; - link.textContent = "Clip öffnen"; + link.textContent = svT("Open clip"); } else { link.hidden = true; link.removeAttribute("href"); @@ -183,7 +184,7 @@ clipPreviewToggle.innerHTML = isPlaying ? '' : ''; - clipPreviewToggle.title = isPlaying ? "Vorschau pausieren" : "Vorschau starten"; + clipPreviewToggle.title = isPlaying ? svT("Pause preview") : svT("Play preview"); clipPreviewToggle.setAttribute("aria-label", clipPreviewToggle.title); } @@ -437,7 +438,7 @@ clipResult.hidden = true; clipResult.replaceChildren(); } - setClipStatus(maxSeconds < 5 ? "Puffer noch kurz — das Clip-Fenster wächst mit." : "", ""); + setClipStatus(maxSeconds < 5 ? svT("Buffer is still short — the clip window grows with it.") : "", ""); setClipRange(nextStart, nextEnd, { dirty: false, seekPreview: false }); clipOverlay.hidden = false; clipOverlay.setAttribute("aria-hidden", "false"); @@ -465,7 +466,7 @@ function closeClipOverlay(force) { if (!clipOverlay || clipOverlay.hidden) return; if (!force && clipHasUnsavedChanges()) { - if (!window.confirm("Clip verwerfen? Nicht gespeicherte Änderungen gehen verloren.")) return; + if (!window.confirm(svT("Discard clip? Unsaved changes will be lost."))) return; } pauseClipPreview(); clearSeekRetry(); @@ -508,21 +509,21 @@ var data = result.data || {}; var status = data.status || ""; if (status === "done") { - showToast("success", "✓ CLIP GESPEICHERT", data.message || "Clip gespeichert.", data.clip_url || ""); + showToast("success", svT("✓ CLIP SAVED"), data.message || svT("Clip saved."), data.clip_url || ""); return; } if (status === "error" || !result.okHttp) { - showToast("error", "✕ CLIP FEHLGESCHLAGEN", data.message || data.error || "Clip fehlgeschlagen."); + showToast("error", svT("✕ CLIP FAILED"), data.message || data.error || svT("Clip failed.")); return; } if (Date.now() - started > POLL_TIMEOUT_MS) { - showToast("error", "✕ CLIP FEHLGESCHLAGEN", "Clip dauert zu lange."); + showToast("error", svT("✕ CLIP FAILED"), svT("Clip is taking too long.")); return; } - showToast("pending", "● CLIP", data.message || "Clip wird erstellt..."); + showToast("pending", "● CLIP", data.message || svT("Creating clip…")); pollTimer = window.setTimeout(tick, POLL_MS); }).catch(function () { - showToast("error", "✕ CLIP FEHLGESCHLAGEN", "Status konnte nicht geladen werden."); + showToast("error", svT("✕ CLIP FAILED"), svT("Could not load status.")); }); }; tick(); @@ -530,16 +531,16 @@ function triggerInstantClip() { if (!instantUrl) { - showToast("error", "✕ CLIP FEHLGESCHLAGEN", "Clip-URL fehlt auf dieser Seite."); + showToast("error", svT("✕ CLIP FAILED"), svT("Clip URL is missing on this page.")); return; } var now = Date.now(); if (now - lastInstantAt < CLIENT_DEBOUNCE_MS) { - showToast("pending", "● CLIP", "Clip wird bereits gestartet…"); + showToast("pending", "● CLIP", svT("Clip is already starting…")); return; } lastInstantAt = now; - showToast("pending", "● CLIP", "Clip wird erstellt..."); + showToast("pending", "● CLIP", svT("Creating clip…")); var body = new FormData(); body.set("csrf_token", csrfToken()); body.set("source", source); @@ -561,24 +562,24 @@ }).catch(function () { return { okHttp: response.ok, - data: { ok: false, status: "error", message: "Ungültige Server-Antwort (" + response.status + ")." } + data: { ok: false, status: "error", message: svT("Invalid server response (%(status)s).").replace("%(status)s", response.status) } }; }); }).then(function (result) { var data = result.data || {}; if (data.status === "error" || data.ok === false) { - showToast("error", "✕ CLIP FEHLGESCHLAGEN", data.message || data.error || "Clip fehlgeschlagen."); + showToast("error", svT("✕ CLIP FAILED"), data.message || data.error || svT("Clip failed.")); return; } var jobId = data.job_id || data.id; if (!jobId) { - showToast("error", "✕ CLIP FEHLGESCHLAGEN", data.message || "Clip fehlgeschlagen."); + showToast("error", svT("✕ CLIP FAILED"), data.message || svT("Clip failed.")); return; } - showToast("pending", "● CLIP", data.message || "Clip wird erstellt..."); + showToast("pending", "● CLIP", data.message || svT("Creating clip…")); pollJob(jobId); }).catch(function () { - showToast("error", "✕ CLIP FEHLGESCHLAGEN", "Clip konnte nicht gestartet werden."); + showToast("error", svT("✕ CLIP FAILED"), svT("Could not start clip.")); }); } @@ -660,7 +661,7 @@ event.preventDefault(); clipSubmitting = true; updateClipUi({}); - setClipStatus("Clip wird erstellt...", "pending"); + setClipStatus(svT("Creating clip…"), "pending"); if (clipResult) { clipResult.hidden = true; clipResult.replaceChildren(); @@ -674,12 +675,12 @@ clipInitialStart = clipStart; clipInitialEnd = clipEnd; clipInitialTitle = ((clipTitleInput && clipTitleInput.value) || "").trim(); - setClipStatus(data.message || "Clip erstellt.", "success"); + setClipStatus(data.message || svT("Clip created."), "success"); if (clipResult) { var link = document.createElement("a"); link.href = data.clip_url || "#"; - link.textContent = "Clip öffnen"; - clipResult.replaceChildren(document.createTextNode("Gespeichert. "), link); + link.textContent = svT("Open clip"); + clipResult.replaceChildren(document.createTextNode(svT("Saved.") + " "), link); clipResult.hidden = false; } } @@ -704,21 +705,21 @@ return; } if (status === "error" || !result.okHttp) { - setClipStatus(data.message || data.error || "Clip konnte nicht erstellt werden.", "error"); + setClipStatus(data.message || data.error || svT("Could not create clip."), "error"); clipSubmitting = false; updateClipUi({}); return; } if (Date.now() - started > POLL_TIMEOUT_MS) { - setClipStatus("Clip dauert zu lange.", "error"); + setClipStatus(svT("Clip is taking too long."), "error"); clipSubmitting = false; updateClipUi({}); return; } - setClipStatus(data.message || "Clip wird erstellt...", "pending"); + setClipStatus(data.message || svT("Creating clip…"), "pending"); pollTimer = window.setTimeout(tick, POLL_MS); }).catch(function () { - setClipStatus("Status konnte nicht geladen werden.", "error"); + setClipStatus(svT("Could not load status."), "error"); clipSubmitting = false; updateClipUi({}); }); @@ -736,14 +737,14 @@ }).then(function (response) { return response.json().then(function (data) { if (!response.ok || data.ok === false || data.status === "error") { - throw new Error(data.message || data.error || "Clip konnte nicht erstellt werden."); + throw new Error(data.message || data.error || svT("Could not create clip.")); } return data; }); }).then(function (data) { var jobId = data.job_id || data.id; if (jobId && data.status && data.status !== "done") { - setClipStatus(data.message || "Clip wird erstellt...", "pending"); + setClipStatus(data.message || svT("Creating clip…"), "pending"); pollOverlayJob(jobId); return; } @@ -751,7 +752,7 @@ clipSubmitting = false; updateClipUi({}); }).catch(function (error) { - setClipStatus((error && error.message) || "Clip konnte nicht erstellt werden.", "error"); + setClipStatus((error && error.message) || svT("Could not create clip."), "error"); clipSubmitting = false; updateClipUi({}); }); diff --git a/WEBUI/static/media_session.js b/WEBUI/static/media_session.js index af2fd61..02c2c19 100644 --- a/WEBUI/static/media_session.js +++ b/WEBUI/static/media_session.js @@ -1,4 +1,5 @@ (function () { + var svT = window.svT || function (s) { return s; }; function text(value, fallback) { const raw = value == null ? "" : String(value).trim(); return raw || fallback || ""; @@ -329,7 +330,7 @@ const artwork = text(data.mediaArtwork || (options && options.artwork), "/app-icon-512.png"); const title = text(data.mediaTitle || (options && options.title), document.title || "StreamVault"); const artist = text(data.mediaArtist || (options && options.artist), "StreamVault"); - const album = text(data.mediaAlbum || (options && options.album), isLive ? "Live" : "Archiv"); + const album = text(data.mediaAlbum || (options && options.album), isLive ? "Live" : svT("Archive")); try { navigator.mediaSession.metadata = new MediaMetadata({ diff --git a/WEBUI/static/mobile_landscape_chat.js b/WEBUI/static/mobile_landscape_chat.js index ceac0f6..cbf580c 100644 --- a/WEBUI/static/mobile_landscape_chat.js +++ b/WEBUI/static/mobile_landscape_chat.js @@ -1,5 +1,6 @@ (function (global) { "use strict"; + var svT = window.svT || function (s) { return s; }; const body = document.body; if (!body || !body.classList.contains("page-mobile-player")) return; @@ -57,8 +58,8 @@ if (fsBtn) { fsBtn.setAttribute("aria-pressed", forced ? "true" : "false"); - fsBtn.setAttribute("aria-label", forced ? "Vollbild beenden" : "Vollbild"); - fsBtn.title = forced ? "Vollbild beenden" : "Vollbild"; + fsBtn.setAttribute("aria-label", forced ? svT("Exit fullscreen") : svT("Fullscreen")); + fsBtn.title = forced ? svT("Exit fullscreen") : svT("Fullscreen"); fsBtn.classList.toggle("is-active", forced); fsBtn.style.display = active ? "none" : ""; } @@ -76,9 +77,9 @@ chatBtn.setAttribute("aria-pressed", chatOpen ? "true" : "false"); chatBtn.setAttribute( "aria-label", - chatOpen ? "Chat einklappen" : "Chat ausklappen" + chatOpen ? svT("Collapse chat") : svT("Expand chat") ); - chatBtn.title = chatOpen ? "Chat einklappen" : "Chat ausklappen"; + chatBtn.title = chatOpen ? svT("Collapse chat") : svT("Expand chat"); } try { localStorage.setItem(KEY, chatOpen ? "1" : "0"); @@ -237,8 +238,8 @@ if (playPauseBtn) { const isPaused = vid.paused; playPauseBtn.classList.toggle("is-paused", isPaused); - playPauseBtn.setAttribute("aria-label", isPaused ? "Wiedergabe fortsetzen" : "Wiedergabe pausieren"); - playPauseBtn.title = isPaused ? "Wiedergabe fortsetzen" : "Wiedergabe pausieren"; + playPauseBtn.setAttribute("aria-label", isPaused ? svT("Resume playback") : svT("Pause playback")); + playPauseBtn.title = isPaused ? svT("Resume playback") : svT("Pause playback"); const playIcon = playPauseBtn.querySelector(".icon-play"); const pauseIcon = playPauseBtn.querySelector(".icon-pause"); const label = playPauseBtn.querySelector(".btn-label"); @@ -247,15 +248,15 @@ pauseIcon.style.display = isPaused ? "none" : "inline-block"; } if (label) { - label.textContent = isPaused ? "Play" : "Pause"; + label.textContent = isPaused ? svT("Play") : svT("Pause"); } } if (muteBtn) { const isMuted = vid.muted || vid.volume === 0; muteBtn.classList.toggle("is-muted", isMuted); - muteBtn.setAttribute("aria-label", isMuted ? "Ton einschalten" : "Stummschalten"); - muteBtn.title = isMuted ? "Ton einschalten" : "Stummschalten"; + muteBtn.setAttribute("aria-label", isMuted ? svT("Unmute") : svT("Mute")); + muteBtn.title = isMuted ? svT("Unmute") : svT("Mute"); const unmuteIcon = muteBtn.querySelector(".icon-unmute"); const muteIcon = muteBtn.querySelector(".icon-mute"); const label = muteBtn.querySelector(".btn-label"); @@ -264,7 +265,7 @@ muteIcon.style.display = isMuted ? "inline-block" : "none"; } if (label) { - label.textContent = isMuted ? "Stumm" : "Ton"; + label.textContent = isMuted ? svT("Muted") : svT("Sound"); } } } diff --git a/WEBUI/static/photo_viewer.js b/WEBUI/static/photo_viewer.js index ed6f31b..86b00ce 100644 --- a/WEBUI/static/photo_viewer.js +++ b/WEBUI/static/photo_viewer.js @@ -1,5 +1,6 @@ (function () { "use strict"; + var svT = window.svT || function (s) { return s; }; var MIN_ZOOM = 1; var MAX_ZOOM = 5; @@ -42,20 +43,20 @@ overlay.className = "ns-photo-viewer"; overlay.setAttribute("role", "dialog"); overlay.setAttribute("aria-modal", "true"); - overlay.setAttribute("aria-label", "Fotoviewer"); + overlay.setAttribute("aria-label", svT("Photo viewer")); overlay.hidden = true; overlay.innerHTML = '
' + '
' + - '" + diff --git a/WEBUI/static/vod_debug.js b/WEBUI/static/vod_debug.js index 3053af7..abd6b87 100644 --- a/WEBUI/static/vod_debug.js +++ b/WEBUI/static/vod_debug.js @@ -1,4 +1,5 @@ (function () { + var svT = window.svT || function (s) { return s; }; var STORAGE_KEY = "streamvault_vod_debug"; var UI_KEY = "streamvault_vod_debug_ui"; var MAX_EVENTS = 500; @@ -228,18 +229,12 @@ var minutes = Math.max(15, Math.ceil((retryAfter || 3600) / 60)); var existing = document.getElementById("vodQuotaToast"); if (existing) { - existing.textContent = "Drive-Quota aktiv — nicht erneut Play drücken (verlängert die Sperre). " - + "Zurückspulen in den schon geladenen Teil, oder ca. " - + minutes - + " Min. warten."; + existing.textContent = svT("Drive quota active — do not press Play again (it extends the block). Seek back into the already loaded part, or wait about %(minutes)s min.").replace("%(minutes)s", minutes); return; } var el = document.createElement("div"); el.id = "vodQuotaToast"; - el.textContent = "Drive-Quota aktiv — nicht erneut Play drücken (verlängert die Sperre). " - + "Zurückspulen in den schon geladenen Teil, oder ca. " - + minutes - + " Min. warten."; + el.textContent = svT("Drive quota active — do not press Play again (it extends the block). Seek back into the already loaded part, or wait about %(minutes)s min.").replace("%(minutes)s", minutes); el.style.cssText = "position:fixed;left:50%;bottom:72px;transform:translateX(-50%);z-index:99998;max-width:min(520px,92vw);padding:10px 14px;border-radius:10px;background:rgba(120,20,20,.94);color:#fff;font:13px/1.4 system-ui,sans-serif;box-shadow:0 10px 30px rgba(0,0,0,.4)"; document.documentElement.appendChild(el); window.setTimeout(function () { diff --git a/WEBUI/static/vod_seek_hover.js b/WEBUI/static/vod_seek_hover.js index d888311..65911fc 100644 --- a/WEBUI/static/vod_seek_hover.js +++ b/WEBUI/static/vod_seek_hover.js @@ -6,6 +6,7 @@ */ (function () { "use strict"; + var svT = window.svT || function (s) { return s; }; function isFiniteNumber(v) { return typeof v === "number" && Number.isFinite(v); @@ -72,11 +73,11 @@ var bar = document.createElement("div"); bar.className = "vod-controlbar"; bar.setAttribute("role", "toolbar"); - bar.setAttribute("aria-label", "Videoplayer-Steuerung"); + bar.setAttribute("aria-label", svT("Video player controls")); bar.innerHTML = [ /* Play/Pause */ - '', @@ -84,7 +85,7 @@ '0:00 / --:--', /* Seekbar (flex-grows to fill available space) */ - '
', + '
', '
', '
', '
', @@ -94,13 +95,13 @@ '
', /* Volume */ - '', - '', + '', /* Fullscreen */ - '', ].join(""); @@ -205,7 +206,7 @@ function updatePlayBtn() { playBtn.innerHTML = video.paused ? ICON_PLAY : ICON_PAUSE; - playBtn.setAttribute("aria-label", video.paused ? "Wiedergabe" : "Pause"); + playBtn.setAttribute("aria-label", video.paused ? svT("Start playback") : svT("Pause")); if (video.paused) { showControls(); clearTimeout(hideTimer); @@ -240,7 +241,7 @@ window.StreamVaultMobileLandscape.toggle(); var forced = window.StreamVaultMobileLandscape.isForced(); fsBtn.innerHTML = forced ? ICON_EXIT_FULL : ICON_FULL; - fsBtn.setAttribute("aria-label", forced ? "Vollbild beenden" : "Vollbild"); + fsBtn.setAttribute("aria-label", forced ? svT("Exit fullscreen") : svT("Fullscreen")); return; } if (!document.fullscreenElement) { @@ -258,17 +259,17 @@ && window.StreamVaultMobileLandscape) { var forced = window.StreamVaultMobileLandscape.isForced(); fsBtn.innerHTML = forced ? ICON_EXIT_FULL : ICON_FULL; - fsBtn.setAttribute("aria-label", forced ? "Vollbild beenden" : "Vollbild"); + fsBtn.setAttribute("aria-label", forced ? svT("Exit fullscreen") : svT("Fullscreen")); return; } fsBtn.innerHTML = document.fullscreenElement ? ICON_EXIT_FULL : ICON_FULL; - fsBtn.setAttribute("aria-label", document.fullscreenElement ? "Vollbild beenden" : "Vollbild"); + fsBtn.setAttribute("aria-label", document.fullscreenElement ? svT("Exit fullscreen") : svT("Fullscreen")); }); function syncMobileFsIcon() { if (!window.StreamVaultMobileLandscape) return; var forced = window.StreamVaultMobileLandscape.isForced(); fsBtn.innerHTML = forced ? ICON_EXIT_FULL : ICON_FULL; - fsBtn.setAttribute("aria-label", forced ? "Vollbild beenden" : "Vollbild"); + fsBtn.setAttribute("aria-label", forced ? svT("Exit fullscreen") : svT("Fullscreen")); } if (document.body) { document.body.addEventListener("streamvault:landscape-chat", syncMobileFsIcon); diff --git a/WEBUI/templates/admin.html b/WEBUI/templates/admin.html index fde3c5b..f3c8753 100644 --- a/WEBUI/templates/admin.html +++ b/WEBUI/templates/admin.html @@ -1,9 +1,9 @@ - + - Optionen & Systemsteuerung — StreamVault + {{ _('Options & system control') }} — StreamVault @@ -246,19 +246,19 @@
-

Optionen & Systemsteuerung

-
- Load {{ sys_load[0] }} - Frei {{ storage.free_str }} - Queue {{ task_queue.total_active }} +

{{ _('Options & system control') }}

+
+ {{ _('Load') }} {{ sys_load[0] }} + {{ _('Free') }} {{ storage.free_str }} + {{ _('Queue') }} {{ task_queue.total_active }}
-
@@ -266,58 +266,58 @@

Optionen & Systemsteuerung

{% if error %}
{{ error }}
{% endif %} {% if storage_alert and storage_alert.critical %}
- Speicher kritisch: {{ storage_alert.used_pct }}% belegt, nur noch {{ storage_alert.free_str }} frei. - {% if storage_alert.webhook_configured %}Webhook aktiv.{% else %}Webhook nicht konfiguriert.{% endif %} + {{ _('Storage critical: %(pct)s%% used, only %(free)s free.', pct=storage_alert.used_pct, free=storage_alert.free_str) }} + {% if storage_alert.webhook_configured %}{{ _('Webhook active.') }}{% else %}{{ _('Webhook not configured.') }}{% endif %}
{% endif %} -
- - - - +
+ + + +
-

System Status

-
Load Average (1m / 5m): {{ sys_load[0] }} / {{ sys_load[1] }}
-
Speicher (Total): {{ storage.total_str }}
-
Speicher (Frei): {{ storage.free_str }}
+

{{ _('System status') }}

+
{{ _('Load average (1m / 5m):') }} {{ sys_load[0] }} / {{ sys_load[1] }}
+
{{ _('Storage (total):') }} {{ storage.total_str }}
+
{{ _('Storage (free):') }} {{ storage.free_str }}
- +
-

Aufgaben-Queue

- {{ task_queue.total_active }} aktiv +

{{ _('Task queue') }}

+ {{ _('%(count)s active', count=task_queue.total_active) }}
{% if task_queue.error %}
{{ task_queue.error }}
{% else %}
- Wartend {{ task_queue.counts.pending or 0 }} - Läuft {{ task_queue.counts.running or 0 }} - Fertig {{ task_queue.counts.completed or 0 }} - Fehler {{ task_queue.counts.failed or 0 }} + {{ _('Pending') }} {{ task_queue.counts.pending or 0 }} + {{ _('Running') }} {{ task_queue.counts.running or 0 }} + {{ _('Done') }} {{ task_queue.counts.completed or 0 }} + {{ _('Failed') }} {{ task_queue.counts.failed or 0 }}
{% for task in task_queue.tasks[:8] %}
{{ task.status }} {{ task.type }} - {{ task.label or '-' }}{% if task.error %}Fehler: {{ task.error }}{% elif task.attempts > 1 %}{{ task.attempts }} Versuche{% endif %} + {{ task.label or '-' }}{% if task.error %}{{ _('Error: %(error)s', error=task.error) }}{% elif task.attempts > 1 %}{{ ngettext('%(num)d attempt', '%(num)d attempts', task.attempts) }}{% endif %} {{ task.age }}
{% else %} -
Keine Jobs in der Queue.
+
{{ _('No jobs in the queue.') }}
{% endfor %}
{% endif %} @@ -325,28 +325,28 @@

Aufgaben-Queue

-

Recording Health

- {{ recording_health|length }} Kanäle +

{{ _('Recording health') }}

+ {{ ngettext('%(num)d channel', '%(num)d channels', recording_health|length) }}
{% if recent_task_errors %}
-

Letzte Task-Fehler

+

{{ _('Recent task errors') }}

{{ recent_task_errors|length }}
@@ -367,23 +367,23 @@

Letzte Task-Fehler

-

Wartungsmodus & Bereichssteuerung

+

{{ _('Maintenance mode & area control') }}

- Steuere den globalen Wartungsmodus oder sperre gezielt einzelne Plattformbereiche. + {{ _('Control the global maintenance mode or lock individual platform areas.') }}

- Globaler Wartungsmodus: + {{ _('Global maintenance mode:') }} {% if maintenance.enabled %} - AKTIV (Gesperrt) + {{ _('ACTIVE (locked)') }} {% else %} - AUS (Normalbetrieb) + {{ _('OFF (normal operation)') }} {% endif %}
{% if maintenance.updated_label %}
- Zuletzt geändert: + {{ _('Last changed:') }} {{ maintenance.updated_label }}{% if maintenance.updated_by %} ({{ maintenance.updated_by }}){% endif %}
{% endif %} @@ -393,11 +393,11 @@

Wartungsmodus & Bereichssteuerung

{% else %} @@ -405,11 +405,11 @@

Wartungsmodus & Bereichssteuerung

{% endif %} @@ -420,56 +420,56 @@

Wartungsmodus & Bereichssteuerung

- - + +
-

Clip-Einstellungen

-

Instant-Clip Parameter und Hotkey im Web-Player.

+

{{ _('Clip settings') }}

+

{{ _('Instant clip parameters and hotkey in the web player.') }}

- +
- +
- +
- +
- +
@@ -479,22 +479,22 @@

Clip-Einstellungen

- Rollenmodell: Vollzugriff (admin), Archiv-Viewer (viewer), Nur-Live (live_only) & Discord-Member. + {{ _('Role model: full access (admin), archive viewer (viewer), live only (live_only) & Discord member.') }} - Benutzer & Gruppen - Traffic-Übersicht + {{ _('Users & groups') }} + {{ _('Traffic overview') }}