Skip to content
Donate

Translations

JellyRock has its own translation system in place of Roku’s tr() and XML locale files. Each language is a flat JSON file in locale/custom/, loaded into m.global as an associative array, so looking up a key is a single read.

' A plain string
translate(translationKeys.ButtonPlay)
' With placeholders
translate(translationKeys.MessageCouldNotReachServer, [serverUrl])
' Plurals (zero, one, many)
translatePlural(translationKeys.LabelEpisodeCount, count, [stri(count).trim()])
  1. Add the key to locale/custom/en_US.json, in alphabetical order:

    "MyNewKey": "My new string with {0} placeholder"
  2. Rebuild. A BrighterScript compiler plugin generates translationKeys.MyNewKey from en_US.json, so the constant appears in your editor’s autocomplete with no other step.

  3. Use it in code:

    translate(translationKeys.MyNewKey, ["value"])
  4. Run npm run lint:translations to check the key and its use.

Keys are PascalCase and start with a prefix that says what kind of text they hold:

PrefixForExample
ButtonButton labelsButtonPlay, ButtonCancel
LabelUI labels and headingsLabelCinemaMode, LabelEpisodeCountOne
MessageLonger textMessageRememberMe
ErrorError messagesErrorFailedToUpdateFavorite
SettingSetting titles and descriptionsSettingDescDisplayLanguage
DescDescriptions of the custom theme color settingsDescThemeColorPrimary
DayDay namesDayMonday
LanguageNames of audio and subtitle languagesLanguageAf

A plural string has three keys in en_US.json, ending in Zero, One and Many:

"LabelEpisodeCountZero": "No episodes",
"LabelEpisodeCountOne": "1 Episode",
"LabelEpisodeCountMany": "{0} Episodes"

Call translatePlural() with the base key, without the ending:

translatePlural(translationKeys.LabelEpisodeCount, item.childCount, [stri(item.childCount).trim()])

translatePlural() picks Zero, One or Many from the count, then calls translate() to look the key up and fill in the placeholders. The compiler plugin generates the base key constant (LabelEpisodeCount) from the three variants.

Write {0}, {1} and so on in the translated text, and pass the values as an array of strings:

' en_US.json: "MessageCouldNotReachServer": "Couldn't reach '{0}'."
translate(translationKeys.MessageCouldNotReachServer, [serverUrl])

Files in source/ share one scope, so translate() and translationKeys work there without imports.

Files in components/ need both imports:

import "pkg:/source/utils/translate.bs"
import "pkg:/source/translationKeys.bs"

translationKeys.bs is generated by the compiler plugin. It isn’t on disk, but the compiler resolves it.

Every entry in settings/settings.json has titleKey and descriptionKey fields, which name keys in en_US.json. The settings screen shows translate(item.titleKey) and translate(item.descriptionKey).

Give a new setting both fields:

{
"title": "Cinema Mode",
"description": "Bring the theater experience straight to your living room with the ability to play custom intros before the main feature.",
"settingName": "playbackCinemaMode",
"type": "bool",
"default": "false",
"titleKey": "LabelCinemaMode",
"descriptionKey": "MessageBringTheTheaterExperienceStraightTo"
}

The title and description fields keep the English text beside the keys. See adding a user setting for the whole process.

Locale files are named the way Weblate expects:

KindNameExample
Base languageLowercase codefr.json, de.json
Regional variantUnderscore and uppercase regionfr_CA.json, es_MX.json
ChineseUnderscore and script codezh_Hans.json, zh_Hant.json, zh_Hant_HK.json
Numeric regionUnderscore and numberes_419.json

translate() looks a key up in three places, in order:

  1. The active locale, such as fr_CA.json.
  2. English, en_US.json.
  3. The key itself, so a missing translation is easy to spot.

A regional locale such as fr_CA is layered over its base language (fr), so users see as many translated strings as possible. zh_Hant_HK is layered over zh_Hant the same way.

The app picks the locale at two points:

  • Before sign-in: the Sign-in Screen Language setting, else the Roku’s language, else en_US.
  • After sign-in: the user’s setting, else the Jellyfin server’s language, else the Roku’s, else en_US.

normalizeLocaleCode() converts the server’s language codes to the file names above: zh-CN becomes zh_Hans, and pt-BR becomes pt_BR.

A language change takes effect when the user leaves Settings, which rebuilds the home screen.

Two scripts check translations. Both are part of npm run lint, and CI runs them:

