Audio- and subtitle-language browse filters, a Jellyfin 12.0 server capability.
audioLanguages / subtitleLanguages are the only GET /Items parameters added between the 10.11.8 and 12.0 spec fingerprints (jellyfin#9787). Measured on the local 10.11.11 / 12.0.0 / 12.1.0 servers, 2026-09-22:
On 12.x they filter. audioLanguages=zzz returns 0 of 17 films; =eng returns all 17. On 10.11 they are silently ignored: audioLanguages=zzz returns all 17, in either casing. That is why the filters are version-gated rather than offered everywhere and allowed to no-op: a 10.11 user picking "Japanese audio" would get their whole library back, which reads as a broken filter. Within one parameter the values are OR'd (hin,pol returns exactly the union, 2 films); across the two parameters they are AND'd (Spanish audio 3, Japanese subtitles 9, both together 2). The panel can only have one filter category active at a time, so only the OR half is reachable today: see itemgridoptions-single-active-filter in tech-debt.md. Comma is the delimiter on the wire, and a pipe returns 0 there. It is not what the categories declare: an option's value is itself several comma-joined codes, so the panel separates options with PANEL_DELIMITER and toWire() flattens the two apart when the request is built. See PANEL_DELIMITER for why. Values are case-sensitive 3-letter ISO 639-2 codes. ENG and en both return 0. So the option list must send the server's own Value verbatim, which is why nothing here upper- or lower-cases a code.
The alias trap, and why this module exists at all. The server does not canonicalize ISO 639-2/T against /B: it matches the literal tag on the file. On the same library, subtitleLanguages=fra returns 1 film and fre returns 10, and /Items/Filters2 duly advertises both as separate options: "French (fra)" and "French (fre)". Offering those two entries as the server sends them would be a filter where picking "French" gives one of two partial answers and which one is arbitrary. Grouping them and sending fra,fre returns all 11, which is what the user meant.
The grouping needs no new table: languageBaseCode() already folds the alias pairs (it is the same fold the audio/subtitle track pickers use), and resolveLanguageName() already localizes a code through the app's own language keys. Checked 2026-09-22 against the 52 codes the local fixture reports: every one resolves, and the 8 alias pairs present (fra/fre, deu/ger, ces/cze, nld/dut, zho/chi, eus/baq, ell/gre, ron/rum) merge cleanly.
Using the app's names rather than the server's Name is also what makes the list readable: the server's strings are English-only library-science labels ("Spanish; Castilian (spa)", "Greek, Modern (1453-) (ell)"), and the app already holds localized names for the same codes.
Members
(static, constant) AUDIO_KEY
The key each category is stored and sent under. Shared by the presenter that declares the category, the Filters2 merge that fills it, and toWire(), so the three cannot drift apart on spelling.
- Default Value
- AudioLanguages
(static, constant) CODE_DELIMITER
Separates the codes WITHIN one language's value, and is what the server requires between values. Comma is not a preference: a pipe-separated list returns 0 results.
- Default Value
- ,
(static, constant) MIN_SERVER
The first server line that honours the parameters at all. Below this they are accepted and ignored, so the gate is on the SERVER VERSION and not on whether the request succeeds.
- Default Value
- 12.0.0
(static, constant) PANEL_DELIMITER
Separates the selected options from EACH OTHER, in the panel and in the registry.
It cannot also be CODE_DELIMITER, and that is the whole reason this constant exists. One language's value is already several codes joined by a comma ("fra,fre"), so a comma between options makes the saved query ambiguous: the restore pass splits "fra,fre" into "fra" and "fre", matches neither against the option, and leaves the box unticked while the filter is still applied, and one confirming keypress on that panel then clears the filter outright. Measured on a Roku Stick 2026-09-22: the round-trip test fails on exactly the 8 alias-merged languages and passes on the rest, which is what makes the bug look like flakiness rather than breakage.
"|" is what Genres and OfficialRatings already use for the same reason (their values are free text that can contain a comma), so this follows the panel's existing rule rather than inventing one: an option's value never contains its category's delimiter. toWire() flattens the two apart on the way to the request.
- Default Value
- |
(static, constant) SUBTITLE_KEY
- Default Value
- SubtitleLanguages
Methods
(static) isLanguageKey(key) → {boolean}
isLanguageKey: whether a filterOptions key is one of the two language categories.
Case-insensitive because the key round-trips through the registry as JSON and through a SceneGraph assocarray field, neither of which promises to preserve the spelling a presenter declared.
| Name | Type | Description |
|---|---|---|
key | dynamic |
- Type:
- boolean
(static) optionsFrom(namePairs) → {object}
optionsFrom: Turn one /Items/Filters2 language list into filter options, merging the ISO 639-2/T and /B spellings of a language into a single entry.
Filters2 response: an array of { Name, Value }. Anything else (invalid, empty, not an array) is answered with an empty array, which the caller reads as "do not offer this category". value is every code that resolved to the same language, comma-joined
| Name | Type | Description |
|---|---|---|
namePairs | dynamic |
|
array of { title, value }, sorted by localized title, where
- Type:
- object
(static) supported(serverVersion) → {boolean}
supported: Whether this server honours the language filter parameters.
an unknown server is assumed not to filter, so the categories are hidden rather than offered and silently ignored)
| Name | Type | Description |
|---|---|---|
serverVersion | string | m.global.server.version |
false for an empty/unknown version (the conservative answer:
- Type:
- boolean
(static) toWire(filterOptions) → {object}
toWire: the request form of a saved filterOptions AA.
The panel and the registry separate selected options with PANEL_DELIMITER; the server wants one flat comma list. Flattening that one level is the entire conversion ("fra,fre|deu,ger" becomes "fra,fre,deu,ger"), and it is what lets the saved form stay unambiguous while the sent form stays what the server accepts.
Only the two language keys are touched. Every other category already stores exactly what it sends, so its value is copied through untouched.
Called from gridQuery.build(), which is the single place a saved filterOptions becomes a request (LoadItemsTask2 appends whatever keys it is handed). Keeping the conversion there is what keeps the panel, the registry and the wire in agreement.
| Name | Type | Description |
|---|---|---|
filterOptions | dynamic | the saved AA, as BaseGridView holds it |
a NEW AA; the caller's is never mutated
- Type:
- object