From 1dba9598523659812257b1b7ad3a1aafe5dce26e Mon Sep 17 00:00:00 2001 From: Mahmut YUM Date: Thu, 1 Oct 2026 07:24:20 +0300 Subject: [PATCH] fix(portals): treat an undetermined audio language as unknown (#1689) * fix(portals): treat an undetermined audio language as unknown ICU 78, which Electron 43 and Node 26 ship, canonicalizes the ffprobe marker `und` to the subtag `und` instead of an empty one. The source metadata then recorded it as a stated language, so a switch between a copy tagged `und` and one tagged English raised the "dub may differ" warning. CI's older ICU hid this: the existing spec only fails on the newer runtime. Decline `und` explicitly, and cover `und-US` plus the dub comparison. * test(portals): cover undetermined language across runtimes --------- Co-authored-by: 4gray --- .../portals-undetermined-audio-language.md | 7 +++ docs/architecture/vod-multi-source.md | 5 +++ .../vod-source-metadata.util.spec.ts | 43 ++++++++++++++++++- .../multi-source/vod-source-metadata.util.ts | 16 +++++-- 4 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 .changes/portals-undetermined-audio-language.md diff --git a/.changes/portals-undetermined-audio-language.md b/.changes/portals-undetermined-audio-language.md new file mode 100644 index 000000000..e1808943b --- /dev/null +++ b/.changes/portals-undetermined-audio-language.md @@ -0,0 +1,7 @@ +--- +type: fix +area: portals +--- + +Switching to another playlist's copy of a movie no longer warns that the dub +may differ when one copy's audio language is simply unknown (`und`). diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index 018852008..ebd6e1cd2 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -776,6 +776,11 @@ tag a language at all, so in practice it is usually silent — which is the honest state, and the same one the rest of this feature takes when it does not know. +Undetermined language tags (`und`, including regional forms such as `und-US`) +are explicitly treated as unknown after normalization. This must hold whether +the runtime's `Intl.Locale` returns no language or preserves `und`: neither +case establishes a factual dub difference against a known language. + Switching sources through `startResolvedPlayback` closes the external session it LAUNCHED first — tracked separately so refreshes and overlapping handoffs cannot disown it within the same route session. The retained identity is ignored diff --git a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-metadata.util.spec.ts b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-metadata.util.spec.ts index c6d3a6d86..b02719588 100644 --- a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-metadata.util.spec.ts +++ b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-metadata.util.spec.ts @@ -180,8 +180,10 @@ describe('applyApiMetadata', () => { }); it.each([ - // ffprobe's marker for "we do not know". + // ffprobe's marker for "we do not know", regardless of whether + // this runtime's Intl.Locale preserves the language subtag. ['und'], + ['und-US'], ['Russian'], ['en_US'], [''], @@ -360,6 +362,45 @@ describe('audioDiffersFactually', () => { expect(audioDiffersFactually(from, to)).toBe(false); }); + it('stays silent when one side reports an undetermined language', () => { + // `und` is ffprobe admitting it does not know; measured against a + // stated English it is not a dub change. + const from = candidate( + applyApiMetadata(candidate(), { audioLanguage: 'und' }) + ); + const to = candidate( + applyApiMetadata(candidate(), { audioLanguage: 'eng' }) + ); + + expect(audioDiffersFactually(from, to)).toBe(false); + }); + + it.each(['und', 'und-US', 'UND'])( + 'stays silent for %s when Intl.Locale preserves und', + (raw) => { + const known = applyApiMetadata(candidate(), { + audioLanguage: 'eng', + }); + // Exercise the newer runtime behavior even on Node 22. The + // captured Locale constructor still reads this prototype getter. + const language = jest + .spyOn(Intl.Locale.prototype, 'language', 'get') + .mockReturnValue('und'); + + try { + const unknown = applyApiMetadata(candidate(), { + audioLanguage: raw, + }); + + expect(unknown.audioLanguage).toBeUndefined(); + expect(audioDiffersFactually(unknown, known)).toBe(false); + expect(audioDiffersFactually(known, unknown)).toBe(false); + } finally { + language.mockRestore(); + } + } + ); + it('stays silent when both sides state the same track', () => { const from = candidate({ audioLanguage: { value: 'rus', provenance: 'api' }, diff --git a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-metadata.util.ts b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-metadata.util.ts index f3ec6ed5e..a0ddad91a 100644 --- a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-metadata.util.ts +++ b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-metadata.util.ts @@ -217,8 +217,11 @@ function keepFactual(field?: VodSourceField): VodSourceField | undefined { * * `Intl.Locale` does the canonicalizing: 639-2 collapses to 639-1 where one * exists, both German forms land on `de`, and regions drop away. `und` — - * ffprobe's marker for undetermined — canonicalizes to nothing, which is - * exactly right: it means the provider does not know either. + * ffprobe's marker for undetermined — is declined explicitly: it means the + * provider does not know either. Some runtimes return no language for this + * tag; others (including Electron 43 and Node 26) preserve `und`. Declining + * it in either case prevents "unknown vs. English" from becoming a dub + * change, independently of the runtime's Intl implementation. * * Anything that survives with more than three characters is not a language * code (`Russian` parses as the subtag `russian`), so the comparison is @@ -232,7 +235,11 @@ function canonicalLanguage(raw: string | null | undefined): string | null { try { const language = new LocaleCtor(value).language; - return language && language.length <= 3 ? language : null; + return language && + language.length <= 3 && + language !== UNDETERMINED_LANGUAGE + ? language + : null; } catch { // Not a well-formed tag at all; saying nothing beats comparing junk. return null; @@ -253,6 +260,9 @@ const LocaleCtor = ( } ).Locale; +/** ISO 639-2 "undetermined"; see `canonicalLanguage`. */ +const UNDETERMINED_LANGUAGE = 'und'; + function cleanString(raw: string | null | undefined): string | null { const trimmed = typeof raw === 'string' ? raw.trim() : ''; return trimmed === '' ? null : trimmed;