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¶
web/app.pyinjects 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)
}
base.htmlwrites it to a global before any module loads:
- 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
}
- 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()):
ytm-localecookie (set by the language dropdown in Display settings)- Browser
Accept-Languageheader - 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¶
- Extract the latest strings:
- Initialize the new locale (e.g. French):
This creates web/translations/fr/LC_MESSAGES/messages.po.
-
Translate - fill in each
msgstrin the.pofile. -
Compile:
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¶
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:
Compiled .mo files are copied into the production image. The .po source files are also included (needed for auto-detection).