diff --git a/docs/rip-recovery.md b/docs/rip-recovery.md index de0291c..5597a6b 100644 --- a/docs/rip-recovery.md +++ b/docs/rip-recovery.md @@ -19,10 +19,11 @@ reach into the others. 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 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 -terminal-output progress sink. Layer 3 runs inside any consumer of -`DiscStream` (direct PES pipeline, ISO playback, etc.) without caller -involvement. +the mux pipeline. The `freemkv` CLI does the same shape, but as of 1.6.0 the +loop itself (including the multi-title rip loop) lives one layer up, in the +shared `freemkv-engine` crate, with a terminal-output progress sink plugged +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: diff --git a/src/error.rs b/src/error.rs index 4f06d44..a0b8806 100644 --- a/src/error.rs +++ b/src/error.rs @@ -59,6 +59,7 @@ pub const E_MKV_INVALID: u16 = 6008; pub const E_NO_STREAMS: u16 = 6009; pub const E_HALTED: u16 = 6010; 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_NOT_FILESYSTEM: u16 = 6013; @@ -292,6 +293,12 @@ pub enum Error { IfoParse, MkvInvalid, 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 /// identifier (e.g. `"status_char"`, `"hex"`); not a translatable /// English message. @@ -579,6 +586,7 @@ impl Error { Error::IfoParse => E_IFO_PARSE, Error::MkvInvalid => E_MKV_INVALID, Error::NoStreams => E_NO_STREAMS, + Error::SelectionPidUnknown { .. } => E_SELECTION_PID_UNKNOWN, Error::MapfileInvalid { .. } => E_MAPFILE_INVALID, Error::AacsNoKeys => E_AACS_NO_KEYS, Error::AacsCertShort => E_AACS_CERT_SHORT, @@ -786,6 +794,9 @@ impl std::fmt::Display for Error { Error::InvalidCdbLength { 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()), } } diff --git a/src/lib.rs b/src/lib.rs index 96bae02..874426d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -272,6 +272,7 @@ pub use mux::{Mp4FitReport, Mp4SkipReason, mp4_fit_report}; // `SectorSource` to get plaintext sectors out. pub use mux::build_iso_pipeline; 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 scsi::{DriveInfo, ScsiSense, ScsiTransport, SenseFamily, drive_has_disc, list_drives}; pub use sector::{ diff --git a/src/mux/driver.rs b/src/mux/driver.rs index e8d0239..110778b 100644 --- a/src/mux/driver.rs +++ b/src/mux/driver.rs @@ -153,6 +153,11 @@ pub enum MuxInput<'a> { } /// 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 { /// Skip past read errors (zero-fill + continue) on the live-drive path /// instead of aborting. Wired onto `DiscStream::skip_errors`. @@ -161,6 +166,11 @@ pub struct MuxOptions { pub batch_sectors: u16, /// Ciphertext passthrough — skip decryption / CSS self-crack. 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. /// /// - `Some(d)` — a hard `d` timeout: a sink that back-pressures a single @@ -278,6 +288,14 @@ pub fn mux_stream( keys, 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 stream = build_iso_pipeline( reader, @@ -299,7 +317,7 @@ pub fn mux_stream( // Pull everything we need out of the disc as owned values so the // immutable disc borrow is released before the mutable // `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 { path: session.device_path().to_string(), })?; @@ -319,6 +337,14 @@ pub fn mux_stream( // (see `session_mux_keys`), never the whole-disc `decrypt_keys()`. (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 // clean error, not a panic (contract Q2). let mut reader = session.take_reader().ok_or_else(|| Error::DeviceNotReady { @@ -1308,6 +1334,7 @@ mod tests { batch_sectors: 8192, raw: false, send_deadline: Some(Duration::from_secs(60)), + selection: Default::default(), }; let halt = Halt::new(); let out = mux_stream( @@ -1416,6 +1443,7 @@ mod tests { batch_sectors: us as u16, raw: false, send_deadline: Some(Duration::from_secs(60)), + selection: Default::default(), }; let halt = Halt::new(); // 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 raw: false, send_deadline: Some(Duration::from_secs(60)), + selection: Default::default(), }; let halt = Halt::new(); let out = mux_stream( @@ -1635,6 +1664,7 @@ mod tests { batch_sectors: 3, // one aligned unit per read raw: false, send_deadline: Some(Duration::from_secs(60)), + selection: Default::default(), }; let halt = Halt::new(); let out = mux_stream( @@ -1685,6 +1715,7 @@ mod tests { batch_sectors: 3, raw: false, send_deadline: Some(Duration::from_secs(60)), + selection: Default::default(), }; let halt = Halt::new(); let err = mux_stream( @@ -1769,6 +1800,7 @@ mod tests { batch_sectors: 3, raw: false, send_deadline: Some(Duration::from_secs(60)), + selection: Default::default(), }; let halt = Halt::new(); let err = mux_stream( @@ -2060,6 +2092,7 @@ mod tests { batch_sectors: 0, raw: false, send_deadline: None, + selection: Default::default(), }; assert_eq!(effective_send_deadline(cli.send_deadline), NO_SEND_DEADLINE); let autorip = MuxOptions { @@ -2067,6 +2100,7 @@ mod tests { batch_sectors: 8192, raw: false, send_deadline: Some(Duration::from_secs(60)), + selection: Default::default(), }; assert_eq!( effective_send_deadline(autorip.send_deadline), diff --git a/src/mux/mod.rs b/src/mux/mod.rs index b460604..967dca5 100644 --- a/src/mux/mod.rs +++ b/src/mux/mod.rs @@ -28,6 +28,7 @@ pub mod disc; pub mod driver; pub mod pipelined_stream; pub mod resolve; +pub mod select; // Internal-only modules. Every reference is via `crate::mux::…` / // `super::…` from inside the crate; nothing in the downstream crates or diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 1d989f2..feace1c 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -330,6 +330,12 @@ pub struct InputOptions { /// still-scrambled unit is re-tried via the application's key source. /// Application seam only; the library makes no network call. pub key_fetch: Option, + /// 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 @@ -342,6 +348,7 @@ impl std::fmt::Debug for InputOptions { .field("title_index", &self.title_index) .field("raw", &self.raw) .field("key_fetch", &self.key_fetch.is_some()) + .field("selection", &self.selection) .finish() } } @@ -403,6 +410,14 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result io::Error { e.into() })?; // Per-title key resolution. DVD CSS is resolved at exactly ONE site — // `build_iso_pipeline`'s per-title crack (below), which decrypts a // crackable title, passes a genuinely-clear one through, and diff --git a/src/mux/select.rs b/src/mux/select.rs new file mode 100644 index 0000000..8191232 --- /dev/null +++ b/src/mux/select.rs @@ -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), +} + +/// 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 = title + .streams + .iter() + .map(|s| self.keeps(s)) + .collect::>(); + + 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 { + 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 { + 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 { + 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" + ); + } +}