Skip to content

fix: match ISO 639-2 bibliographic and terminological language codes - #2041

Open
chrisbenincasa wants to merge 5 commits into
mainfrom
fix/iso-639-language-code-matching
Open

fix: match ISO 639-2 bibliographic and terminological language codes#2041
chrisbenincasa wants to merge 5 commits into
mainfrom
fix/iso-639-language-code-matching

Conversation

@chrisbenincasa

Copy link
Copy Markdown
Owner

Fixes #1960

Problem

ISO 639-2 assigns 20 languages two three-letter codes: a bibliographic (/B) code derived from the English name, and a terminological (/T) code derived from the native name. German is both ger and deu; French both fre and fra. Media servers and containers use them interchangeably.

A German audio preference never matched a Jellyfin track. From the reporter's troubleshooting log — preference [ger, eng], track tagged deu, English selected:

"rules": [{ "audioAction": "Select audio by language: [ger, eng]" }],
"selectedAudioStream": { "index": 2, "language": "eng" },
"subtitleReason": "No subtitle found for languages: ger"

English is unaffected (it has only one code), which is why this went unnoticed.

Root cause

Four defects, one of which is the origin of the other three:

  1. LanguageService.getAlpha3TCode was broken for every /B code. Its /B branch called alpha3TToAlpha2 instead of alpha3BToAlpha2. alpha3TToAlpha2('ger') returns undefined, so the function returned undefined for all 20 bibliographic codes. This silently broke CelEvaluationService.matchLanguageInList, SubtitleStreamPicker, and LocalSubtitlesService — the last of which dropped the language of any sidecar subtitle named with a /B code (Movie.ger.srt), logging it as an unknown language.

  2. StreamSelectionEvaluator never normalized at all. Both the audio and subtitle by_language paths compared codes with raw lowercased string equality, so 'deu' !== 'ger' fell through to the next preference.

  3. SubtitleStreamPicker normalized only one side. It ran the stream's code through getAlpha3TCode but compared the result to a raw, unnormalized preference.

  4. The two web language pickers disagreed. useLanguagePreferences (Settings → FFmpeg → Audio Language Preferences) emitted /B via alpha2ToAlpha3B, while LanguageAutocomplete (channel subtitle preferences) emitted /T via alpha2ToAlpha3T. The server normalizes all probed stream tags to /T, so the FFmpeg settings screen was writing codes that could never match.

Also fixed (not in the original report)

languageBy3LetterCode was keyed on /B codes but ChannelSubtitlePreferencesTable looks up values written by LanguageAutocomplete, which emits /T. The Language column rendered blank for all 20 affected languages.

Changes

  • Fix the alpha3BToAlpha2 call in LanguageService.getAlpha3TCode.
  • Add LanguageService.codesMatch, which normalizes both sides to /T and falls back to a case-insensitive exact comparison when either side can't be resolved — keeping private-use codes such as qaa working.
  • Route all stream language matching in StreamSelectionEvaluator and SubtitleStreamPicker through codesMatch.
  • Resolve a stream's language by tag precedence (ISO 639-2 > ISO 639-1 > free-form) rather than matching against any of the three fields. This is the precedence buildCelContext already uses in the same file, so the module is now self-consistent.
  • Both web pickers emit /T; the display map indexes both code sets.
  • Rename a misleading maybeLang3B local in LocalSubtitlesService — it holds a /T code.

No migration needed. Preferences already stored as /B keep matching through codesMatch, and still resolve a display name.

Note for reviewers

Two pre-existing tests began failing once matching actually worked. Their fixtures build a "French" stream that also carries languageCodeISO6391: 'en' and language: 'English' — an artifact of makeAudioStream's English defaults. The old code only avoided matching them by accident ('english' !== 'eng'). I made the fixtures internally consistent and added a test pinning the tag-precedence rule so the choice is tested rather than incidental.

