mux: StreamSelection primitive + apply sites for per-title stream selection

The demux pipeline is declaration-driven off DiscTitle.streams (build_demux_state,
DiscStream::new, and the MKV writer all key off that list), so 'which streams to
keep' is already a pipeline capability with no public knob. This adds the knob:

- mux/select.rs: StreamSelection { audio, subtitle: PidFilter::All | Only(Vec<u16>) }
  + apply(&mut DiscTitle): keep Video always, keep Audio/Subtitle whose PID the
  filter lists, prune the rest (and the parallel codec_privates in lockstep);
  error SelectionPidUnknown on a listed PID absent from the title (fail loud, not
  a silently-missing track). Pure; 6 unit tests. Re-exported at crate root.
- Error::SelectionPidUnknown (E6014).
- MuxOptions gains  (+ derives Default now) applied in mux_stream's
  Iso/Session arms before the highway/DiscStream builds demux state (and before
  probe_and_remap's DVD AC-3 PID rewrite). InputOptions gains  applied
  in input()'s iso arm right after the title-index bounds check.

PIDs not languages -- language->PID is caller/engine policy. Default All/All is a
no-op (apply gated on !is_all()), so the no-selection path is byte-identical:
nothing below the title-finalization line changes (ts/ps/demux_thread/
pipelined_stream/mkv/disc untouched). All 2488 lib tests pass on 1.86.

Design: freemkv-private/audit/engine-split/STREAM-SELECTION-DESIGN.md (Fable).
This commit is contained in:
MattJackson
2026-07-28 13:14:40 -07:00
parent 17694af625
commit 19b646e012
7 changed files with 367 additions and 5 deletions
+5 -4
View File
@@ -19,10 +19,11 @@ reach into the others.
The library exposes flat verbs; the caller drives the multipass loop. Autorip The library exposes flat verbs; the caller drives the multipass loop. Autorip
runs `Disc::sweep` once, then loops `Disc::patch` until either the mapfile is runs `Disc::sweep` once, then loops `Disc::patch` until either the mapfile is
clean or the configured retry budget is exhausted, then hands the ISO off to clean or the configured retry budget is exhausted, then hands the ISO off to
the mux pipeline. The `freemkv` CLI does the same shape with a the mux pipeline. The `freemkv` CLI does the same shape, but as of 1.6.0 the
terminal-output progress sink. Layer 3 runs inside any consumer of loop itself (including the multi-title rip loop) lives one layer up, in the
`DiscStream` (direct PES pipeline, ISO playback, etc.) without caller shared `freemkv-engine` crate, with a terminal-output progress sink plugged
involvement. into it as the `Sink`. Layer 3 runs inside any consumer of `DiscStream`
(direct PES pipeline, ISO playback, etc.) without caller involvement.
Three primitives compose the disc-side flow: Three primitives compose the disc-side flow:
+11
View File
@@ -59,6 +59,7 @@ pub const E_MKV_INVALID: u16 = 6008;
pub const E_NO_STREAMS: u16 = 6009; pub const E_NO_STREAMS: u16 = 6009;
pub const E_HALTED: u16 = 6010; pub const E_HALTED: u16 = 6010;
pub const E_MAPFILE_INVALID: u16 = 6011; pub const E_MAPFILE_INVALID: u16 = 6011;
pub const E_SELECTION_PID_UNKNOWN: u16 = 6014;
pub const E_UDF_BUFFER_TOO_SMALL: u16 = 6012; pub const E_UDF_BUFFER_TOO_SMALL: u16 = 6012;
pub const E_UDF_NOT_FILESYSTEM: u16 = 6013; pub const E_UDF_NOT_FILESYSTEM: u16 = 6013;
@@ -292,6 +293,12 @@ pub enum Error {
IfoParse, IfoParse,
MkvInvalid, MkvInvalid,
NoStreams, NoStreams,
/// A [`crate::StreamSelection`] listed a PID that does not exist in the
/// title's declared streams — a caller bug (e.g. a stale scan), reported
/// loudly rather than silently producing an MKV missing a requested track.
SelectionPidUnknown {
pid: u16,
},
/// ddrescue mapfile parse failed. `kind` is a stable, language-neutral /// ddrescue mapfile parse failed. `kind` is a stable, language-neutral
/// identifier (e.g. `"status_char"`, `"hex"`); not a translatable /// identifier (e.g. `"status_char"`, `"hex"`); not a translatable
/// English message. /// English message.
@@ -579,6 +586,7 @@ impl Error {
Error::IfoParse => E_IFO_PARSE, Error::IfoParse => E_IFO_PARSE,
Error::MkvInvalid => E_MKV_INVALID, Error::MkvInvalid => E_MKV_INVALID,
Error::NoStreams => E_NO_STREAMS, Error::NoStreams => E_NO_STREAMS,
Error::SelectionPidUnknown { .. } => E_SELECTION_PID_UNKNOWN,
Error::MapfileInvalid { .. } => E_MAPFILE_INVALID, Error::MapfileInvalid { .. } => E_MAPFILE_INVALID,
Error::AacsNoKeys => E_AACS_NO_KEYS, Error::AacsNoKeys => E_AACS_NO_KEYS,
Error::AacsCertShort => E_AACS_CERT_SHORT, Error::AacsCertShort => E_AACS_CERT_SHORT,
@@ -786,6 +794,9 @@ impl std::fmt::Display for Error {
Error::InvalidCdbLength { len, max } => { Error::InvalidCdbLength { len, max } => {
write!(f, "E{}: {}/{}", self.code(), len, max) write!(f, "E{}: {}/{}", self.code(), len, max)
} }
Error::SelectionPidUnknown { pid } => {
write!(f, "E{}: 0x{:04x}", self.code(), pid)
}
_ => write!(f, "E{}", self.code()), _ => write!(f, "E{}", self.code()),
} }
} }
+1
View File
@@ -272,6 +272,7 @@ pub use mux::{Mp4FitReport, Mp4SkipReason, mp4_fit_report};
// `SectorSource` to get plaintext sectors out. // `SectorSource` to get plaintext sectors out.
pub use mux::build_iso_pipeline; pub use mux::build_iso_pipeline;
pub use mux::resolve_mux_key_map; pub use mux::resolve_mux_key_map;
pub use mux::select::{PidFilter, StreamSelection};
pub use mux::{MuxEvents, MuxInput, MuxOptions, MuxOutcome, NoopEvents, mux_stream}; pub use mux::{MuxEvents, MuxInput, MuxOptions, MuxOutcome, NoopEvents, mux_stream};
pub use scsi::{DriveInfo, ScsiSense, ScsiTransport, SenseFamily, drive_has_disc, list_drives}; pub use scsi::{DriveInfo, ScsiSense, ScsiTransport, SenseFamily, drive_has_disc, list_drives};
pub use sector::{ pub use sector::{
+35 -1
View File
@@ -153,6 +153,11 @@ pub enum MuxInput<'a> {
} }
/// Tuning / behaviour knobs for a mux run. /// Tuning / behaviour knobs for a mux run.
///
/// `Default` = keep-everything, no-skip, decrypt, no send deadline — the
/// archival default. Added so callers set only the fields they care about
/// (and so the additive `selection` field doesn't churn every constructor).
#[derive(Default)]
pub struct MuxOptions { pub struct MuxOptions {
/// Skip past read errors (zero-fill + continue) on the live-drive path /// Skip past read errors (zero-fill + continue) on the live-drive path
/// instead of aborting. Wired onto `DiscStream::skip_errors`. /// instead of aborting. Wired onto `DiscStream::skip_errors`.
@@ -161,6 +166,11 @@ pub struct MuxOptions {
pub batch_sectors: u16, pub batch_sectors: u16,
/// Ciphertext passthrough — skip decryption / CSS self-crack. /// Ciphertext passthrough — skip decryption / CSS self-crack.
pub raw: bool, pub raw: bool,
/// Which audio/subtitle streams to keep in the muxed title. Default keeps
/// every stream (video is always kept). Applied to the title before the
/// demux pipeline is built, so track headers, `codec_privates`, and frame
/// routing all follow the pruned list. See [`crate::StreamSelection`].
pub selection: crate::StreamSelection,
/// Per-frame write-pipeline send deadline. /// Per-frame write-pipeline send deadline.
/// ///
/// - `Some(d)` — a hard `d` timeout: a sink that back-pressures a single /// - `Some(d)` — a hard `d` timeout: a sink that back-pressures a single
@@ -278,6 +288,14 @@ pub fn mux_stream(
keys, keys,
key_fetch, key_fetch,
} => { } => {
// Prune to the selected audio/subtitle streams BEFORE the highway
// builds its demux state from `title.streams` (and before
// `build_iso_pipeline`'s `probe_and_remap` may rewrite DVD AC-3
// PIDs). Video is always kept; a no-op for the default All/All.
let mut title = title;
opts.selection
.apply(&mut title)
.map_err(std::io::Error::from)?;
let reader = FileSectorSource::open(path)?; let reader = FileSectorSource::open(path)?;
let stream = build_iso_pipeline( let stream = build_iso_pipeline(
reader, reader,
@@ -299,7 +317,7 @@ pub fn mux_stream(
// Pull everything we need out of the disc as owned values so the // Pull everything we need out of the disc as owned values so the
// immutable disc borrow is released before the mutable // immutable disc borrow is released before the mutable
// `take_reader` below. // `take_reader` below.
let (title, format, mut keys, playlist) = { let (mut title, format, mut keys, playlist) = {
let disc = session.disc().ok_or_else(|| Error::DeviceNotReady { let disc = session.disc().ok_or_else(|| Error::DeviceNotReady {
path: session.device_path().to_string(), path: session.device_path().to_string(),
})?; })?;
@@ -319,6 +337,14 @@ pub fn mux_stream(
// (see `session_mux_keys`), never the whole-disc `decrypt_keys()`. // (see `session_mux_keys`), never the whole-disc `decrypt_keys()`.
(title, disc.content_format, session_mux_keys(disc), playlist) (title, disc.content_format, session_mux_keys(disc), playlist)
}; };
// Prune to the selected streams before `DiscStream::new` builds its
// demux tables from `title.streams` (and before its inline
// `probe_and_remap`). Only touches the stream list, so the
// ciphertext sampling in `resolve_inline_base_map` (keyed on extents)
// is unaffected. No-op for the default All/All.
opts.selection
.apply(&mut title)
.map_err(std::io::Error::from)?;
// A missing staged reader ("already consumed" / never staged) is a // A missing staged reader ("already consumed" / never staged) is a
// clean error, not a panic (contract Q2). // clean error, not a panic (contract Q2).
let mut reader = session.take_reader().ok_or_else(|| Error::DeviceNotReady { let mut reader = session.take_reader().ok_or_else(|| Error::DeviceNotReady {
@@ -1308,6 +1334,7 @@ mod tests {
batch_sectors: 8192, batch_sectors: 8192,
raw: false, raw: false,
send_deadline: Some(Duration::from_secs(60)), send_deadline: Some(Duration::from_secs(60)),
selection: Default::default(),
}; };
let halt = Halt::new(); let halt = Halt::new();
let out = mux_stream( let out = mux_stream(
@@ -1416,6 +1443,7 @@ mod tests {
batch_sectors: us as u16, batch_sectors: us as u16,
raw: false, raw: false,
send_deadline: Some(Duration::from_secs(60)), send_deadline: Some(Duration::from_secs(60)),
selection: Default::default(),
}; };
let halt = Halt::new(); let halt = Halt::new();
// Drains to a NoStreams refusal (zeroed data resolves no headers); we // Drains to a NoStreams refusal (zeroed data resolves no headers); we
@@ -1534,6 +1562,7 @@ mod tests {
batch_sectors: 3, // one aligned unit per read batch_sectors: 3, // one aligned unit per read
raw: false, raw: false,
send_deadline: Some(Duration::from_secs(60)), send_deadline: Some(Duration::from_secs(60)),
selection: Default::default(),
}; };
let halt = Halt::new(); let halt = Halt::new();
let out = mux_stream( let out = mux_stream(
@@ -1635,6 +1664,7 @@ mod tests {
batch_sectors: 3, // one aligned unit per read batch_sectors: 3, // one aligned unit per read
raw: false, raw: false,
send_deadline: Some(Duration::from_secs(60)), send_deadline: Some(Duration::from_secs(60)),
selection: Default::default(),
}; };
let halt = Halt::new(); let halt = Halt::new();
let out = mux_stream( let out = mux_stream(
@@ -1685,6 +1715,7 @@ mod tests {
batch_sectors: 3, batch_sectors: 3,
raw: false, raw: false,
send_deadline: Some(Duration::from_secs(60)), send_deadline: Some(Duration::from_secs(60)),
selection: Default::default(),
}; };
let halt = Halt::new(); let halt = Halt::new();
let err = mux_stream( let err = mux_stream(
@@ -1769,6 +1800,7 @@ mod tests {
batch_sectors: 3, batch_sectors: 3,
raw: false, raw: false,
send_deadline: Some(Duration::from_secs(60)), send_deadline: Some(Duration::from_secs(60)),
selection: Default::default(),
}; };
let halt = Halt::new(); let halt = Halt::new();
let err = mux_stream( let err = mux_stream(
@@ -2060,6 +2092,7 @@ mod tests {
batch_sectors: 0, batch_sectors: 0,
raw: false, raw: false,
send_deadline: None, send_deadline: None,
selection: Default::default(),
}; };
assert_eq!(effective_send_deadline(cli.send_deadline), NO_SEND_DEADLINE); assert_eq!(effective_send_deadline(cli.send_deadline), NO_SEND_DEADLINE);
let autorip = MuxOptions { let autorip = MuxOptions {
@@ -2067,6 +2100,7 @@ mod tests {
batch_sectors: 8192, batch_sectors: 8192,
raw: false, raw: false,
send_deadline: Some(Duration::from_secs(60)), send_deadline: Some(Duration::from_secs(60)),
selection: Default::default(),
}; };
assert_eq!( assert_eq!(
effective_send_deadline(autorip.send_deadline), effective_send_deadline(autorip.send_deadline),
+1
View File
@@ -28,6 +28,7 @@ pub mod disc;
pub mod driver; pub mod driver;
pub mod pipelined_stream; pub mod pipelined_stream;
pub mod resolve; pub mod resolve;
pub mod select;
// Internal-only modules. Every reference is via `crate::mux::…` / // Internal-only modules. Every reference is via `crate::mux::…` /
// `super::…` from inside the crate; nothing in the downstream crates or // `super::…` from inside the crate; nothing in the downstream crates or
+15
View File
@@ -330,6 +330,12 @@ pub struct InputOptions {
/// still-scrambled unit is re-tried via the application's key source. /// still-scrambled unit is re-tried via the application's key source.
/// Application seam only; the library makes no network call. /// Application seam only; the library makes no network call.
pub key_fetch: Option<crate::sector::KeyFetch>, pub key_fetch: Option<crate::sector::KeyFetch>,
/// Which audio/subtitle streams to keep. `input()` scans the source and
/// picks the title internally, so the caller can't prune the `DiscTitle`
/// itself — it passes the selection here and `input()` applies it right
/// after the title-index bounds check. Default keeps every stream (video is
/// always kept). See [`crate::StreamSelection`].
pub selection: crate::StreamSelection,
} }
// `KeyFetchFactory` holds a trait object that is not `Debug`; hand-roll the // `KeyFetchFactory` holds a trait object that is not `Debug`; hand-roll the
@@ -342,6 +348,7 @@ impl std::fmt::Debug for InputOptions {
.field("title_index", &self.title_index) .field("title_index", &self.title_index)
.field("raw", &self.raw) .field("raw", &self.raw)
.field("key_fetch", &self.key_fetch.is_some()) .field("key_fetch", &self.key_fetch.is_some())
.field("selection", &self.selection)
.finish() .finish()
} }
} }
@@ -403,6 +410,14 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
} }
.into()); .into());
} }
// Prune to the selected audio/subtitle streams now, on the scanned
// (pre-`probe_and_remap`) title, so everything downstream — the
// TrueHD channel-correction probe, the final title clone, and
// `build_iso_pipeline`'s demux/track construction — sees the pruned
// list. Video is always kept; a no-op for the default All/All.
opts.selection
.apply(&mut disc.titles[idx])
.map_err(|e| -> io::Error { e.into() })?;
// Per-title key resolution. DVD CSS is resolved at exactly ONE site — // Per-title key resolution. DVD CSS is resolved at exactly ONE site —
// `build_iso_pipeline`'s per-title crack (below), which decrypts a // `build_iso_pipeline`'s per-title crack (below), which decrypts a
// crackable title, passes a genuinely-clear one through, and // crackable title, passes a genuinely-clear one through, and
+299
View File
@@ -0,0 +1,299 @@
//! Per-title audio/subtitle stream selection — the pure primitive.
//!
//! The demux pipeline is **declaration-driven**: every demux table
//! (`build_demux_state` in `mux/resolve.rs`, `DiscStream::new` in `mux/disc.rs`)
//! is built from the input [`DiscTitle`]'s `streams` list, and the MKV writer
//! builds its track headers + `codec_privates` from that same list. A PID not
//! declared there is never tracked, extracted, or written. So "which streams to
//! keep" is already a capability of the pipeline — it just has no public knob.
//!
//! [`StreamSelection::apply`] is that knob: prune the `DiscTitle.streams` list
//! (video always kept) BEFORE the mux path finalizes the title, and everything
//! downstream — track headers, `codec_privates`, PID routing, frame emission —
//! follows from the pruned list by construction, with zero scattered PID
//! checks. This is language-agnostic: PIDs, not languages (the language→PID
//! mapping is the caller's/engine's policy).
use crate::disc::{DiscTitle, Stream};
use crate::error::{Error, Result};
/// Which PIDs to keep for one stream class (audio or subtitle). Video is always
/// kept, so it has no filter.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum PidFilter {
/// Keep every stream of this class. The default; [`StreamSelection::apply`]
/// is a no-op for an All/All selection, so the no-selection path is
/// byte-identical to no selection at all.
#[default]
All,
/// Keep only the streams whose PID is listed. `Only(vec![])` is legal and
/// means keep none (a video-only output when both classes are `Only([])`).
Only(Vec<u16>),
}
/// A per-title stream selection: which audio and which subtitle PIDs to keep.
/// Video is always retained (it is implicit and never pruned).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct StreamSelection {
pub audio: PidFilter,
pub subtitle: PidFilter,
}
impl StreamSelection {
/// True for the All/All default. Apply sites gate on `!is_all()` so the
/// no-selection path never even clones the title.
pub fn is_all(&self) -> bool {
matches!(self.audio, PidFilter::All) && matches!(self.subtitle, PidFilter::All)
}
/// Prune `title.streams` in place: keep every [`Stream::Video`]
/// unconditionally; keep an [`Stream::Audio`]/[`Stream::Subtitle`] iff its
/// PID passes the corresponding [`PidFilter`]; drop the rest. Declared order
/// is preserved. The parallel `codec_privates` vec is pruned in lockstep
/// when it is populated (it is empty on a freshly-scanned title, non-empty
/// only if a caller pre-filled it).
///
/// Errors [`Error::SelectionPidUnknown`] if a filter lists a PID that does
/// not exist in `title.streams` — a caller bug (e.g. a stale scan). Fail
/// loud rather than silently emit an MKV missing a requested track. On
/// error the title is left unmodified.
pub fn apply(&self, title: &mut DiscTitle) -> Result<()> {
if self.is_all() {
return Ok(());
}
// Validate every listed PID exists in the title before mutating, so an
// unknown PID leaves the title untouched (no partial prune).
for pid in self.listed_pids() {
let present = title.streams.iter().any(|s| stream_pid(s) == Some(pid));
if !present {
return Err(Error::SelectionPidUnknown { pid });
}
}
let codec_privates_aligned = title.codec_privates.len() == title.streams.len();
// Retain by index so we can prune the parallel codec_privates in lockstep.
let keep: Vec<bool> = title
.streams
.iter()
.map(|s| self.keeps(s))
.collect::<Vec<_>>();
let mut i = 0;
title.streams.retain(|_| {
let k = keep[i];
i += 1;
k
});
if codec_privates_aligned {
let mut j = 0;
title.codec_privates.retain(|_| {
let k = keep[j];
j += 1;
k
});
}
Ok(())
}
/// Whether this selection keeps `stream`.
fn keeps(&self, stream: &Stream) -> bool {
match stream {
Stream::Video(_) => true,
Stream::Audio(a) => filter_keeps(&self.audio, a.pid),
Stream::Subtitle(s) => filter_keeps(&self.subtitle, s.pid),
}
}
/// Every PID explicitly listed across both filters (for existence checking).
fn listed_pids(&self) -> Vec<u16> {
let mut v = Vec::new();
if let PidFilter::Only(pids) = &self.audio {
v.extend_from_slice(pids);
}
if let PidFilter::Only(pids) = &self.subtitle {
v.extend_from_slice(pids);
}
v
}
}
fn filter_keeps(filter: &PidFilter, pid: u16) -> bool {
match filter {
PidFilter::All => true,
PidFilter::Only(pids) => pids.contains(&pid),
}
}
/// The PID of an audio/subtitle stream; `None` for video (which is never
/// filtered, so its PID is irrelevant to selection).
fn stream_pid(stream: &Stream) -> Option<u16> {
match stream {
Stream::Audio(a) => Some(a.pid),
Stream::Subtitle(s) => Some(s.pid),
Stream::Video(_) => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::disc::{
AudioChannels, AudioStream, Codec, ColorSpace, FrameRate, HdrFormat, LabelPurpose,
LabelQualifier, Resolution, SampleRate, SubtitleStream, VideoStream,
};
fn video(pid: u16) -> Stream {
Stream::Video(VideoStream {
pid,
codec: Codec::Hevc,
resolution: Resolution::R2160p,
frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Hdr10,
color_space: ColorSpace::Bt2020,
display_aspect: None,
secondary: false,
label: String::new(),
measured_cicp: None,
})
}
fn audio(pid: u16, lang: &str) -> Stream {
Stream::Audio(AudioStream {
pid,
codec: Codec::TrueHd,
channels: AudioChannels::Stereo,
language: lang.into(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: LabelPurpose::Normal,
label: String::new(),
})
}
fn subtitle(pid: u16, lang: &str) -> Stream {
Stream::Subtitle(SubtitleStream {
pid,
codec: Codec::Pgs,
language: lang.into(),
forced: false,
qualifier: LabelQualifier::None,
codec_data: None,
})
}
// video + 3 audio (eng/spa/fra) + 2 subs (eng/spa).
fn title() -> DiscTitle {
let mut t = DiscTitle::empty();
t.streams = vec![
video(0x1011),
audio(0x1100, "eng"),
audio(0x1101, "spa"),
audio(0x1102, "fra"),
subtitle(0x1200, "eng"),
subtitle(0x1201, "spa"),
];
t
}
fn pids(t: &DiscTitle) -> Vec<u16> {
t.streams
.iter()
.filter_map(|s| match s {
Stream::Video(v) => Some(v.pid),
Stream::Audio(a) => Some(a.pid),
Stream::Subtitle(s) => Some(s.pid),
})
.collect()
}
#[test]
fn apply_all_is_identity_and_untouched() {
let sel = StreamSelection::default();
assert!(sel.is_all());
let mut t = title();
let before = pids(&t);
sel.apply(&mut t).unwrap();
assert_eq!(pids(&t), before, "All/All must not change the stream list");
}
#[test]
fn apply_only_retains_listed_audio_pids_in_declared_order() {
// Keep eng+fra audio (skip spa); leave subtitles alone.
let sel = StreamSelection {
audio: PidFilter::Only(vec![0x1100, 0x1102]),
subtitle: PidFilter::All,
};
let mut t = title();
sel.apply(&mut t).unwrap();
assert_eq!(
pids(&t),
vec![0x1011, 0x1100, 0x1102, 0x1200, 0x1201],
"video + eng/fra audio (order preserved) + both subs"
);
}
#[test]
fn apply_only_empty_yields_video_only() {
let sel = StreamSelection {
audio: PidFilter::Only(vec![]),
subtitle: PidFilter::Only(vec![]),
};
let mut t = title();
sel.apply(&mut t).unwrap();
assert_eq!(pids(&t), vec![0x1011], "only the video stream survives");
}
#[test]
fn apply_subtitle_filter_does_not_touch_audio() {
let sel = StreamSelection {
audio: PidFilter::All,
subtitle: PidFilter::Only(vec![0x1200]),
};
let mut t = title();
sel.apply(&mut t).unwrap();
assert_eq!(
pids(&t),
vec![0x1011, 0x1100, 0x1101, 0x1102, 0x1200],
"all audio kept, only eng subtitle kept"
);
}
#[test]
fn apply_unknown_pid_errors_and_leaves_title_untouched() {
let sel = StreamSelection {
audio: PidFilter::Only(vec![0x9999]),
subtitle: PidFilter::All,
};
let mut t = title();
let before = pids(&t);
let err = sel.apply(&mut t).unwrap_err();
assert!(matches!(err, Error::SelectionPidUnknown { pid: 0x9999 }));
assert_eq!(pids(&t), before, "title unmodified on error");
}
#[test]
fn apply_prunes_codec_privates_in_lockstep_when_populated() {
// A caller that pre-filled codec_privates parallel to streams: pruning
// must keep the two vecs aligned.
let mut t = title();
t.codec_privates = vec![
Some(vec![0xAA]), // video 0x1011
Some(vec![0x11]), // audio 0x1100 eng
Some(vec![0x22]), // audio 0x1101 spa
Some(vec![0x33]), // audio 0x1102 fra
None, // sub 0x1200
None, // sub 0x1201
];
let sel = StreamSelection {
audio: PidFilter::Only(vec![0x1100]),
subtitle: PidFilter::Only(vec![]),
};
sel.apply(&mut t).unwrap();
assert_eq!(pids(&t), vec![0x1011, 0x1100]);
assert_eq!(
t.codec_privates,
vec![Some(vec![0xAA]), Some(vec![0x11])],
"codec_privates pruned to match the retained streams, in order"
);
}
}