From 6037e5020b67a7334fd7ad0fd21706cc6eb8fa17 Mon Sep 17 00:00:00 2001
From: Philipp Bischoff
Date: Tue, 1 Sep 2026 18:57:40 +0200
Subject: [PATCH] feat(session): resolve the audio track from an ordered
language preference
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
PlayerConfiguration gains `preferredAudioLanguages` (ordered, already-normalised
bare tags supplied by the caller) and `autoEnableForcedSubtitlesForForeignAudio`.
buildPipeline resolves the audio track against that preference before
demuxer.resume(), so the choice costs no lane teardown and no seek — selecting
after open() would seek to a position that is 0 until the first frame, wiping a
startPosition VOD resume, and MediaInfo.isSeekable is true for many live IPTV
endpoints that providers then drop.
Both fields default to the previous behaviour, so an empty preference resolves
`first(where: \.isDefault) ?? first` exactly as before.
TrackLanguageMatcher carries the ISO 639-2/B alias table Foundation does not
provide — Locale.LanguageCode("ger").identifier(.alpha2) is nil while "deu"
resolves — and scores candidates by preference rank, commentary/audio-description
penalty, and exact-over-regional-variant, matching track titles per token rather
than by substring.
Paired with the Lume app change for bilipp/Lume#200; the app resolves
../LumeEngine as a local SPM package, so the two commits must land together.
---
.../Session/PlayerSession.swift | 64 +++++-
.../Session/TrackLanguageMatcher.swift | 190 ++++++++++++++++++
.../Fixtures/generate-fixtures.sh | 37 ++++
.../PlayerSessionTests.swift | 96 +++++++++
.../TrackLanguageMatcherTests.swift | 132 ++++++++++++
5 files changed, 517 insertions(+), 2 deletions(-)
create mode 100644 Sources/LumeEngineCore/Session/TrackLanguageMatcher.swift
create mode 100644 Tests/LumeEngineCoreTests/TrackLanguageMatcherTests.swift
diff --git a/Sources/LumeEngineCore/Session/PlayerSession.swift b/Sources/LumeEngineCore/Session/PlayerSession.swift
index e6e4631..f7883b1 100644
--- a/Sources/LumeEngineCore/Session/PlayerSession.swift
+++ b/Sources/LumeEngineCore/Session/PlayerSession.swift
@@ -47,6 +47,26 @@ public struct PlayerConfiguration: Sendable {
public var enableVideo = true
public var enableAudio = true
public var muted = false
+ /// Ordered, ALREADY-NORMALISED bare language tags supplied by the caller
+ /// (`["de", "en"]`); empty means use the container default.
+ ///
+ /// The engine does no normalisation of its own — it never reads the device
+ /// locale and never invents a preference — but it does normalise the
+ /// *container's* tags, which arrive as ISO 639-2/B (`ger`), /T (`deu`),
+ /// 639-1, or a regional variant (`de-AT`). Applied while the pipeline is
+ /// built, before the demuxer streams a byte (see `TrackLanguageMatcher`).
+ ///
+ /// Also ranks the forced subtitle track when
+ /// `autoEnableForcedSubtitlesForForeignAudio` is on.
+ public var preferredAudioLanguages: [String] = []
+ /// Turn a forced subtitle track on by itself when the audio that ends up
+ /// selected matches none of `preferredAudioLanguages`.
+ ///
+ /// Off by default: whether untranslated dialogue should be subtitled is a
+ /// viewer-facing policy the host owns. The mechanism lives here because it
+ /// has to run while the pipeline is built — attaching the lane after
+ /// `open()` would miss cues and route through a seek.
+ public var autoEnableForcedSubtitlesForForeignAudio = false
/// Seconds without clock progress (while expected) before `.stalled` fires.
public var stallThreshold: Double = 8
@@ -183,12 +203,20 @@ public actor PlayerSession {
guard let demuxer else { throw EngineError(code: .invalidState, message: "no demuxer") }
self.info = info
- // Default track selection: container default flag, else first of kind.
+ // Track selection: the app's ordered language preference first, then
+ // the container default flag, then the first track of the kind. The
+ // preference is resolved *here*, while the pipeline is being built —
+ // selecting after open would route through `seek(to: position)` with
+ // position still 0, discarding `startPosition` on VOD resume and
+ // seeking live sources that do not survive one (see
+ // `TrackLanguageMatcher`). An empty preference list matches nothing,
+ // so a default configuration behaves exactly as it did before.
let videoTrack = configuration.enableVideo
? info.videoTracks.first(where: \.isDefault) ?? info.videoTracks.first
: nil
let audioTrack = configuration.enableAudio
- ? info.audioTracks.first(where: \.isDefault) ?? info.audioTracks.first
+ ? TrackLanguageMatcher.bestMatch(in: info.audioTracks, preferring: configuration.preferredAudioLanguages)
+ ?? info.audioTracks.first(where: \.isDefault) ?? info.audioTracks.first
: nil
guard videoTrack != nil || audioTrack != nil else {
@@ -240,6 +268,26 @@ public actor PlayerSession {
decoder.start()
}
+ // The one case where the engine turns subtitles on by itself, and only
+ // when the host asked for it: the audio the viewer will hear is foreign
+ // to them (it matched none of their preferred audio languages) and the
+ // source carries a forced track for the untranslated dialogue.
+ // Attaching here rather than after open keeps it on the same no-seek
+ // path as the audio choice, and the demuxer has not read a packet yet,
+ // so no cue is missed.
+ if configuration.autoEnableForcedSubtitlesForForeignAudio,
+ let audioTrack,
+ !configuration.preferredAudioLanguages.isEmpty,
+ !TrackLanguageMatcher.matches(audioTrack, preferring: configuration.preferredAudioLanguages),
+ let forced = forcedSubtitleTrack(in: info),
+ let parameters = demuxer.codecParameters(forStream: forced.index) {
+ let packets = Channel(capacity: 128)
+ demuxer.attach(channel: packets, toStream: forced.index)
+ let decoder = SubtitleDecoder(parameters: parameters, input: packets, store: subtitles)
+ installSubtitleLane(trackIndex: forced.index, packets: packets, decoder: decoder)
+ decoder.start()
+ }
+
renderer.onRenderFailure = { [weak self] error in
guard let self else { return }
Task { await self.handleRenderFailure(error) }
@@ -263,6 +311,18 @@ public actor PlayerSession {
}
}
+ /// The forced track to show under foreign audio, ranked by the viewer's
+ /// audio preference (someone who wants German audio reads German signs)
+ /// and otherwise whatever forced track exists — a forced track is short,
+ /// on-screen only for dialogue the soundtrack leaves untranslated, and is
+ /// what the mux author expected to be shown.
+ private func forcedSubtitleTrack(in info: MediaInfo) -> TrackInfo? {
+ let forced = info.subtitleTracks.filter(\.isForced)
+ guard !forced.isEmpty else { return nil }
+ return TrackLanguageMatcher.bestMatch(in: forced, preferring: configuration.preferredAudioLanguages)
+ ?? forced.first
+ }
+
/// Timeline origin: media start time plus any requested start position.
private var startTimeline: Int64 {
info?.startTime ?? 0
diff --git a/Sources/LumeEngineCore/Session/TrackLanguageMatcher.swift b/Sources/LumeEngineCore/Session/TrackLanguageMatcher.swift
new file mode 100644
index 0000000..f3bda8f
--- /dev/null
+++ b/Sources/LumeEngineCore/Session/TrackLanguageMatcher.swift
@@ -0,0 +1,190 @@
+import Foundation
+
+/// Ordered language-preference matching over container tracks.
+///
+/// Used at open time only (`PlayerSession.buildPipeline`): the track a viewer
+/// wants must be chosen *while the pipeline is being built*, never by opening
+/// on one track and switching afterwards. Switching after open re-syncs every
+/// lane through `seek(to: position)`, and at open `position` is still 0 —
+/// which would silently discard `PlayerConfiguration.startPosition` on VOD
+/// resume, and issue a seek against live IPTV endpoints that report
+/// `isSeekable` but drop the connection when one arrives.
+///
+/// Contract with the app (PLAN.md §3.8 — configuration in, no global knobs):
+/// preferences arrive **already normalised** as ordered bare language tags
+/// (`["de", "en"]`). The engine never consults the device locale and never
+/// invents a preference; an empty list means "leave the container's own
+/// choice alone", which is what every pre-existing session gets.
+///
+/// Container tags are whatever the muxer wrote, so the *track* side does need
+/// normalising: ISO 639-2/B (`ger`), /T (`deu`), 639-1 (`de`), a BCP-47
+/// variant (`de-AT`), or nothing at all with the language spelled out in the
+/// title instead.
+enum TrackLanguageMatcher {
+ /// Ranked quality of one track↔preference hit. Lower is better, and the
+ /// fields are compared in declaration order:
+ ///
+ /// 1. `rank` — position in the caller's ordered preference list.
+ /// 2. `penalty` — commentary / audio-description labels lose to a plain
+ /// track of the same language (a viewer asking for German means the
+ /// German feature audio, never the director's commentary).
+ /// 3. `quality` — an exact bare-code tag beats a regional variant
+ /// (`de` over `de-AT`), which beats a language code found in the free
+ /// text of the title.
+ /// 4. `order` — container order; first match wins all else being equal.
+ private struct Score: Comparable {
+ let rank: Int
+ let penalty: Int
+ let quality: Int
+ let order: Int
+
+ static func < (lhs: Score, rhs: Score) -> Bool {
+ (lhs.rank, lhs.penalty, lhs.quality, lhs.order)
+ < (rhs.rank, rhs.penalty, rhs.quality, rhs.order)
+ }
+ }
+
+ /// Best track for an ordered preference list, or `nil` when nothing
+ /// matches. `nil` means *do nothing* — callers keep the container's own
+ /// selection rather than falling back to some arbitrary track.
+ static func bestMatch(in tracks: [TrackInfo], preferring languages: [String]) -> TrackInfo? {
+ guard !tracks.isEmpty else { return nil }
+ let wanted = normalizedPreferences(languages)
+ guard !wanted.isEmpty else { return nil }
+
+ var best: (score: Score, track: TrackInfo)?
+ for (order, track) in tracks.enumerated() {
+ guard let hit = score(track, order: order, against: wanted) else { continue }
+ if best == nil || hit < best!.score {
+ best = (hit, track)
+ }
+ }
+ return best?.track
+ }
+
+ /// True when the track is one the viewer asked for. Used to decide
+ /// whether the audio that ended up selected is *foreign* to the viewer,
+ /// which is what gates forced-subtitle auto-enable. An empty preference
+ /// list makes every track "wanted", so nothing is ever foreign — the
+ /// default configuration cannot reach the forced branch at all.
+ static func matches(_ track: TrackInfo, preferring languages: [String]) -> Bool {
+ let wanted = normalizedPreferences(languages)
+ guard !wanted.isEmpty else { return true }
+ return score(track, order: 0, against: wanted) != nil
+ }
+
+ // MARK: Scoring
+
+ private static func score(_ track: TrackInfo, order: Int, against wanted: [String]) -> Score? {
+ let penalty = isDeprioritized(track) ? 1 : 0
+
+ if let tag = track.language, let canonical = canonicalize(tag) {
+ if let rank = wanted.firstIndex(of: canonical) {
+ return Score(rank: rank, penalty: penalty, quality: hasSubtags(tag) ? 1 : 0, order: order)
+ }
+ }
+
+ // Free-text titles ("ger 5.1", "DE"). Matched per whitespace/punctuation
+ // token, never by substring: a bare "de" occurs inside half the Romance
+ // titles ever written and would match everything.
+ if let title = track.title {
+ for token in tokens(of: title) {
+ guard let canonical = canonicalize(token), let rank = wanted.firstIndex(of: canonical) else { continue }
+ return Score(rank: rank, penalty: penalty, quality: 2, order: order)
+ }
+ }
+
+ return nil
+ }
+
+ /// Labels that mean "not the feature audio": director commentary and
+ /// audio description. Matched on the title, since `TrackInfo` carries no
+ /// FFmpeg comment/visual-impaired disposition.
+ private static func isDeprioritized(_ track: TrackInfo) -> Bool {
+ guard let title = track.title?.lowercased() else { return false }
+ for phrase in commentaryPhrases where title.contains(phrase) {
+ return true
+ }
+ // "English AD" — an abbreviation only as a standalone token.
+ return tokens(of: title).contains("ad")
+ }
+
+ private static let commentaryPhrases = [
+ "commentary", "commentaire", "kommentar", "comentario", "comentário",
+ "commento", "audio description", "audiodescription", "audiodescricao",
+ "audiodescrição", "audiodescripcion", "audiodescripción", "descriptive",
+ "described", "hörfilm",
+ ]
+
+ // MARK: Language tags
+
+ /// Ordered, de-duplicated canonical forms of the caller's preferences.
+ private static func normalizedPreferences(_ languages: [String]) -> [String] {
+ var seen = Set()
+ var result: [String] = []
+ for language in languages {
+ guard let canonical = canonicalize(language), seen.insert(canonical).inserted else { continue }
+ result.append(canonical)
+ }
+ return result
+ }
+
+ /// Reduces a tag to a comparable primary language subtag, or `nil` when it
+ /// carries no language information at all.
+ ///
+ /// `Locale.LanguageCode` covers the ISO 639-2/T codes (`deu` → `de`) but
+ /// returns nil for the bibliographic /B forms that MKV and MPEG-TS
+ /// actually carry (`ger`, `fre`, `chi`, `cze`, `dut`, `gre`, …), so those
+ /// need the table below. Three-letter codes with no two-letter form at all
+ /// (`fil`, `haw`) stay as they are and still compare against each other.
+ static func canonicalize(_ tag: String) -> String? {
+ let trimmed = tag.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ guard !trimmed.isEmpty else { return nil }
+ let primary = trimmed.split(whereSeparator: { $0 == "-" || $0 == "_" }).first.map(String.init) ?? trimmed
+ guard primary.allSatisfy(\.isLetter) else { return nil }
+ guard !placeholders.contains(primary) else { return nil }
+
+ switch primary.count {
+ case 2:
+ return primary
+ case 3:
+ if let alpha2 = bibliographicAlpha2[primary] { return alpha2 }
+ if let alpha2 = Locale.LanguageCode(primary).identifier(.alpha2), alpha2.count == 2 {
+ return alpha2
+ }
+ return primary
+ default:
+ return nil
+ }
+ }
+
+ /// True when the tag names a region/script beyond the language itself
+ /// (`de-AT`), which loses to an exact `de` when both are present.
+ private static func hasSubtags(_ tag: String) -> Bool {
+ tag.contains("-") || tag.contains("_")
+ }
+
+ /// Tags that assert the absence of a single language. `vo` is French
+ /// broadcast shorthand for "version originale" (not Volapük, which no
+ /// IPTV provider has ever muxed), and `multi` is the IPTV convention for
+ /// a multiplexed multi-language track. All of them mean "no match" — the
+ /// container's own choice stands.
+ private static let placeholders: Set = [
+ "und", "mul", "mis", "zxx", "vo", "vos", "vost", "multi", "original", "unknown",
+ ]
+
+ /// ISO 639-2/B → 639-1, i.e. exactly the codes `Locale.LanguageCode`
+ /// cannot resolve because it only knows the terminological forms.
+ private static let bibliographicAlpha2: [String: String] = [
+ "alb": "sq", "arm": "hy", "baq": "eu", "bur": "my", "chi": "zh",
+ "cze": "cs", "dut": "nl", "fre": "fr", "geo": "ka", "ger": "de",
+ "gre": "el", "ice": "is", "mac": "mk", "may": "ms", "mao": "mi",
+ "per": "fa", "rum": "ro", "slo": "sk", "tib": "bo", "wel": "cy",
+ ]
+
+ private static func tokens(of text: String) -> [String] {
+ text.lowercased()
+ .split(whereSeparator: { !$0.isLetter })
+ .map(String.init)
+ }
+}
diff --git a/Tests/LumeEngineCoreTests/Fixtures/generate-fixtures.sh b/Tests/LumeEngineCoreTests/Fixtures/generate-fixtures.sh
index 4251024..e49e2c2 100644
--- a/Tests/LumeEngineCoreTests/Fixtures/generate-fixtures.sh
+++ b/Tests/LumeEngineCoreTests/Fixtures/generate-fixtures.sh
@@ -6,6 +6,8 @@
# basic.mp4 — H.264 + AAC, 10 s VOD
# wrap.ts — MPEG-TS whose raw 33-bit timestamps wrap mid-file
# multitrack.mkv — video + 2 audio languages + SRT subtitles + chapters
+# multilang.mkv — video + eng (default disposition) + ger audio
+# forcedsubs.mkv — video + eng audio + a FORCED eng subtitle track
# surround71.mkv — FLAC 7.1 audio-only
# truehd.mkv — TrueHD 5.1 audio-only (40-sample access units)
# interlaced.ts — 1080i-style MPEG-TS, field-coded, TFF (deinterlacer input)
@@ -70,6 +72,41 @@ gen interlaced.ts \
-vf "interlace=scan=tff" \
-c:v mpeg2video -flags +ilme+ildct -top 1 -g 25 -f mpegts
+# Two audio languages where the container's own default flag (eng) is the
+# *wrong* answer for a German-preferring viewer: the ordered preference must
+# beat the disposition, and an empty/unmatched preference must leave it alone.
+gen multilang.mkv \
+ -f lavfi -i "testsrc2=duration=5:size=320x180:rate=25" \
+ -f lavfi -i "sine=frequency=440:duration=5" \
+ -f lavfi -i "sine=frequency=880:duration=5" \
+ -map 0:v -map 1:a -map 2:a \
+ -c:v libx264 -preset ultrafast -pix_fmt yuv420p -c:a aac \
+ -metadata:s:a:0 language=eng -metadata:s:a:1 language=ger \
+ -disposition:a:0 default -f matroska
+
+# A forced subtitle track under English-only audio: the one case where the
+# engine enables subtitles by itself (viewer prefers another audio language,
+# so the dialogue they get is foreign and the signs/foreign-speech track is
+# what the mux author meant them to see).
+if [ ! -f "$OUT/forcedsubs.mkv" ]; then
+ cat > "$OUT/forced.srt" <<'SRT'
+1
+00:00:01,000 --> 00:00:03,000
+[speaking another language]
+SRT
+ "$FFMPEG" -hide_banner -loglevel error -y \
+ -f lavfi -i "testsrc2=duration=5:size=320x180:rate=25" \
+ -f lavfi -i "sine=frequency=440:duration=5" \
+ -i "$OUT/forced.srt" \
+ -map 0:v -map 1:a -map 2:s \
+ -c:v libx264 -preset ultrafast -pix_fmt yuv420p \
+ -c:a aac -c:s srt \
+ -metadata:s:a:0 language=eng -metadata:s:s:0 language=eng \
+ -disposition:s:0 forced \
+ "$OUT/forcedsubs.mkv"
+ echo "generated: $OUT/forcedsubs.mkv"
+fi
+
if [ ! -f "$OUT/multitrack.mkv" ]; then
cat > "$OUT/subs.srt" <<'SRT'
1
diff --git a/Tests/LumeEngineCoreTests/PlayerSessionTests.swift b/Tests/LumeEngineCoreTests/PlayerSessionTests.swift
index 42fab7c..c602b89 100644
--- a/Tests/LumeEngineCoreTests/PlayerSessionTests.swift
+++ b/Tests/LumeEngineCoreTests/PlayerSessionTests.swift
@@ -212,4 +212,100 @@ struct PlayerSessionTests {
await session.shutdown()
}
+
+ // MARK: Preferred languages
+ //
+ // The selection must happen while the pipeline is built: selecting after
+ // open() routes through seek(to: position) with position still 0, which
+ // wipes a startPosition resume and seeks live sources that cannot take one.
+ // These tests therefore assert the state right after open(), before play().
+
+ private func makeLanguageSession(audioLanguages: [String] = []) -> PlayerSession {
+ var configuration = PlayerConfiguration()
+ configuration.muted = true
+ configuration.bufferTarget = 0.5
+ configuration.preferredAudioLanguages = audioLanguages
+ // What a host that wants the forced-subtitle rule sets. The rule is
+ // opt-in, so these tests have to opt in the same way Lume does.
+ configuration.autoEnableForcedSubtitlesForForeignAudio = true
+ return PlayerSession(configuration: configuration)
+ }
+
+ @Test("preferred audio language beats the container default", .timeLimit(.minutes(1)))
+ func preferredAudioLanguageWins() async throws {
+ // multilang.mkv: a:0 eng carries the default disposition, a:1 ger does not.
+ let session = makeLanguageSession(audioLanguages: ["de", "en"])
+ let info = try await session.open(url: try Fixtures.path("multilang.mkv"))
+
+ let german = try #require(info.audioTracks.first { $0.language == "ger" })
+ #expect(await session.selectedAudioTrackIndex == german.index)
+ #expect(await session.selectedSubtitleTrackIndex == nil, "subtitles must stay off")
+
+ await session.shutdown()
+ }
+
+ @Test("no preference keeps the container default", .timeLimit(.minutes(1)))
+ func noPreferenceKeepsContainerDefault() async throws {
+ let session = makeLanguageSession()
+ let info = try await session.open(url: try Fixtures.path("multilang.mkv"))
+
+ let english = try #require(info.audioTracks.first { $0.language == "eng" })
+ #expect(await session.selectedAudioTrackIndex == english.index)
+
+ await session.shutdown()
+ }
+
+ @Test("an unmatched preference is inert", .timeLimit(.minutes(1)))
+ func unmatchedPreferenceIsInert() async throws {
+ // No Japanese track: "no match" means leaving the container alone, not
+ // picking track 0 and not surfacing anything.
+ let session = makeLanguageSession(audioLanguages: ["ja"])
+ let info = try await session.open(url: try Fixtures.path("multilang.mkv"))
+
+ let english = try #require(info.audioTracks.first { $0.language == "eng" })
+ #expect(await session.selectedAudioTrackIndex == english.index)
+
+ await session.shutdown()
+ }
+
+ @Test("foreign audio auto-enables a forced subtitle track", .timeLimit(.minutes(1)))
+ func forcedSubtitlesUnderForeignAudio() async throws {
+ // forcedsubs.mkv has English audio only; a German-preferring viewer
+ // gets audio they did not ask for, so the forced track comes on.
+ let session = makeLanguageSession(audioLanguages: ["de"])
+ let info = try await session.open(url: try Fixtures.path("forcedsubs.mkv"))
+
+ let forced = try #require(info.subtitleTracks.first { $0.isForced })
+ #expect(await session.selectedSubtitleTrackIndex == forced.index)
+
+ await session.shutdown()
+ }
+
+ @Test("foreign audio never enables a non-forced subtitle track", .timeLimit(.minutes(1)))
+ func foreignAudioLeavesFullSubtitleTracksOff() async throws {
+ // multitrack.mkv is eng+ger audio with a plain (non-forced) eng SRT.
+ // A Japanese-preferring viewer gets foreign audio, but only a forced
+ // track is ever switched on for them — a full track stays the app's
+ // call, made through selectSubtitleTrack(_:).
+ let session = makeLanguageSession(audioLanguages: ["ja"])
+ _ = try await session.open(url: try Fixtures.path("multitrack.mkv"))
+ #expect(await session.selectedSubtitleTrackIndex == nil, "only forced tracks are ever auto-enabled")
+ await session.shutdown()
+ }
+
+ @Test("matching audio leaves forced subtitles off", .timeLimit(.minutes(1)))
+ func forcedSubtitlesStayOffWhenAudioMatches() async throws {
+ let session = makeLanguageSession(audioLanguages: ["en"])
+ _ = try await session.open(url: try Fixtures.path("forcedsubs.mkv"))
+ #expect(await session.selectedSubtitleTrackIndex == nil)
+ await session.shutdown()
+ }
+
+ @Test("default configuration never reaches the forced branch", .timeLimit(.minutes(1)))
+ func forcedSubtitlesUnreachableWithoutPreferences() async throws {
+ let session = makeLanguageSession()
+ _ = try await session.open(url: try Fixtures.path("forcedsubs.mkv"))
+ #expect(await session.selectedSubtitleTrackIndex == nil, "empty preferences must behave exactly as before")
+ await session.shutdown()
+ }
}
diff --git a/Tests/LumeEngineCoreTests/TrackLanguageMatcherTests.swift b/Tests/LumeEngineCoreTests/TrackLanguageMatcherTests.swift
new file mode 100644
index 0000000..85caf89
--- /dev/null
+++ b/Tests/LumeEngineCoreTests/TrackLanguageMatcherTests.swift
@@ -0,0 +1,132 @@
+import Foundation
+import Testing
+@testable import LumeEngineCore
+
+@Suite("TrackLanguageMatcher")
+struct TrackLanguageMatcherTests {
+ private func audio(
+ _ index: Int32,
+ language: String?,
+ title: String? = nil,
+ isDefault: Bool = false,
+ isForced: Bool = false
+ ) -> TrackInfo {
+ TrackInfo(
+ index: index,
+ kind: .audio,
+ codecName: "aac",
+ codecID: 0,
+ language: language,
+ title: title,
+ isDefault: isDefault,
+ isForced: isForced,
+ bitrate: 0,
+ video: nil,
+ audio: TrackInfo.Audio(channels: 2, sampleRate: 48_000),
+ wrapBits: 64
+ )
+ }
+
+ @Test("an empty preference matches nothing — the container's choice stands")
+ func emptyPreferenceIsInert() {
+ let tracks = [audio(0, language: "eng"), audio(1, language: "ger")]
+ #expect(TrackLanguageMatcher.bestMatch(in: tracks, preferring: []) == nil)
+ }
+
+ @Test("ISO 639-2/B container tags resolve to the app's bare codes")
+ func bibliographicCodes() {
+ // Locale.LanguageCode cannot do these: "ger"/"fre"/"chi"/"cze"/"dut"
+ // all return nil for .alpha2, and MKV/TS write exactly those.
+ let cases: [(String, String)] = [
+ ("ger", "de"), ("deu", "de"), ("fre", "fr"), ("fra", "fr"),
+ ("chi", "zh"), ("cze", "cs"), ("dut", "nl"), ("gre", "el"),
+ ("por", "pt"), ("eng", "en"),
+ ]
+ for (tag, wanted) in cases {
+ let track = audio(0, language: tag)
+ #expect(
+ TrackLanguageMatcher.bestMatch(in: [track], preferring: [wanted])?.index == 0,
+ "\(tag) must match \(wanted)"
+ )
+ }
+ }
+
+ @Test("preference order decides, not container order")
+ func preferenceOrderWins() {
+ let tracks = [audio(0, language: "eng"), audio(1, language: "ger"), audio(2, language: "fre")]
+ #expect(TrackLanguageMatcher.bestMatch(in: tracks, preferring: ["fr", "de"])?.index == 2)
+ #expect(TrackLanguageMatcher.bestMatch(in: tracks, preferring: ["de", "fr"])?.index == 1)
+ }
+
+ @Test("a bare preference matches a regional variant, exact first")
+ func regionalVariants() {
+ let variantOnly = [audio(0, language: "eng"), audio(1, language: "pt-BR")]
+ #expect(TrackLanguageMatcher.bestMatch(in: variantOnly, preferring: ["pt"])?.index == 1)
+
+ // de-AT is offered first in the container; the exact de still wins.
+ let both = [audio(0, language: "de-AT"), audio(1, language: "de")]
+ #expect(TrackLanguageMatcher.bestMatch(in: both, preferring: ["de"])?.index == 1)
+ }
+
+ @Test("first container match wins between equals")
+ func firstMatchWins() {
+ let tracks = [
+ audio(0, language: "ger", title: "German 5.1"),
+ audio(1, language: "ger", title: "German Stereo"),
+ ]
+ #expect(TrackLanguageMatcher.bestMatch(in: tracks, preferring: ["de"])?.index == 0)
+ }
+
+ @Test("commentary and audio description lose to the feature audio")
+ func commentaryIsDeprioritized() {
+ let commentaryFirst = [
+ audio(0, language: "eng", title: "English (Director's Commentary)"),
+ audio(1, language: "eng", title: "English"),
+ ]
+ #expect(TrackLanguageMatcher.bestMatch(in: commentaryFirst, preferring: ["en"])?.index == 1)
+
+ let describedFirst = [
+ audio(0, language: "eng", title: "English AD"),
+ audio(1, language: "eng", title: "English"),
+ ]
+ #expect(TrackLanguageMatcher.bestMatch(in: describedFirst, preferring: ["en"])?.index == 1)
+
+ // Still selected when it is the only track of that language: a
+ // de-prioritised match beats no audio at all.
+ let onlyCommentary = [audio(0, language: "eng", title: "Commentary")]
+ #expect(TrackLanguageMatcher.bestMatch(in: onlyCommentary, preferring: ["en"])?.index == 0)
+ }
+
+ @Test("und / mul / VO / Multi are not a match")
+ func placeholderTagsNeverMatch() {
+ for tag in ["und", "mul", "VO", "Multi", "unknown", "zxx"] {
+ let tracks = [audio(0, language: tag)]
+ #expect(
+ TrackLanguageMatcher.bestMatch(in: tracks, preferring: ["de", "en", "fr"]) == nil,
+ "\(tag) must not match"
+ )
+ }
+ }
+
+ @Test("an untagged track matches through its title, per token")
+ func titleTokens() {
+ let tagged = [audio(0, language: nil, title: "GER 5.1"), audio(1, language: nil, title: "ENG")]
+ #expect(TrackLanguageMatcher.bestMatch(in: tagged, preferring: ["de"])?.index == 0)
+
+ // No substring matching: "de" inside "Bande originale" is not French.
+ let prose = [audio(0, language: nil, title: "Bande originale")]
+ #expect(TrackLanguageMatcher.bestMatch(in: prose, preferring: ["de"]) == nil)
+
+ // A real language tag outranks a title hit.
+ let mixed = [audio(0, language: nil, title: "eng"), audio(1, language: "eng")]
+ #expect(TrackLanguageMatcher.bestMatch(in: mixed, preferring: ["en"])?.index == 1)
+ }
+
+ @Test("matches() is what makes audio 'foreign' — and never with an empty list")
+ func foreignAudioTest() {
+ let english = audio(0, language: "eng")
+ #expect(TrackLanguageMatcher.matches(english, preferring: []))
+ #expect(TrackLanguageMatcher.matches(english, preferring: ["en"]))
+ #expect(!TrackLanguageMatcher.matches(english, preferring: ["de"]))
+ }
+}