Test plan

  • New LanguageService.test.ts covers all 20 /B/T pairs in both directions, plus codesMatch — 74 tests
  • New B/T matching suite in StreamSelectionEvaluator.test.ts for audio and subtitles, including preference ordering, preferChannels, tag precedence, and the unresolvable-code fallback
  • New language-matching suite in SubtitleStreamPicker.test.ts
  • B/T regression test added to CelEvaluationService.test.ts for hasAudioLang
  • New web/src/helpers/language.test.ts pins that both pickers emit /T and that legacy /B values still resolve a display name
  • Verified the new tests fail against the old code — reintroducing the LanguageService bug produces 12 failures across the three server suites
  • pnpm turbo typecheck clean
  • pnpm lint-changed clean
  • pnpm turbo test — 1319 server tests, 87 web tests, all passing

🤖 Generated with Claude Code

@chrisbenincasa

Copy link
Copy Markdown
Owner Author

Added 2bc8be9c: the switch to terminological codes exposed a mismatch in LanguagePreferencesList — the options list deduped on iso6392 while isOptionEqualToValue compared iso6391, so a preference saved earlier as ger no longer suppressed the deu option and German could be added twice. Both now key on the ISO 639-1 code, which has no B/T split. Covered by a new component test.

A broader sweep for the same bug class turned up several more sites (troubleshoot subtitle matching, Meilisearch language filtering, and unnormalized writes at the Plex/Jellyfin/Emby boundary). Those are being handled separately to keep this PR focused.

chrisbenincasa and others added 4 commits September 7, 2026 13:24
ISO 639-2 assigns 20 languages two three-letter codes: a bibliographic
(/B) code derived from the English name and a terminological (/T) code
derived from the native name. German is both "ger" and "deu". Media
servers and containers use them interchangeably.

A German audio preference never matched a Jellyfin track, because:

- LanguageService.getAlpha3TCode called alpha3TToAlpha2 in its /B branch,
  so it returned undefined for every /B code. This broke normalization in
  CelEvaluationService.matchLanguageInList, SubtitleStreamPicker and
  LocalSubtitlesService, and dropped the language of any sidecar or
  container track tagged with a /B code.
- StreamSelectionEvaluator compared codes with raw string equality and
  never normalized either side.
- SubtitleStreamPicker normalized the stream's code but compared it to a
  raw, unnormalized preference.
- The FFmpeg settings language picker emitted /B codes while the channel
  subtitle picker emitted /T codes, so the two screens disagreed and the
  subtitle table could not resolve a display name for either.

Adds LanguageService.codesMatch, which normalizes both sides to /T and
falls back to a case-insensitive exact comparison for codes it cannot
resolve, and routes all stream language matching through it. Stream
language is now resolved by tag precedence (ISO 639-2 > ISO 639-1 >
free-form), matching what buildCelContext already does.

Both web pickers now emit /T. Preferences already stored as /B keep
working through codesMatch and still resolve a display name, so no
migration is needed.

Fixes #1960

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The options list suppressed already-selected languages by comparing
ISO 639-2 codes while isOptionEqualToValue compared ISO 639-1 codes.
Now that the picker emits terminological codes, a preference saved
earlier as "ger" no longer matched the "deu" option, so German was
offered again in the dropdown and could be selected twice.

Both checks now key on the ISO 639-1 code, which has no bibliographic
and terminological split.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The troubleshoot simulator resolved audio through the stream selector but
re-implemented subtitle language matching inline with raw string
comparison. After the selector learned to treat the two ISO 639-2 code
sets as equivalent, the two disagreed: the report would claim "No
subtitle found for languages: ger" for a file that playback selects a
German subtitle for.

This matters more than a normal display bug, because the troubleshoot
report is what users paste into issues — #1960 was diagnosed from one.

Extracts the lookup into findSubtitleForLanguages and routes it through
the selector's streamMatchesLanguage, which is now exported.

Also adds the missing iso6392 to the FFmpeg settings language-preference
fallback; LanguagePreferenceSchema requires it, so the fallback produced
a value that would fail validation.

Refs #1960, #2026

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@chrisbenincasa
chrisbenincasa force-pushed the fix/iso-639-language-code-matching branch from d1d9e14 to 84c337e Compare September 7, 2026 17:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Reported audio language "deu" is not considered German

1 participant