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"])) + } +}