CommandWhat it checks
lint:translationsen_US.json is sorted and has no unused keys. Every translationKeys.* reference in the code exists, and no code passes a key as a string (translate("Key")). Every locale file is valid JSON with matching placeholders and complete plurals. Coverage, and that languages.json matches the locale files.
lint:language-coverageThe tables that map codes to translation keys stay complete: the media-language resolver in languages.bs, the person-label table in people.bs, and creditRowKinds() in the same file. Every PersonKind value in the committed spec fingerprints must have a label or be listed as blank on purpose. Every kind a credit line names must still be one some supported server sends, and its messageKey must exist.

If lint:language-coverage fails after a Jellyfin upgrade refreshed the fingerprints, read which check failed, because each has a different fix:

  • A PersonKind value has no row: a new one appeared upstream. Add a LabelPersonKind… key and a row to personKindTranslationKeys(), or a row to unlabeledPersonKinds() if it should show nothing.
  • A creditRowKinds() kind is not a PersonKind value: one was renamed or removed upstream, so that credit line would show nothing. Change the kind in creditRowKinds() to the new spelling, or remove the line if the kind is gone. Never delete the check to silence it: an empty credit line is the bug, and nothing shows it at runtime.
  • The credit-row table could not be read: someone rewrote the table in a form the check’s parser doesn’t know. Put back the { kind: "x", messageKey: translationKeys.Y } shape, or teach parseAAArray() the new one. The parser fails rather than skipping, because a table it can’t read is a check that silently stops working. For why this is a lint and not a compile error, see translations.md.

The JellyRock bot (jellyrock-bot.yml) runs on every push to main. It:

  1. Removes unused keys from en_US.json.
  2. Sorts en_US.json.
  3. Adds new locale files to languages.json, so they appear in the language picker.
  4. Pushes en_US.json and languages.json to the weblate branch for the community translators.

To do steps 1 to 3 yourself, run npm run update-translations.

A key that doesn’t exist fails the build, because translationKeys is generated from en_US.json and the compiler rejects an unknown constant.

Translations move back from the weblate branch to main only at release prep (release-management.yml). The merge works key by key, in three ways: whichever side changed a key since the last release wins, deletions included, and Weblate wins when both did. Release prep then seeds missing translations from other Jellyfin clients (below) and pushes the result back to weblate. For why and how, see Weblate sync.

Seeding translations from other Jellyfin clients

Section titled “Seeding translations from other Jellyfin clients”

A new key starts untranslated in every language, but another Jellyfin client’s community has often translated the same string already. npm run translations:seed copies those translations in. It only fills empty cells, only from a reviewed map, and only from projects whose license allows it. Release prep runs it, so mapping a key is enough for it to fill at the next release. Run it yourself to see the result in your pull request.

Terminal window
npm run translations:seed # dry run: per-key/per-locale coverage, refusals, casing flags
npm run translations:seed -- suggest --key LabelX # candidate source keys for a key, as YAML stubs
npm run translations:seed -- --write # apply to locale/custom/ and record each fill in locale/seed/seeded.json

The first run downloads each pinned source (a shallow, partial clone) into the gitignored .cache/translation-seed/. Later runs work offline.

  1. suggest --key <Key> lists source keys with the same English text. Treat it as a starting point, not an answer.
  2. Check each candidate’s meaning and shape in context. Meaning is what the source key’s name says it is for: web’s MediaInfo* keys label its media-info panel, and PriorityIdle is a process priority. Shape is whether it is the same kind of string: a bare label or Codec: %1$s, a standalone label or a word from inside a sentence. Pick the key whose context matches yours.
  3. Add the entry to locale/seed/keymap.yml. Copy both English strings exactly: if either side changes later, the entry stops seeding. List candidates you turned down under reject: with a reason, so suggest never offers them again.
  4. Do a dry run and read the values, at least in languages you can check. A source’s translation can be wrong even for its own key: web’s MediaInfoChannels reads “Chaînes” (TV channels) in French. Drop a bad cell with exclude: { <locale>: <reason> }. If the value was already written, also delete it from the locale file. The seed ledger (locale/seed/seeded.json) then keeps it from coming back, and the next release’s merge removes it from Weblate too.
  5. Review the Casing to review list. Nothing is re-cased for you. Exclude a cell when a better-cased value comes from the base language; otherwise keep it, since a lowercase word in the user’s language beats English.
  • To move a source forward, change ref and commit together in locale/seed/sources.yml, then do a dry run. Entries whose source English changed are listed under Needs re-review.
  • To add a project, its license must be GPL-2.0-only, GPL-2.0-or-later or MPL-2.0. The seeder refuses anything else, and checks the LICENSE file at the pinned commit. A new file format needs a parser in scripts/lib/translation-formats.cjs, with a fixture test.