Skip to content

Internationalization (i18n)

The web dashboard uses Flask-Babel for translations. English is the default (and currently only) locale. Strings are wrapped with _() in three layers: Jinja2 templates, Python route handlers, and JavaScript modules.

Architecture

Layer Marker Extraction Runtime
Jinja2 templates {{ _('…') }} Babel built-in jinja2 extractor Flask-Babel resolves at render time
Python routes _('…') / gettext('…') Babel built-in python extractor Flask-Babel resolves at call time
JavaScript modules _('…') Custom extractor (web/i18n/extractor.py) window.__i18n__ dict injected by server

How JS Translations Work

  1. web/app.py injects the active catalog into every page via a context processor:
catalog = get_translations()
js_translations = {
    k: v for k, v in catalog._catalog.items()
    if k and v and isinstance(k, str)
}
  1. base.html writes it to a global before any module loads:
<script nonce="{{ csp_nonce }}">
  window.__i18n__ = {{ js_translations | tojson }};
</script>
  1. JS modules import a thin helper (web/static/js/modules/i18n.js):
const _translations = window.__i18n__ || {}

export function _(msg, params) {
  let translated = _translations[msg] || msg
  if (params) {
    for (const [key, value] of Object.entries(params))
      translated = translated.replaceAll(`%(${key})s`, value)
  }
  return translated
}
  1. Callers use it like gettext, with optional named interpolation:
import { _ } from "./i18n.js"

showToast(_("Settings saved successfully!"), "success")
setText(_("Connected! Last liked song: %(song)s", { song: name }))

The %(key)s syntax mirrors Python's % formatting so translators see the same placeholders everywhere.

Locale Selection

Locale is resolved in this order (web/app.py::get_locale()):

  1. ytm-locale cookie (set by the language dropdown in Display settings)
  2. Browser Accept-Language header
  3. Fallback: "en"

Supported locales are auto-detected at startup by scanning web/translations/*/LC_MESSAGES/messages.po. No hardcoded list - drop in a new locale directory and it appears in the UI automatically.

The language dropdown in Display settings (_panel_settings.html) renders each locale with its native display name via Locale.get_display_name().

Configuration Files

File Purpose
babel.cfg Extraction mapping - which files to scan, which extractor to use
web/i18n/extractor.py Custom Babel extractor for _() calls in JS files
pyproject.toml Registers the extractor module (py-modules) and Babel entry point
web/translations/messages.pot Extracted string template (regenerated, not hand-edited)
web/translations/<locale>/LC_MESSAGES/messages.po Per-locale translations (hand-edited)
web/translations/<locale>/LC_MESSAGES/messages.mo Compiled binary catalog (gitignored)

babel.cfg

[extractors]
js = web.i18n.extractor:extract_js

[python: web/**.py]

[jinja2: web/templates/**.html]
encoding = utf-8
silent = false

[js: web/static/js/**.js]
encoding = utf-8

The [extractors] section maps the alias js to the custom extractor function. This is necessary because Babel's config parser splits section headers on the first : - without the alias, web.i18n.extractor:extract_js would be split incorrectly.

Custom JS Extractor

web/i18n/extractor.py uses a regex to find _("…") and _('…') calls in JavaScript source. It handles escaped quotes and yields standard Babel (lineno, funcname, message, comments) tuples. It does not parse template literals or multi-line strings - all JS translation strings must be single-line string literals.

Important: This constraint also applies when writing translatable strings in JS modules. Don't use template literals, concatenation, or multi-line strings inside _() - the extractor won't find them, and they'll be silently missing from the catalog.

Quick Reference

# Full extract -> update -> compile cycle
pybabel extract -F babel.cfg -o web/translations/messages.pot .
pybabel update  -i web/translations/messages.pot -d web/translations
pybabel compile -d web/translations

Adding a New Language

  1. Extract the latest strings:
pybabel extract -F babel.cfg -o web/translations/messages.pot .
  1. Initialize the new locale (e.g. French):
pybabel init -i web/translations/messages.pot -d web/translations -l fr

This creates web/translations/fr/LC_MESSAGES/messages.po.

  1. Translate - fill in each msgstr in the .po file.

  2. Compile:

pybabel compile -d web/translations

That's it. The new locale is auto-detected on next startup and appears in the language dropdown. No code changes needed.

Updating Translations After Code Changes

When you add or change translatable strings, update (not re-initialize) existing catalogs:

pybabel extract -F babel.cfg -o web/translations/messages.pot .
pybabel update  -i web/translations/messages.pot -d web/translations
pybabel compile -d web/translations

pybabel update merges new/changed strings into existing .po files, preserving already-translated entries. Removed strings are commented out (marked #~) so nothing is lost.

Marking New Strings

In Jinja2 Templates

<h2>{{ _('Dashboard') }}</h2>
<span data-tooltip="{{ _('Open %(name)s on YouTube Music', name=playlist_name) }}">

In Python Route Handlers

from flask_babel import gettext as _

flash(_("Settings saved successfully."), "success")

In JavaScript Modules

import { _ } from "./i18n.js"

showToast(_("Connection failed"), "error")
setText(_("%(count)sm ago", { count: diffMin }))

Constraints: The custom extractor only sees _("…") and _('…') on a single line. Don't use template literals, concatenation, or multi-line strings inside _().

Docker

The Dockerfile compiles translations in the builder stage:

RUN pybabel compile -d web/translations

Compiled .mo files are copied into the production image. The .po source files are also included (needed for auto-detection).

Directory Structure

web/translations/
├── messages.pot                     # String template (regenerated)
└── en/
    └── LC_MESSAGES/
        ├── messages.po              # English catalog (source strings)
        └── messages.mo              # Compiled binary (gitignored)
babel.cfg                            # Extraction config
web/i18n/extractor.py                # Custom JS extractor