source: open_source opens every input incl. disc://; MuxSource replaced by Source; one image probe; disc-named output titles

This commit is contained in:
Matthew Jackson
2026-09-30 21:28:56 -07:00
parent 1f5f5df8cb
commit ed02bc1392
14 changed files with 737 additions and 611 deletions
+3 -1
View File
@@ -5,9 +5,10 @@
### Breaking
- **AACS keys:** `keys::KeyRing` is the only door to AACS keys. Removed: crate-root `AacsKeyMap`, `DecryptKeys`, `resolve_and_apply`, `KeyFetch`; `resolve_and_apply_traced`, `keysource::{fetch_unit_keys, fetch_fmts_indexes, key_fetch}`, `sector::{KeyFetch, KeyFetchFn}`, `session::{resolve_keys_for, ResolvedKeys}`, `DiscSession::{resolve_keys, key_fetch}`, `Disc::{decrypt_keys, decrypt_keys_for_title, decrypt_with, ensure_decryptable, ensure_decryptable_keys, ensure_title_decryptable}`, `InputOptions::{unit_keys, key_fetch}`, `DiscStream::with_key_map`. `DecryptKeys` is `#[non_exhaustive]`; `AacsKeyMap::from_ranges*`, `DecryptingSectorSource::{with_key_map, set_key_map}` and `aacs::content::{decrypt_unit, decrypt_bus}` are no longer public. Acquire once with `DiscSession::resolve_key_set` / `KeyRing::acquire`.
- **Mux entry point:** `mux_stream` and `MuxInput` are replaced by `mux_with_keys(MuxSource, Option<&KeyRing>, dest_url, opts, ctx)`; `MuxSource` (`Session`, `Iso`, `Live`, `Url`) carries no key material. `InputOptions::keys: Option<KeyRing>` replaces `unit_keys` / `key_fetch`; an AACS image opened without a set is refused before any output. Crate-root `build_iso_pipeline` and `resolve_mux_key_map` are removed.
- **Mux entry point:** `mux_stream` and `MuxInput` are replaced by `mux_with_keys(Source, Option<&KeyRing>, dest_url, opts, ctx)`; a `Source` carries no key material. `InputOptions::keys: Option<KeyRing>` replaces `unit_keys` / `key_fetch`; an AACS image opened without a set is refused before any output. Crate-root `build_iso_pipeline` and `resolve_mux_key_map` are removed.
- **One run context for every stage:** `Ctx { halt, events, stats, diag }` is passed to `input(url, opts, ctx)`, `mux_with_keys(.., ctx)`, `Disc::extract_tree(reader, dest, opts, ctx)`, `write_image(reader, dest, sectors, ctx)`, `DiscStream::new(.., ctx)` and `PrefetchedSectorSource::new(.., ctx)`. One `Events` trait over one `Event` enum (`BytesRead`, `SectorSkipped`, `UnitBlanked`, `BatchSizeChanged`, `Pass`, `BytesWritten`, `BytesDurable`, `OutputOpened`) replaces `MuxEvents`, `event::{Event, EventKind}`, `progress::Progress`, `EventFn` / `PrefetchedSectorSource::new_with_events`, `DiscStream::{on_event, with_halt}` and `Drive::on_event`; `ExtractOptions::{halt, progress}` are removed (Stop is `ctx.halt`; extract progress is `PassKind::Extract`). `Stats` / `LossReport` count read skips, lost bytes, blanked units and resync drops across the run. `halt::Progress` is renamed `halt::Liveness`. `FREEMKV_SKIP_PARSE` / `FREEMKV_PROFILE` take effect only through `Diag::from_env()` on the caller's `Ctx`.
- **Key acquisition is not bound to a scanned disc:** `ResolvedKeySet` is renamed `KeyRing` and `ResolveKeysOptions` `AcquireOptions` (its `halt` is the run's `Ctx`; `resolve_with_progress` is `AcquireOptions::liveness`). `KeyRing::acquire(&KeyEvidence, &mut Sampler, sources, opts, ctx)` takes what a source can tell (`KeyEvidence`: `MediaId`, AACS structures, `Piece`s in scope, main title, `Detector`), and `KeyRing::acquire_for_disc(disc, reader, scope, sources, opts, ctx)` builds that evidence from a scanned disc (`KeyEvidence::from_disc`). `KeyRing::is_for` takes a `&MediaId` (`Disc::media_id()`), which includes the Volume ID fingerprint. Evidence with no disc hash derives it from the title-key file, so a keydb lookup no longer misses silently. The legacy `aacs::resolve` / `aacs::provider` ladder and `disc::Key` are removed; `KeyOrigin` lives in `aacs::trace` (still re-exported at the crate root).
- **One way to open any input:** `open_source(url, ScanOptions, ctx)` opens every input scheme, `disc://` included (the drive is brought up and scanned; `input("disc://…")` now opens it instead of returning `DiscUrlNotDirect`), and `Source::layout()` is the probe's scanned `Disc` for `disc://`, `iso://` and `dir://`. `MuxSource` is removed: `Source::from_session(&mut DiscSession)`, `Source::from_image(path, ScannedTitle)` (a title scanned elsewhere, never rescanned) and `Source::from_reader(reader, ScannedTitle)` replace `Session`, `Iso` and `Live`, and `mux_url(url, keys, dest, opts, ctx)` replaces `Url`. `MuxOptions` gains `title_index` (it replaces `MuxSource::Session::title_index` and `InputOptions::title_index` for a mux), and `MuxOptions::selection` applies to every source. `scan_iso` / `scan_dir` share the one image probe.
- **Stop is the only send bound:** `MuxOptions::send_deadline`, `NO_SEND_DEADLINE` and `halt::POLL_INTERVAL` are removed (use `halt::WAIT_SLICE`); a slow sink blocks the mux until it drains or Stop.
- `Drive::clear_halt`, `Drive::scsi_mut` and `Drive::is_unlocked` and the path-based `platform::fs_type::detect` are removed; use `Drive::attach` / `detach` / `halt` / `token` and `DiscSession::source_mut`.
- `DvdAudioAttr::sub_stream_id` (positional id) is removed.
@@ -29,6 +30,7 @@
- **`json://`** audio `sample_rate` is a number in Hz (`null` when unknown), not a string such as `"48kHz"`; a `sample_rates` array lists every rate a stream carries.
- **FMKV (`network://`, `stdio://`) header version 2** is used when a track carries decoder timing (Opus CodecDelay/SeekPreRoll) and adds per-frame DiscardPadding; streams without timing stay version 1. Headers now also carry display aspect, CICP, stream purpose/qualifier, chapters and content format, and PIDs actually written by M2TS. A header declaring more than 256 streams is refused with E9008. An older receiver omits the MKV Channels element for the new channel-layout strings; keep both ends on one version.
- **`dir://` image bytes:** the PVD declares interchange level 3, Unique IDs start at 16 and the volume id round-trips through the d-string encoder; `dir://` sources are stamped as images in the `fvi://` provenance header.
- **One output title name:** a title read from a disc layout (`disc://`, `iso://`, `dir://`, a staged image, a server live rip) is named by the disc's meta title (else its volume id) in every container's title field and in `fvi://` provenance; 1.7.7 named image and server-live rips by the MPLS playlist file. `fvi://` provenance of an `iso://`/`dir://` source now carries the volume id.
- **Frames dropped by the resync gate count as errors** in `MuxOutcome::errors` / `lost_bytes`, together with AACS units blanked as damaged.
- **A failed or stopped `network://` / `stdio://` sender resets the connection** (`Stream::finish_incomplete`), so the receiver errors instead of finishing a truncated title.
- **Key-service 404 / 422 answers are E7022** (no key opens the title), and a key-service outage or unreachable source is a source failure, never "no key for this disc".
+1 -5
View File
@@ -750,12 +750,8 @@ fn a_dir_url_mux_reports_read_progress_to_the_ctx() {
}
}));
let url = format!("dir://{}", s.path().display());
let src = crate::MuxSource::Url {
url: &url,
opts: crate::InputOptions::default(),
};
// The fixture's TS carries no PES, so the drain refuses as NoStreams after reading it all.
let _ = crate::mux_with_keys(src, None, "null://", &crate::MuxOptions::default(), &ctx);
let _ = crate::mux_url(&url, None, "null://", &crate::MuxOptions::default(), &ctx);
assert_eq!(
read.load(std::sync::atomic::Ordering::Relaxed),
4096 * 192,
+4 -5
View File
@@ -1904,11 +1904,10 @@ fn resolve_retains_no_source_and_nothing_asks_after() {
measured_cicp: None,
}));
let r = crate::mux::mux_with_keys(
crate::mux::MuxSource::Live {
reader: Box::new(src.clone()),
title,
format: ContentFormat::BdTs,
},
crate::mux::Source::from_reader(
Box::new(src.clone()),
crate::mux::ScannedTitle::new(title, ContentFormat::BdTs),
),
Some(&set),
"null://",
&crate::mux::MuxOptions {
+4 -11
View File
@@ -4,9 +4,10 @@
//! see [`Golden`](crate::test_util::Golden) for the re-bless command.
use super::*;
use crate::mux::driver::{MuxOptions, MuxSource, mux_with_keys};
use crate::mux::driver::{MuxOptions, mux_with_keys};
use crate::mux::parity_tests::{golden, record_run, record_tree};
use crate::mux::select::{PidFilter, StreamSelection};
use crate::mux::source::{ScannedTitle, Source};
use crate::test_util::{CLIP_AUDIO_PIDS, Golden, synthetic_bd_clip};
fn refused<T>(g: &mut Golden, key: &str, r: &Result<T>) -> bool {
@@ -139,17 +140,9 @@ fn mux_cells(g: &mut Golden, fx: &Fx, set: Option<&KeyRing>, dead: Option<(u32,
if let Some((a, b)) = dead {
src.kill(a, b);
}
MuxSource::Live {
reader: Box::new(src),
title: title.clone(),
format,
}
Source::from_reader(Box::new(src), ScannedTitle::new(title.clone(), format))
} else {
MuxSource::Iso {
path: &iso,
title: title.clone(),
format,
}
Source::from_image(&iso, ScannedTitle::new(title.clone(), format))
};
let r = mux_with_keys(source, set, &dest, opts, &crate::ctx::Ctx::default());
record_run(g, tag, &r);
+4 -9
View File
@@ -6,8 +6,9 @@ use super::*;
use crate::dirimage::tests::{minimal_clpi, one_item_mpls};
use crate::disc::ScanOptions;
use crate::drive::Drive;
use crate::mux::driver::{MuxOptions, MuxSource, mux_with_keys};
use crate::mux::driver::{MuxOptions, mux_with_keys};
use crate::mux::parity_tests::{golden, record_run, record_tree};
use crate::mux::source::Source;
use crate::session::{DiscSession, KeySpec};
use crate::test_util::{FakeMode, FakeTransport, Golden, synthetic_bd_clip};
@@ -79,10 +80,7 @@ fn mux(
let dir = tempfile::tempdir().unwrap();
let dest = format!("mkv://{}", dir.path().join("o.mkv").display());
let r = mux_with_keys(
MuxSource::Session {
session: s,
title_index: 0,
},
Source::from_session(s),
set,
&dest,
opts,
@@ -231,10 +229,7 @@ fn parity_live_bd_stop_mid_read() {
armed.store(true, std::sync::atomic::Ordering::Relaxed);
let dir = tempfile::tempdir().unwrap();
let r = mux_with_keys(
MuxSource::Session {
session: &mut s,
title_index: 0,
},
Source::from_session(&mut s),
Some(&set),
&format!("mkv://{}", dir.path().join("o.mkv").display()),
&MuxOptions {
+1 -1
View File
@@ -254,7 +254,7 @@ pub use mux::{Mp4FitReport, Mp4Sink, Mp4SkipReason, mp4_fit_report};
#[cfg(feature = "rip")]
pub use mux::select::{PidFilter, StreamSelection};
#[cfg(feature = "rip")]
pub use mux::{MuxOptions, MuxOutcome, MuxSource, mux_with_keys};
pub use mux::{MuxOptions, MuxOutcome, ScannedTitle, Source, mux_url, mux_with_keys, open_source};
pub use scsi::{
DiscPresence, DriveInfo, ScsiSense, ScsiTransport, SenseFamily, disc_presence, drive_has_disc,
list_drives,
+295 -457
View File
@@ -5,7 +5,6 @@
//! [`mux_with_keys`] DRIVES the existing pipeline via the same ISO pipeline builders
//! and a [`WRITE_PIPELINE_DEPTH`]-deep write [`Pipeline`]; it does not replace either.
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::Duration;
@@ -22,14 +21,15 @@ use crate::pes::{CountingStream, PesFrame, Stream};
use crate::sector::{FileSectorSource, SectorSource};
use crate::session::DiscSession;
use super::resolve::{
InputOptions, StreamUrl, build_iso_pipeline, input, output, output_with, parse_url,
};
use super::resolve::{InputOptions, StreamUrl, build_iso_pipeline, output, output_with, parse_url};
#[cfg(test)]
use super::source::ScannedTitle;
use super::source::{Origin, Source};
use super::videomap::{Medium, SourceInfo};
// The source medium a parsed input URL denotes, used only for provenance. Exhaustive, so a
// new scheme must be placed; sink-only schemes never reach here and take the `File` default.
fn url_medium(parsed: &StreamUrl) -> Medium {
pub(crate) fn url_medium(parsed: &StreamUrl) -> Medium {
match parsed {
StreamUrl::Disc { .. } => Medium::Disc,
// `dir://` is an image-level source: a UDF volume synthesized over the folder.
@@ -60,7 +60,7 @@ pub(crate) const HEADER_BUFFER_CAP_BYTES: usize = 512 * 1024 * 1024;
/// `Default` = keep-everything, no-skip, decrypt — the archival default. Added
/// so callers set only the fields they care about (and so an additive field
/// doesn't churn every constructor).
#[derive(Default)]
#[derive(Default, Clone)]
pub struct MuxOptions {
/// Skip past read errors (zero-fill + continue) on the live-drive path
/// instead of aborting. Wired onto `DiscStream::skip_errors`.
@@ -74,11 +74,11 @@ pub struct MuxOptions {
/// demux pipeline is built, so track headers, `codec_privates`, and frame
/// routing all follow the pruned list. See [`crate::StreamSelection`].
///
/// Applies to the `Iso`, `Session` and `Live` inputs. It does NOT apply to
/// [`MuxSource::Url`], which builds its demux inside `input()` — a Url-source
/// caller sets `InputOptions::selection` instead. Setting this field for a Url
/// input has no effect.
/// Applies to every [`Source`].
pub selection: crate::StreamSelection,
/// 0-based index of the title to mux in the source's layout (a container source has
/// one title, 0). Out of range is [`Error::MuxTrackRange`].
pub title_index: usize,
}
/// The result of a [`mux_with_keys`] run.
@@ -128,232 +128,17 @@ impl MuxOutcome {
}
}
// The mux of a source with no AACS key set (clear, CSS, `raw`, or a URL that scans):
// construct the source stream, open the `dest_url` sink and pump it (`drive_mux`).
fn mux_unkeyed(
input_src: MuxSource,
dest_url: &str,
opts: &MuxOptions,
ctx: &Ctx,
) -> std::io::Result<MuxOutcome> {
// Construct the source stream (ISO/live/URL) under `ctx`. Each arm also derives
// SourceInfo since `output()` has no access to source.
let (stream, playlist_name, mut source): (Box<dyn Stream>, Option<String>, SourceInfo) =
match input_src {
// The Url path builds its demux INSIDE `input()`, pruned via
// `InputOptions.selection`. `MuxOptions.selection` does NOT apply
// here — that's the File/Session arms' field.
MuxSource::Url { url, opts: in_opts } => {
// Provenance: source URL verbatim, scheme's medium, and the title
// `input()` will open. `playlist` fills in below from the opened
// stream's scanned title — not known until the scan runs.
let source = SourceInfo {
medium: url_medium(&parse_url(url)),
path: url.to_string(),
title: in_opts.title_index.unwrap_or(0),
..SourceInfo::default()
};
let stream = input(url, &in_opts, ctx)?;
let source = SourceInfo {
playlist: stream.info().playlist.clone(),
..source
};
(stream, None, source)
}
MuxSource::Iso {
path,
title,
format,
} => {
// Prune to selected audio/subtitle streams BEFORE the highway
// builds demux state (and before the DVD AC-3 channel probe).
// Video is always kept; no-op for default All/All.
let mut title = title;
opts.selection
.apply(&mut title)
.map_err(std::io::Error::from)?;
// Provenance: staged image as an `iso://` URL, plus playlist. Title
// INDEX is NOT reachable here — `MuxSource::Iso` carries an already-
// scanned `DiscTitle` with no index, so it stays 0 rather than guessed.
let source = SourceInfo {
medium: Medium::Iso,
path: format!("iso://{}", path.display()),
playlist: title.playlist.clone(),
..SourceInfo::default()
};
let reader = FileSectorSource::open(path)?;
let stream = build_iso_pipeline(
reader,
title,
DecryptKeys::None,
opts.batch_sectors,
format,
opts.raw,
ctx,
)?;
(Box::new(stream), None, source)
}
MuxSource::Session {
session,
title_index,
} => {
// 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 (mut title, format, keys, playlist, source) = {
let disc = session.disc().ok_or_else(|| Error::DeviceNotReady {
path: session.device_path().to_string(),
})?;
let title =
disc.titles
.get(title_index)
.cloned()
.ok_or(Error::MuxTrackRange {
track: title_index,
tracks: disc.titles.len(),
})?;
let playlist = disc
.meta_title
.clone()
.unwrap_or_else(|| disc.volume_id.clone());
// Provenance: every member is reachable on this arm — the device
// the session is bound to, the title index the caller named, the
// title's own playlist, and the scanned volume id.
let source = SourceInfo {
medium: Medium::Disc,
path: format!("disc://{}", session.device_path()),
title: title_index,
playlist: title.playlist.clone(),
volume_id: disc.volume_id.clone(),
};
// DVD CSS is per-VTS: resolve the per-title key via the pipeline
// (see `session_mux_keys`), never the whole-disc `decrypt_keys()`.
(
title,
disc.content_format,
session_mux_keys(disc),
playlist,
source,
)
};
// Prune to selected streams before `DiscStream::new` builds demux tables.
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 reader = session.take_reader().ok_or_else(|| Error::DeviceNotReady {
path: session.device_path().to_string(),
})?;
let mut stream = crate::mux::DiscStream::new(
reader,
title,
keys,
opts.batch_sectors,
format,
opts.raw,
ctx,
)?;
if opts.raw {
stream.set_raw();
}
stream.skip_errors = opts.skip_errors;
(Box::new(stream), Some(playlist), source)
}
MuxSource::Live {
reader,
title,
format,
} => {
// Prune to selected streams, exactly as the Iso/Session arms do —
// without this, selection was silently ignored on the live-drive
// path despite the field's doc saying it was applied.
let mut title = title;
opts.selection
.apply(&mut title)
.map_err(std::io::Error::from)?;
// Provenance: medium is certain, playlist is on hand. Device PATH
// and title INDEX are NOT reachable — `MuxSource::Live` hands an
// opaque reader/title with neither, so both stay empty/0.
let source = SourceInfo {
medium: Medium::Disc,
playlist: title.playlist.clone(),
..SourceInfo::default()
};
// INLINE `DiscStream`, same constructor as the `Session` arm, NOT
// `build_iso_pipeline` — the highway would bypass the adaptive
// batch-retry that lives in `DiscStream::fill_extents`.
let mut stream = crate::mux::DiscStream::new(
reader,
title,
DecryptKeys::None,
opts.batch_sectors,
format,
opts.raw,
ctx,
)?;
if opts.raw {
stream.set_raw();
}
stream.skip_errors = opts.skip_errors;
(Box::new(stream), None, source)
}
};
// The consumer-supplied disc name overrides the title's own playlist for the
// muxed title (see `drive_mux`); mirror that into the provenance so a
// `fvi://` header and its sibling MKV agree on what the playlist was called.
if let Some(name) = playlist_name.as_deref() {
source.playlist = name.to_string();
}
drive_mux(
stream,
dest_url,
ctx,
playlist_name.as_deref(),
Some(&source),
)
}
/// Where [`mux_with_keys`] reads its PES frames from (KU §3.1). No variant carries key
/// material: keys come only from the rip's [`KeyRing`](crate::keys::KeyRing).
pub enum MuxSource<'a> {
/// Live single-pass mux off an opened [`DiscSession`] (its reader staged).
Session {
session: &'a mut DiscSession,
/// Index into `session.disc().titles`.
title_index: usize,
},
/// An image file and a title the caller ALREADY scanned (the drive scan, J14): the
/// image is never rescanned, so a sweep's unreadable UDF/MPLS/AACS sectors do not
/// matter. The set must cover the title and be for a sector-exact image of its disc.
Iso {
path: &'a Path,
title: DiscTitle,
format: crate::disc::ContentFormat,
},
/// Live single-pass mux off a raw disc reader (the inline `DiscStream`).
Live {
reader: Box<dyn SectorSource>,
title: DiscTitle,
format: crate::disc::ContentFormat,
},
/// Any URL-addressed source; the only variant that scans. `keys` goes into
/// [`InputOptions::keys`].
Url { url: &'a str, opts: InputOptions },
}
/// Run the decrypt + mux pipeline end-to-end with the rip's up-front key set (KU §3.1):
/// every AACS read goes through the set's readers (its map, and the on-arrival proof for
/// pieces it left unproven), with no key lookup. `keys == None`, a non-AACS set, or
/// `opts.raw`: no AACS decryption (CSS and clear discs decrypt as before). E7013 when the
/// set is not for the disc or does not cover the title; E7026 when the title needs
/// forensic keys that are Pending. Unresolved codec headers are [`Error::MkvInvalid`], a
/// zero-output drain [`Error::NoStreams`]. Every stage runs under `ctx`; a Stop of its halt,
/// during the open or the pump alike, yields `completed = false, halted = true`, not an error.
/// Run the decrypt + mux pipeline end-to-end over `source` with the rip's up-front key set
/// (KU §3.1): every AACS read goes through the set's readers (its map, and the on-arrival
/// proof for pieces it left unproven), with no key lookup. `keys == None`, a non-AACS set, or
/// `opts.raw`: no AACS decryption (CSS and clear discs decrypt as before; a BD-TS disc with
/// no set refuses its first flagged unit, E7022). E7013 when the set is not for the disc or
/// does not cover the title; E7026 when the title needs forensic keys that are Pending.
/// Unresolved codec headers are [`Error::MkvInvalid`], a zero-output drain
/// [`Error::NoStreams`]. Every stage runs under `ctx`; a Stop of its halt, during the open
/// or the pump alike, yields `completed = false, halted = true`, not an error.
pub fn mux_with_keys(
source: MuxSource,
source: Source,
keys: Option<&crate::keys::KeyRing>,
dest_url: &str,
opts: &MuxOptions,
@@ -367,154 +152,244 @@ pub fn mux_with_keys(
}
}
// `mux_with_keys` before its one halt rule: picks the keyed or unkeyed arms.
fn mux_routed(
source: MuxSource,
/// [`open_source`](super::source::open_source) then [`mux_with_keys`]: mux title
/// `opts.title_index` of the input `url`. A Stop during the open is a stopped outcome, as
/// during the pump.
pub fn mux_url(
url: &str,
keys: Option<&crate::keys::KeyRing>,
dest_url: &str,
opts: &MuxOptions,
ctx: &Ctx,
) -> std::io::Result<MuxOutcome> {
let set = keys.filter(|s| s.is_aacs() && !opts.raw);
// KU §3.1: "`keys` must be `Some` for AACS". A BD-TS Iso/Live mux with no AACS set reads
// through a keyless set: the first AACS-flagged unit is E7022, never muxed as content.
// MPEG-PS keeps the old path (a DVD cracks its own CSS key; `MuxSource` has no disc).
let keyless = match (&source, set) {
(MuxSource::Iso { title, format, .. } | MuxSource::Live { title, format, .. }, None)
if !opts.raw && *format == crate::disc::ContentFormat::BdTs =>
{
Some(crate::keys::KeyRing::keyless_for(title, *format))
let source = match super::source::open_source(url, crate::disc::ScanOptions::default(), ctx) {
Ok(s) => s,
Err(Error::Halted) if ctx.halt.is_cancelled() => {
return Ok(MuxOutcome::stopped_before_output(0, 0));
}
// A Session over an AACS disc with no set: BD-TS as above; HD DVD is never
// probed (KU §2.6), so it refuses up front.
(
MuxSource::Session {
session,
title_index,
},
None,
) if !opts.raw => match session.disc() {
Some(d) if d.aacs.is_some() => {
if d.content_format != crate::disc::ContentFormat::BdTs {
return Err(Error::NoDiscKey {
disc_hash: d.aacs_disc_hash(),
}
.into());
}
crate::keys::KeyRing::keyless_for_disc(d, *title_index)
}
_ => None,
},
_ => None,
Err(e) => return Err(e.into()),
};
let set = set.or(keyless.as_ref());
match (source, set) {
(MuxSource::Url { url, opts: mut o }, _) => {
if let Some(k) = keys {
o.keys = Some(k.clone());
}
mux_unkeyed(MuxSource::Url { url, opts: o }, dest_url, opts, ctx)
}
(src, None) => mux_unkeyed(src, dest_url, opts, ctx),
(src, Some(set)) => mux_keyed(src, set, dest_url, opts, ctx),
}
mux_with_keys(source, keys, dest_url, opts, ctx)
}
// The keyed arms of `mux_with_keys`: the set's readers, never a resolve.
fn mux_keyed(
source: MuxSource,
set: &crate::keys::KeyRing,
// `mux_with_keys` before its one halt rule.
fn mux_routed(
source: Source,
keys: Option<&crate::keys::KeyRing>,
dest_url: &str,
opts: &MuxOptions,
ctx: &Ctx,
) -> std::io::Result<MuxOutcome> {
let (stream, playlist, source): (Box<dyn Stream>, Option<String>, SourceInfo) = match source {
MuxSource::Iso { path, title, .. } => {
let name = source.title_name();
let mut info = source.provenance(opts.title_index);
let from_stream = matches!(source.origin, Origin::Stream { .. });
let stream = open_pes(source, keys, opts, ctx)?;
if from_stream {
info.playlist = stream.info().playlist.clone();
}
if let Some(n) = &name {
info.playlist = n.clone();
}
drive_mux(stream, dest_url, ctx, name.as_deref(), Some(&info))
}
// The PES stream of `opts.title_index` read from `source`: the one place that turns any
// opened input into frames (decrypting with `keys` unless `opts.raw`).
pub(crate) fn open_pes(
source: Source,
keys: Option<&crate::keys::KeyRing>,
opts: &MuxOptions,
ctx: &Ctx,
) -> std::io::Result<Box<dyn Stream>> {
let set = keys.filter(|s| s.is_aacs() && !opts.raw);
let idx = opts.title_index;
let input_opts = || InputOptions {
title_index: Some(idx),
raw: opts.raw,
selection: opts.selection.clone(),
keys: keys.cloned(),
};
match source.origin {
Origin::Stream { url } => super::resolve::open_stream_url(&url, &input_opts(), ctx),
Origin::Image {
path,
folder,
reader,
disc,
} => {
let reopen = move || super::resolve::reopen_image(&path, folder);
Ok(Box::new(super::resolve::image_input_scanned(
reader,
disc,
&input_opts(),
reopen,
ctx,
)?))
}
Origin::Prescanned { path, title: t } => {
let (title, format) = (t.title, t.format);
let keyless = keyless_ring(&title, format, set, opts);
let set = set.or(keyless.as_ref());
let mut title = title;
opts.selection
.apply(&mut title)
.map_err(std::io::Error::from)?;
let source = SourceInfo {
medium: Medium::Iso,
path: format!("iso://{}", path.display()),
playlist: title.playlist.clone(),
..SourceInfo::default()
};
let reader = FileSectorSource::open(path)?;
let stream = super::resolve::build_iso_pipeline_keyed(
reader,
title,
set,
opts.batch_sectors,
ctx,
)?;
(Box::new(stream), None, source)
let reader = FileSectorSource::open(&path)?;
match set {
Some(set) => Ok(Box::new(super::resolve::build_iso_pipeline_keyed(
reader,
title,
set,
opts.batch_sectors,
ctx,
)?)),
None => Ok(Box::new(build_iso_pipeline(
reader,
title,
DecryptKeys::None,
opts.batch_sectors,
format,
opts.raw,
ctx,
)?)),
}
}
MuxSource::Session {
session,
title_index,
} => {
let (title, format, playlist, source) = {
let disc = session.disc().ok_or_else(|| Error::DeviceNotReady {
path: session.device_path().to_string(),
})?;
let scope = crate::keys::KeyScope::Titles(vec![title_index]);
if !set.is_for(&disc.media_id()) || !set.covers(&scope) {
tracing::error!(target: "freemkv::keys", "key set is not for this session's title");
return Err(Error::DecryptFailed.into());
Origin::Live { reader, title: t } => {
let (title, format) = (t.title, t.format);
let keyless = keyless_ring(&title, format, set, opts);
match set.or(keyless.as_ref()) {
Some(set) => {
let title = live_keyed_title(&*reader, title, set, opts)?;
live_keyed(reader, title, format, set, opts, ctx)
}
let title = disc
.titles
.get(title_index)
.cloned()
.ok_or(Error::MuxTrackRange {
track: title_index,
tracks: disc.titles.len(),
})?;
let source = SourceInfo {
medium: Medium::Disc,
path: format!("disc://{}", session.device_path()),
title: title_index,
playlist: title.playlist.clone(),
volume_id: disc.volume_id.clone(),
};
let playlist = disc
.meta_title
.clone()
.unwrap_or_else(|| disc.volume_id.clone());
(title, disc.content_format, playlist, source)
};
// Refuse the selection / the set's gate BEFORE taking the reader, so a
// refused mux leaves the session retryable (as mux_unkeyed does).
let path = session.device_path().to_string();
let not_ready = || Error::DeviceNotReady { path: path.clone() };
None => {
let mut title = title;
opts.selection
.apply(&mut title)
.map_err(std::io::Error::from)?;
live_unkeyed(reader, title, format, DecryptKeys::None, opts, ctx)
}
}
}
Origin::Session(session) => session_pes(session, set, opts, ctx),
Origin::Drive(mut session) => session_pes(&mut session, set, opts, ctx),
}
}
// Title `idx` of a scanned layout, and the layout's container format.
fn title_of(
disc: &crate::disc::Disc,
idx: usize,
) -> std::io::Result<(DiscTitle, crate::disc::ContentFormat)> {
let title = disc.titles.get(idx).cloned().ok_or(Error::MuxTrackRange {
track: idx,
tracks: disc.titles.len(),
})?;
Ok((title, disc.content_format))
}
// KU §3.1: "`keys` must be `Some` for AACS". A BD-TS read with no AACS set reads through a
// keyless set: the first AACS-flagged unit is E7022, never muxed as content. MPEG-PS keeps
// its own path (a DVD cracks its CSS key in the stream).
fn keyless_ring(
title: &DiscTitle,
format: crate::disc::ContentFormat,
set: Option<&crate::keys::KeyRing>,
opts: &MuxOptions,
) -> Option<crate::keys::KeyRing> {
(set.is_none() && !opts.raw && format == crate::disc::ContentFormat::BdTs)
.then(|| crate::keys::KeyRing::keyless_for(title, format))
}
// A live mux off a drive session: the set's readers when keyed; else the session's own
// CSS/clear keys, and a keyless set for an AACS disc (HD DVD refuses up front).
fn session_pes(
session: &mut DiscSession,
set: Option<&crate::keys::KeyRing>,
opts: &MuxOptions,
ctx: &Ctx,
) -> std::io::Result<Box<dyn Stream>> {
let idx = opts.title_index;
let path = session.device_path().to_string();
let not_ready = || Error::DeviceNotReady { path: path.clone() };
let disc = session.disc().ok_or_else(not_ready)?;
let (title, format) = title_of(disc, idx)?;
let keyless = match set {
None if !opts.raw && disc.aacs.is_some() => {
if disc.content_format != crate::disc::ContentFormat::BdTs {
return Err(Error::NoDiscKey {
disc_hash: disc.aacs_disc_hash(),
}
.into());
}
crate::keys::KeyRing::keyless_for_disc(disc, idx)
}
_ => None,
};
let keys = session_mux_keys(disc);
let set = set.or(keyless.as_ref());
if let Some(set) = set {
let scope = crate::keys::KeyScope::Titles(vec![idx]);
if !set.is_for(&disc.media_id()) || !set.covers(&scope) {
tracing::error!(target: "freemkv::keys", "key set is not for this session's title");
return Err(Error::DecryptFailed.into());
}
}
let batch = match opts.batch_sectors {
0 => crate::disc::detect_max_batch_sectors(&path),
n => n,
};
let opts = &MuxOptions {
batch_sectors: batch,
..opts.clone()
};
if session.staged_reader().is_none() {
session.stage_drive_as_reader();
}
match set {
Some(set) => {
// Refuse the selection / the set's gate BEFORE taking the reader, so a refused
// mux leaves the session retryable.
let staged = session.staged_reader().ok_or_else(not_ready)?;
let title = live_keyed_title(staged, title, set, opts)?;
let reader = session.take_reader().ok_or_else(not_ready)?;
let stream = live_keyed(reader, title, format, set, opts, ctx)?;
(stream, Some(playlist), source)
live_keyed(reader, title, format, set, opts, ctx)
}
MuxSource::Live {
reader,
title,
format,
} => {
let source = SourceInfo {
medium: Medium::Disc,
playlist: title.playlist.clone(),
..SourceInfo::default()
};
let title = live_keyed_title(&*reader, title, set, opts)?;
let stream = live_keyed(reader, title, format, set, opts, ctx)?;
(stream, None, source)
None => {
let mut title = title;
opts.selection
.apply(&mut title)
.map_err(std::io::Error::from)?;
let reader = session.take_reader().ok_or_else(not_ready)?;
live_unkeyed(reader, title, format, keys, opts, ctx)
}
MuxSource::Url { .. } => unreachable!("mux_with_keys routes Url to mux_unkeyed"),
};
let mut source = source;
if let Some(name) = playlist.as_deref() {
source.playlist = name.to_string();
}
drive_mux(stream, dest_url, ctx, playlist.as_deref(), Some(&source))
}
// The inline live-drive `DiscStream` with no AACS set: `keys` (CSS cracks per title when
// `None` on a DVD), or ciphertext under `opts.raw`.
fn live_unkeyed(
reader: Box<dyn SectorSource>,
title: DiscTitle,
format: crate::disc::ContentFormat,
keys: DecryptKeys,
opts: &MuxOptions,
ctx: &Ctx,
) -> std::io::Result<Box<dyn Stream>> {
let mut stream = crate::mux::DiscStream::new(
reader,
title,
keys,
opts.batch_sectors,
format,
opts.raw,
ctx,
)?;
if opts.raw {
stream.set_raw();
}
stream.skip_errors = opts.skip_errors;
Ok(Box::new(stream))
}
// The live title after the selection, once the set's gate admits it over `reader`.
@@ -1638,11 +1513,10 @@ mod tests {
..DiscTitle::empty()
};
let reader = crate::test_util::MemSource::new(vec![0u8; 16 * 2048]);
let src = MuxSource::Live {
reader: Box::new(reader),
title,
format: crate::disc::ContentFormat::MpegPs,
};
let src = Source::from_reader(
Box::new(reader),
ScannedTitle::new(title, crate::disc::ContentFormat::MpegPs),
);
let opts = MuxOptions {
batch_sectors: 16,
..Default::default()
@@ -1868,14 +1742,15 @@ mod tests {
batch_sectors: 8192,
raw: false,
selection: Default::default(),
title_index: 0,
};
let halt = Halt::new();
let out = mux_unkeyed(
MuxSource::Iso {
path: &iso_path,
title,
format: crate::disc::ContentFormat::BdTs,
},
let out = mux_with_keys(
Source::from_image(
&iso_path,
ScannedTitle::new(title, crate::disc::ContentFormat::BdTs),
),
None,
"null://",
&opts,
&crate::ctx::Ctx::new(halt.clone()).with_events(events.clone()),
@@ -1993,13 +1868,12 @@ mod tests {
batch_sectors: 3,
raw: false,
selection: Default::default(),
title_index: 0,
};
let halt = Halt::new();
let err = mux_unkeyed(
MuxSource::Session {
session: &mut session,
title_index: 0,
},
let err = mux_with_keys(
Source::from_session(&mut session),
None,
"null://",
&opts,
&crate::ctx::Ctx::new(halt.clone()),
@@ -2066,13 +1940,12 @@ mod tests {
batch_sectors: 3,
raw: false,
selection: Default::default(),
title_index: num_titles + 5,
};
let halt = Halt::new();
let err = mux_unkeyed(
MuxSource::Session {
session: &mut session,
title_index: num_titles + 5, // out of range
},
let err = mux_with_keys(
Source::from_session(&mut session),
None,
"null://",
&opts,
&crate::ctx::Ctx::new(halt.clone()),
@@ -3011,6 +2884,7 @@ mod tests {
batch_sectors: 3,
raw: false,
selection: Default::default(),
title_index: 0,
}
}
@@ -3048,11 +2922,10 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let out_path = dir.path().join("live.mkv");
let out = mux_with_keys(
MuxSource::Live {
Source::from_reader(
reader,
title,
format: crate::disc::ContentFormat::BdTs,
},
ScannedTitle::new(title, crate::disc::ContentFormat::BdTs),
),
Some(&set),
&format!("mkv://{}", out_path.display()),
&keyed_opts(),
@@ -3076,10 +2949,7 @@ mod tests {
let set = crate::keys::KeyRing::keyed_for_test(&disc, key, &[(0, 3)]);
let mut session = DiscSession::from_parts_for_test(Some(disc), Some(reader));
let out = mux_with_keys(
MuxSource::Session {
session: &mut session,
title_index: 0,
},
Source::from_session(&mut session),
Some(&set),
"null://",
&keyed_opts(),
@@ -3103,10 +2973,7 @@ mod tests {
bad.selection.audio = crate::mux::select::PidFilter::Only(vec![0x0FFF]);
let run = |session: &mut DiscSession, opts: &MuxOptions| {
mux_with_keys(
MuxSource::Session {
session,
title_index: 0,
},
Source::from_session(session),
Some(&set),
"null://",
opts,
@@ -3136,10 +3003,7 @@ mod tests {
let set = crate::keys::KeyRing::keyed_for_test(&other, key, &[(0, 3)]);
let mut session = DiscSession::from_parts_for_test(Some(disc), Some(reader));
let err = mux_with_keys(
MuxSource::Session {
session: &mut session,
title_index: 0,
},
Source::from_session(&mut session),
Some(&set),
"null://",
&keyed_opts(),
@@ -3163,11 +3027,10 @@ mod tests {
std::fs::write(&path, &image).unwrap();
let out_path = dir.path().join("iso.mkv");
let out = mux_with_keys(
MuxSource::Iso {
path: &path,
title,
format: crate::disc::ContentFormat::BdTs,
},
Source::from_image(
&path,
ScannedTitle::new(title, crate::disc::ContentFormat::BdTs),
),
Some(&set),
&format!("mkv://{}", out_path.display()),
&keyed_opts(),
@@ -3180,11 +3043,8 @@ mod tests {
"the muxed audio is the plaintext"
);
let url = format!("iso://{}", path.display());
let rescanned = mux_with_keys(
MuxSource::Url {
url: &url,
opts: InputOptions::default(),
},
let rescanned = mux_url(
&url,
Some(&set),
"null://",
&keyed_opts(),
@@ -3215,11 +3075,10 @@ mod tests {
..keyed_opts()
};
mux_with_keys(
MuxSource::Live {
Source::from_reader(
reader,
title,
format: crate::disc::ContentFormat::BdTs,
},
ScannedTitle::new(title, crate::disc::ContentFormat::BdTs),
),
keys,
"null://",
&opts,
@@ -3250,11 +3109,10 @@ mod tests {
image.resize(16 * 2048, 0);
std::fs::write(&path, &image).unwrap();
let iso = mux_with_keys(
MuxSource::Iso {
path: &path,
title,
format: crate::disc::ContentFormat::BdTs,
},
Source::from_image(
&path,
ScannedTitle::new(title, crate::disc::ContentFormat::BdTs),
),
None,
"null://",
&keyed_opts(),
@@ -3278,10 +3136,7 @@ mod tests {
});
let mut session = DiscSession::from_parts_for_test(Some(disc), Some(reader));
let r = mux_with_keys(
MuxSource::Session {
session: &mut session,
title_index: 0,
},
Source::from_session(&mut session),
None,
"null://",
&keyed_opts(),
@@ -3335,11 +3190,10 @@ mod tests {
dest: &str,
) -> std::io::Result<MuxOutcome> {
mux_with_keys(
MuxSource::Iso {
Source::from_image(
path,
title,
format: crate::disc::ContentFormat::BdTs,
},
ScannedTitle::new(title, crate::disc::ContentFormat::BdTs),
),
Some(set),
dest,
&MuxOptions {
@@ -3463,22 +3317,20 @@ mod tests {
..keyed_opts()
};
let iso = mux_with_keys(
MuxSource::Iso {
path: &path,
title: title.clone(),
format: crate::disc::ContentFormat::BdTs,
},
Source::from_image(
&path,
ScannedTitle::new(title.clone(), crate::disc::ContentFormat::BdTs),
),
Some(&set),
"null://",
&opts,
&crate::ctx::Ctx::default(),
);
let live = mux_with_keys(
MuxSource::Live {
reader: Box::new(ImageReader(std::fs::read(&path).unwrap())),
title,
format: crate::disc::ContentFormat::BdTs,
},
Source::from_reader(
Box::new(ImageReader(std::fs::read(&path).unwrap())),
ScannedTitle::new(title, crate::disc::ContentFormat::BdTs),
),
Some(&set),
"null://",
&opts,
@@ -3618,15 +3470,14 @@ mod tests {
title
}
fn clear_live(units: usize, bad: Option<u32>) -> MuxSource<'static> {
MuxSource::Live {
reader: Box::new(BadSectorImage {
fn clear_live(units: usize, bad: Option<u32>) -> Source<'static> {
Source::from_reader(
Box::new(BadSectorImage {
data: clear_audio_image(units),
bad,
}),
title: clear_title(units),
format: crate::disc::ContentFormat::BdTs,
}
ScannedTitle::new(clear_title(units), crate::disc::ContentFormat::BdTs),
)
}
// `raw` routes a clear Live source through mux_unkeyed; otherwise mux_keyed runs it
@@ -3702,13 +3553,7 @@ mod tests {
let (src, raw) = match arm {
"live-unkeyed" => (clear_live(2, Some(4)), true),
"live-keyed" => (clear_live(2, Some(4)), false),
_ => (
MuxSource::Session {
session: &mut s,
title_index: 0,
},
false,
),
_ => (Source::from_session(&mut s), false),
};
let opts = MuxOptions {
skip_errors: skip,
@@ -3761,19 +3606,12 @@ mod tests {
let (src, raw) = match arm {
"live-unkeyed" => (clear_live(2, None), true),
"live-keyed" => (clear_live(2, None), false),
"session" => (
MuxSource::Session {
session: &mut session,
title_index: 0,
},
false,
),
"session" => (Source::from_session(&mut session), false),
_ => (
MuxSource::Iso {
path: &iso,
title: clear_title(2),
format: crate::disc::ContentFormat::BdTs,
},
Source::from_image(
&iso,
ScannedTitle::new(clear_title(2), crate::disc::ContentFormat::BdTs),
),
arm == "iso-unkeyed",
),
};
+3 -6
View File
@@ -2,7 +2,7 @@
//! QA explicitly runs ignored `ffmpeg_` tests with software codecs installed.
//! Fixtures are generated locally and never downloaded from a media corpus.
use super::driver::{MuxOptions, MuxSource, mux_with_keys};
use super::driver::MuxOptions;
use serde_json::Value;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
@@ -190,11 +190,8 @@ fn generate_with(
fn remux(source: &Path, output: &Path, transport: bool) {
let scheme = if transport { "m2ts" } else { "mkv" };
let url = format!("{scheme}://{}", source.display());
let result = mux_with_keys(
MuxSource::Url {
url: &url,
opts: Default::default(),
},
let result = crate::mux::mux_url(
&url,
None,
&format!("mkv://{}", output.display()),
&MuxOptions::default(),
+3 -1
View File
@@ -23,6 +23,7 @@ pub mod driver;
pub mod pipelined_stream;
pub mod resolve;
pub mod select;
pub mod source;
// Internal-only modules (referenced only via `crate::mux::…`; not public API).
// `#[allow(dead_code)]`: narrowing `pub`→`pub(crate)` surfaces helpers only
@@ -84,13 +85,14 @@ pub(crate) mod videomap;
// The provenance types ARE public: `output()` takes a `SourceInfo` so an `fvi://`
// destination records the INPUT it was built from (§6.2), not the file written.
pub use disc::DiscStream;
pub use driver::{MuxOptions, MuxOutcome, MuxSource, mux_with_keys};
pub use driver::{MuxOptions, MuxOutcome, mux_url, mux_with_keys};
pub use fit::{FitReport, SkipReason, fit_report};
pub use m2ts::M2tsStream;
pub use mkvstream::{
MkvProbe, MkvProbeTrack, MkvStream, MkvTrackKind, parse_freemkv_version, probe_mkv,
probe_mkv_with_cues,
};
pub use source::{ScannedTitle, Source, open_source};
pub use videomap::{Medium, SourceInfo};
// `Mp4Sink` is public so a caller driving the sink can ask `final_report()` what
// the finished file contains — the pre-mux `mp4_fit_report` is only a prediction,
+4 -6
View File
@@ -2059,7 +2059,7 @@ fn a_map_with_a_bad_crc_is_ignored() {
// offset); the extension is the mkv sink's to exclude (M1, J23).
#[test]
fn an_mpg_source_remuxes_to_mkv() {
use crate::mux::driver::{MuxOptions, MuxSource, mux_with_keys};
use crate::mux::driver::MuxOptions;
let fx = fixture(&Opts {
spu_tracks: 0,
..Opts::default()
@@ -2074,14 +2074,12 @@ fn an_mpg_source_remuxes_to_mkv() {
batch_sectors: 8192,
raw: false,
selection: Default::default(),
title_index: 0,
};
let out = {
let _g = crate::sector::prefetched::holder_test_lock();
mux_with_keys(
MuxSource::Url {
url: &url,
opts: Default::default(),
},
crate::mux::mux_url(
&url,
None,
&format!("mkv://{}", mkv.display()),
&opts,
+15 -18
View File
@@ -2,7 +2,7 @@
//! every sink driven through `mux_with_keys`, over the synthetic BD clip. Pinned as output
//! hashes, loss counters and refusal codes (see [`Golden`]); cells are `parity_*`.
use super::driver::{MuxOptions, MuxOutcome, MuxSource, mux_with_keys};
use super::driver::{MuxOptions, MuxOutcome};
use super::resolve::{InputOptions, input};
use super::select::{PidFilter, StreamSelection};
use crate::aacs::content::{ALIGNED_UNIT_LEN, encrypt_unit};
@@ -119,14 +119,17 @@ fn sinks(
"null" => "null://".to_string(),
s => format!("{s}://{}", out.join(dest_name(s)).display()),
};
let r = mux_with_keys(
MuxSource::Url {
url: &url,
opts: iopts.clone(),
},
None,
let m = MuxOptions {
title_index: iopts.title_index.unwrap_or(0),
raw: iopts.raw,
selection: iopts.selection.clone(),
..mopts.clone()
};
let r = crate::mux::mux_url(
&url,
iopts.keys.as_ref(),
&dest,
mopts,
&m,
&crate::ctx::Ctx::default(),
);
record_run(g, sink, &r);
@@ -212,11 +215,8 @@ fn parity_sinks_from_mkv_mpg_and_fmkv_sources() {
std::fs::write(&src, &clip).unwrap();
for (scheme, name) in [("mkv", "a.mkv"), ("mpg", "a.mpg"), ("m2ts", "a.fmkv.m2ts")] {
let p = dir.path().join(name);
mux_with_keys(
MuxSource::Url {
url: &format!("m2ts://{}", src.display()),
opts: Default::default(),
},
crate::mux::mux_url(
&format!("m2ts://{}", src.display()),
None,
&format!("{scheme}://{}", p.display()),
&MuxOptions::default(),
@@ -396,11 +396,8 @@ pub(crate) fn clear_mpg() -> Vec<u8> {
let src = dir.path().join("c.m2ts");
std::fs::write(&src, &clip).unwrap();
let dst = dir.path().join("c.mpg");
mux_with_keys(
MuxSource::Url {
url: &format!("m2ts://{}", src.display()),
opts: Default::default(),
},
crate::mux::mux_url(
&format!("m2ts://{}", src.display()),
None,
&format!("mpg://{}", dst.display()),
&MuxOptions::default(),
+90 -80
View File
@@ -2,12 +2,8 @@
//!
//! Format: `scheme://path`.
//!
//! For disc→ISO (raw sector copy), use `freemkv_engine::recovery::copy` instead.
//!
//! Note: `disc://` cannot be opened through [`input`]; it returns
//! [`crate::error::Error::DiscUrlNotDirect`]. Live-disc input must go
//! through `Drive::open()` + `Disc::scan()` + `DiscStream::new()`, not
//! the URL resolver.
//! Every input scheme, `disc://` included, opens through [`super::source::open_source`];
//! [`input`] is that plus the PES stream of one title.
use super::network::NetworkStream;
use super::null::NullStream;
@@ -381,60 +377,50 @@ impl std::fmt::Debug for InputOptions {
}
}
/// Open a PES input stream (produces PES frames) for the run `ctx`. Its halt reaches the
/// image/PS/TS pipelines' scan, crack, reads and demux and the network receive (`stdio://`,
/// `mkv://`, `mp4://` read inline); image and PS reads report `BytesRead`; stats count loss.
/// Open a PES input stream (produces PES frames) for the run `ctx`: [`open_source`] then the
/// PES stream of `opts.title_index`. Every input scheme opens here, `disc://` included (the
/// drive is brought up and scanned with default [`crate::disc::ScanOptions`]). Its halt
/// reaches the scan, crack, reads and demux and the network receive (`stdio://`, `mkv://`,
/// `mp4://` read inline); image and PS reads report `BytesRead`; stats count loss.
///
/// [`open_source`]: super::source::open_source
pub fn input(
url: &str,
opts: &InputOptions,
ctx: &crate::ctx::Ctx,
) -> io::Result<Box<dyn crate::pes::Stream>> {
let source = super::source::open_source(url, crate::disc::ScanOptions::default(), ctx)?;
let mux = super::driver::MuxOptions {
raw: opts.raw,
selection: opts.selection.clone(),
title_index: opts.title_index.unwrap_or(0),
..Default::default()
};
super::driver::open_pes(source, opts.keys.as_ref(), &mux, ctx)
}
// A probed image's reader opened again, for the TrueHD header probe.
pub(crate) fn reopen_image(path: &Path, folder: bool) -> io::Result<Box<dyn SectorSource>> {
Ok(if folder {
Box::new(crate::dirimage::DirImage::open(path)?)
} else {
Box::new(crate::io::file_sector_source::FileSectorSource::open(path)?)
})
}
pub(crate) fn validate_path(path: &Path, scheme: &str) -> crate::error::Result<()> {
validate_file_path(path, scheme).map_err(crate::error::Error::from)
}
// The container and stream inputs (`m2ts://`, `mkv://`, `mp4://`, `mpg://`, `network://`,
// `stdio://`), each through the one decryption stage.
pub(crate) fn open_stream_url(
url: &str,
opts: &InputOptions,
ctx: &crate::ctx::Ctx,
) -> io::Result<Box<dyn crate::pes::Stream>> {
let parsed = parse_url(url);
match parsed {
StreamUrl::Disc { .. } => {
// Disc sources require live SCSI state — caller must use
// `Drive::open() + Disc::scan() + DiscStream::new()` directly.
// Typed error only; the CLI/UI explains the entry point.
Err(crate::error::Error::DiscUrlNotDirect.into())
}
StreamUrl::Iso { ref path } => {
validate_file_path(path, "iso")?;
// Sole file-backed sector source: carries the platform SEQUENTIAL
// fadvise hint (widens kernel readahead) plus periodic DONTNEED
// eviction bounding memory pressure during same-disk mux output.
let reader = crate::io::file_sector_source::FileSectorSource::open(path)?;
let probe_path = path.clone();
let stream = image_input(
reader,
opts,
move || {
crate::io::file_sector_source::FileSectorSource::open(&probe_path)
.map_err(|e| -> io::Error { e.into() })
},
false,
ctx,
)?;
Ok(Box::new(stream))
}
// `dir://` as a SOURCE: an extracted disc folder presented as a
// synthetic UDF image (`crate::dirimage`), reaching the exact same
// body as `iso://` since `DirImage` is just another `SectorSource`.
StreamUrl::Dir { ref path } => {
validate_file_path(path, "dir")?;
let reader = crate::dirimage::DirImage::open(path)?;
let probe_path = path.clone();
let stream = image_input(
reader,
opts,
move || {
crate::dirimage::DirImage::open(&probe_path)
.map_err(|e| -> io::Error { e.into() })
},
true,
ctx,
)?;
Ok(Box::new(stream))
}
StreamUrl::M2ts { ref path } => {
validate_file_path(path, "m2ts")?;
let len = std::fs::metadata(path)?.len();
@@ -489,41 +475,31 @@ pub fn input(
| StreamUrl::Json { .. } => Err(crate::error::Error::StreamWriteOnly.into()),
// `fvi://` is an output-only sink (per-picture video index); never a source.
StreamUrl::Fvi { .. } => Err(crate::error::Error::StreamWriteOnly.into()),
StreamUrl::Disc { .. } | StreamUrl::Iso { .. } | StreamUrl::Dir { .. } => {
Err(crate::error::Error::StreamUrlInvalid {
url: url.to_string(),
}
.into())
}
StreamUrl::Unknown { ref raw } => {
Err(crate::error::Error::StreamUrlInvalid { url: raw.clone() }.into())
}
}
}
// Shared body of every IMAGE-level PES source (`iso://`/`dir://`).
fn image_input<S, F>(
mut reader: S,
// Shared body of every IMAGE-level PES source (`iso://`/`dir://`) over the probe's reader
// and layout (`open_source` scanned it, and judged a folder's AACS verdict from content).
pub(crate) fn image_input_scanned<S, F>(
reader: S,
mut disc: crate::disc::Disc,
opts: &InputOptions,
reopen: F,
// A FOLDER's `encrypted` flag comes from tree shape and can be wrong; an
// image's cannot. See `session::apply_folder_encryption_verdict`.
is_folder: bool,
ctx: &crate::ctx::Ctx,
) -> io::Result<PipelinedPesStream>
where
S: SectorSource + Send + 'static,
F: FnOnce() -> io::Result<S>,
{
let capacity = reader.capacity_sectors();
// BUG-4: the scan and every pipeline stage below observe the run's halt.
let scan = crate::disc::ScanOptions {
halt: Some(ctx.halt.clone()),
..Default::default()
};
let mut disc = crate::disc::Disc::scan_image(&mut reader, capacity, &scan)
.map_err(|e| -> io::Error { e.into() })?;
// Without this a folder reached here with a tree-shape verdict while
// `session::scan_dir` reached the opposite one from its CONTENT, so the
// same folder ripped through one door and failed through the other.
if is_folder {
crate::session::apply_folder_encryption_verdict(&mut reader, &mut disc)
.map_err(|e| -> io::Error { e.into() })?;
}
if let Some(set) = opts.keys.as_ref().filter(|s| s.is_aacs() && !opts.raw) {
return keyed_image_input(reader, disc, opts, set, reopen, ctx);
}
@@ -1438,12 +1414,22 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// The resolver doc table marks disc:// as input-only via the
/// `Drive::open` path — input("disc://") must surface DiscUrlNotDirect
/// (E9009 → Unsupported), never attempt to open a stream.
/// `disc://` is an ordinary input: `input()` opens the named drive (a missing device
/// fails as the drive open does), never refusing the scheme.
#[test]
fn input_disc_url_is_not_direct() {
assert_eq!(input_err_kind("disc://"), std::io::ErrorKind::Unsupported);
fn input_disc_url_opens_the_drive() {
let err = super::input(
"disc:///nonexistent/freemkv-test-drive",
&super::InputOptions::default(),
&crate::ctx::Ctx::default(),
)
.err()
.expect("no such drive");
assert_ne!(
crate::error::error_code(&err),
Some(crate::error::E_DISC_URL_NOT_DIRECT),
"got {err}"
);
}
/// null:// is write-only per the table — input() must reject it with
@@ -1926,7 +1912,31 @@ mod tests {
#[cfg(test)]
mod stop_tests {
use super::{InputOptions, image_input};
use super::{InputOptions, PipelinedPesStream, image_input_scanned};
// `open_source`'s image probe, then the image stream, over a test reader.
fn image_input<S, F>(
mut reader: S,
opts: &InputOptions,
reopen: F,
folder: bool,
ctx: &crate::ctx::Ctx,
) -> std::io::Result<PipelinedPesStream>
where
S: SectorSource + Send + 'static,
F: FnOnce() -> std::io::Result<S>,
{
let cap = reader.capacity_sectors();
let scan = crate::disc::ScanOptions {
halt: Some(ctx.halt.clone()),
..Default::default()
};
let mut disc = crate::disc::Disc::scan_image(&mut reader, cap, &scan)?;
if folder {
crate::session::apply_folder_encryption_verdict(&mut reader, &mut disc)?;
}
image_input_scanned(reader, disc, opts, reopen, ctx)
}
use crate::pes::Stream as _;
use crate::sector::SectorSource;
use std::sync::mpsc;
+307
View File
@@ -0,0 +1,307 @@
//! One way to open any input (pipeline design §2.2, slice 4): [`open_source`] takes a URL
//! for every input scheme, `disc://` included, and probes it once. The [`Source`] carries
//! that probe's [`Disc`] layout (for `disc://`, `iso://` and `dir://`), so listing titles,
//! acquiring keys and muxing read the same scan. A caller that already holds a scanned
//! layout (a drive scan reused over a staged image, J14) or an opened session builds the
//! same `Source` with [`Source::from_image`], [`Source::from_reader`] or
//! [`Source::from_session`]; [`crate::mux_with_keys`] and [`crate::input`] take it.
use std::path::{Path, PathBuf};
use crate::ctx::Ctx;
use crate::disc::{Disc, ScanOptions};
use crate::error::{Error, Result};
use crate::sector::SectorSource;
use crate::session::{DeviceTarget, DiscSession, KeySpec};
use super::resolve::{StreamUrl, parse_url};
use super::videomap::{Medium, SourceInfo};
/// One title of a disc the caller already scanned, for [`Source::from_image`] and
/// [`Source::from_reader`]: the title, its container format, and the disc's name.
#[derive(Debug, Clone)]
pub struct ScannedTitle {
pub title: crate::disc::DiscTitle,
pub format: crate::disc::ContentFormat,
/// The disc's name (meta title, else volume id), which names the output title; `None`
/// keeps the title's own playlist name.
pub disc_name: Option<String>,
pub volume_id: String,
}
impl ScannedTitle {
/// Title `idx` of `disc`, named by the disc. `None` when out of range.
pub fn of(disc: &Disc, idx: usize) -> Option<Self> {
Some(ScannedTitle {
title: disc.titles.get(idx)?.clone(),
format: disc.content_format,
disc_name: Some(disc_name(disc)),
volume_id: disc.volume_id.clone(),
})
}
/// `title` alone: no disc name or volume id.
pub fn new(title: crate::disc::DiscTitle, format: crate::disc::ContentFormat) -> Self {
ScannedTitle {
title,
format,
disc_name: None,
volume_id: String::new(),
}
}
}
// A disc's name: its meta title, else its volume id.
fn disc_name(d: &Disc) -> String {
d.meta_title.clone().unwrap_or_else(|| d.volume_id.clone())
}
/// An opened input: where PES frames come from, and the layout probed from it.
pub struct Source<'a> {
pub(crate) origin: Origin<'a>,
}
pub(crate) enum Origin<'a> {
// A live drive session the caller keeps (tray lock, eject, finish stay with it).
Session(&'a mut DiscSession),
// `disc://`: a session opened and scanned here.
Drive(Box<DiscSession>),
// An image (`iso://`, `dir://`) and the probe's reader.
Image {
path: PathBuf,
folder: bool,
reader: Box<dyn SectorSource>,
disc: Disc,
},
// An image file read with a title the caller scanned elsewhere (J14): opened at read
// time, never rescanned.
Prescanned {
path: PathBuf,
title: ScannedTitle,
},
// A raw live reader the caller already holds, with the title scanned off it.
Live {
reader: Box<dyn SectorSource>,
title: ScannedTitle,
},
// A container or stream input, opened when it is read.
Stream {
url: String,
},
}
/// Open the input `url` and probe it: a drive is brought up and scanned (`disc://`, with
/// `probe`'s credentials and key sources for the handshake), an image or folder is scanned
/// (`iso://`, `dir://`; a folder's AACS verdict is judged from its content). Container and
/// stream inputs (`m2ts://`, `mkv://`, `mp4://`, `mpg://`, `network://`, `stdio://`) open
/// when read. `ctx.halt` stops the bring-up and the scan.
pub fn open_source(url: &str, mut probe: ScanOptions, ctx: &Ctx) -> Result<Source<'static>> {
if probe.halt.is_none() {
probe.halt = Some(ctx.halt.clone());
}
let origin = match parse_url(url) {
StreamUrl::Disc { device } => {
let target = device.map_or(DeviceTarget::Autodetect, DeviceTarget::Path);
let spec = KeySpec {
credentials: probe.credentials.take(),
key_sources: std::mem::take(&mut probe.key_sources),
..KeySpec::default()
};
let mut session = DiscSession::open_with(target, spec, &ctx.halt)?;
session.scan_with(probe)?;
Origin::Drive(Box::new(session))
}
StreamUrl::Iso { path } => {
super::resolve::validate_path(&path, "iso")?;
let (disc, reader) = probe_image(&path, false, &probe)?;
Origin::Image {
path,
folder: false,
reader,
disc,
}
}
StreamUrl::Dir { path } => {
super::resolve::validate_path(&path, "dir")?;
let (disc, reader) = probe_image(&path, true, &probe)?;
Origin::Image {
path,
folder: true,
reader,
disc,
}
}
StreamUrl::M2ts { .. }
| StreamUrl::Mkv { .. }
| StreamUrl::Mp4 { .. }
| StreamUrl::Mpg { .. }
| StreamUrl::Network { .. }
| StreamUrl::Stdio => Origin::Stream {
url: url.to_string(),
},
StreamUrl::Null
| StreamUrl::Demux { .. }
| StreamUrl::Video { .. }
| StreamUrl::Audio { .. }
| StreamUrl::Sub { .. }
| StreamUrl::Fvi { .. }
| StreamUrl::Chapters { .. }
| StreamUrl::Json { .. } => return Err(Error::StreamWriteOnly),
StreamUrl::Unknown { raw } => return Err(Error::StreamUrlInvalid { url: raw }),
};
Ok(Source { origin })
}
/// The one image probe (`iso://` file or `dir://` folder): scan the image, and judge a
/// folder's AACS verdict from its content (tree shape alone can be wrong for a decrypted
/// folder that kept `AACS/`). Returns the layout and the probe's reader.
pub(crate) fn probe_image(
path: &Path,
folder: bool,
probe: &ScanOptions,
) -> Result<(Disc, Box<dyn SectorSource>)> {
let mut reader: Box<dyn SectorSource> = if folder {
Box::new(crate::dirimage::DirImage::open(path)?)
} else {
Box::new(crate::io::file_sector_source::FileSectorSource::open(path)?)
};
let cap = reader.capacity_sectors();
let mut disc = Disc::scan_image(&mut reader, cap, probe)?;
if folder {
crate::session::apply_folder_encryption_verdict(&mut reader, &mut disc)?;
}
Ok((disc, reader))
}
impl<'a> Source<'a> {
/// A live drive the caller opened and scanned; the session stays the caller's, so it
/// still locks, ejects and finishes the tray. Its drive is staged as the reader here.
pub fn from_session(session: &'a mut DiscSession) -> Source<'a> {
Source {
origin: Origin::Session(session),
}
}
/// The image file at `path` read with a title the caller already scanned (the drive's,
/// J14): it is never rescanned, so a sweep's unread filesystem sectors do not matter.
/// The mux reads this one title whatever `MuxOptions::title_index` says.
pub fn from_image(path: &Path, title: ScannedTitle) -> Source<'static> {
Source {
origin: Origin::Prescanned {
path: path.to_path_buf(),
title,
},
}
}
/// A raw live reader the caller holds, with the title scanned off it (read like
/// [`Self::from_image`]'s, whatever `MuxOptions::title_index` says).
pub fn from_reader(reader: Box<dyn SectorSource>, title: ScannedTitle) -> Source<'static> {
Source {
origin: Origin::Live { reader, title },
}
}
/// The probed layout: the scanned disc for `disc://`, `iso://`, `dir://` and the
/// `from_*` forms; `None` for a container or stream input.
pub fn layout(&self) -> Option<&Disc> {
match &self.origin {
Origin::Session(s) => s.disc(),
Origin::Drive(s) => s.disc(),
Origin::Image { disc, .. } => Some(disc),
Origin::Prescanned { .. } | Origin::Live { .. } | Origin::Stream { .. } => None,
}
}
/// The opened drive session, for `disc://` and [`Self::from_session`].
pub fn session_mut(&mut self) -> Option<&mut DiscSession> {
match &mut self.origin {
Origin::Session(s) => Some(s),
Origin::Drive(s) => Some(s),
_ => None,
}
}
/// The drive session `open_source("disc://…")` opened, so the caller can finish it
/// (unlock, eject). `None` for every other input.
pub fn into_session(self) -> Option<DiscSession> {
match self.origin {
Origin::Drive(s) => Some(*s),
_ => None,
}
}
/// The probe's layout and raw reader, for a caller that keys or copies the image itself
/// (`iso://`, `dir://`). `None` for every other input.
pub fn into_image(self) -> Option<(Disc, Box<dyn SectorSource>)> {
match self.origin {
Origin::Image { reader, disc, .. } => Some((disc, reader)),
_ => None,
}
}
/// The raw reader the layout was probed through, for key acquisition before a mux.
pub fn reader_mut(&mut self) -> Option<&mut dyn SectorSource> {
match &mut self.origin {
Origin::Session(s) => s.source_mut(),
Origin::Drive(s) => s.source_mut(),
Origin::Image { reader: r, .. } | Origin::Live { reader: r, .. } => Some(r.as_mut()),
_ => None,
}
}
// The provenance of title `title` read from this source (one place, SO15).
pub(crate) fn provenance(&self, title: usize) -> SourceInfo {
let (medium, path) = match &self.origin {
Origin::Session(s) => (Medium::Disc, format!("disc://{}", s.device_path())),
Origin::Drive(s) => (Medium::Disc, format!("disc://{}", s.device_path())),
Origin::Image { path, folder, .. } => (
Medium::Iso,
format!(
"{}://{}",
if *folder { "dir" } else { "iso" },
path.display()
),
),
Origin::Prescanned { path, .. } => (Medium::Iso, format!("iso://{}", path.display())),
Origin::Live { .. } => (Medium::Disc, String::new()),
Origin::Stream { url } => (super::driver::url_medium(&parse_url(url)), url.clone()),
};
let (playlist, volume_id) = match (&self.origin, self.layout()) {
(Origin::Prescanned { title: t, .. } | Origin::Live { title: t, .. }, _) => {
(t.title.playlist.clone(), t.volume_id.clone())
}
(_, Some(d)) => (
d.titles
.get(title)
.map(|t| t.playlist.clone())
.unwrap_or_default(),
d.volume_id.clone(),
),
(_, None) => (String::new(), String::new()),
};
// A pre-scanned title carries no index; it is reported as 0.
let title = match &self.origin {
Origin::Prescanned { .. } | Origin::Live { .. } => 0,
_ => title,
};
SourceInfo {
medium,
path,
title,
playlist,
volume_id,
}
}
// The output title's name (SO14): every disc layout names it by the disc (its meta
// title, else its volume id); a container keeps its own.
pub(crate) fn title_name(&self) -> Option<String> {
match &self.origin {
Origin::Prescanned { title, .. } | Origin::Live { title, .. } => {
title.disc_name.clone()
}
_ => self.layout().map(disc_name),
}
}
}
+3 -11
View File
@@ -12,7 +12,7 @@ use crate::drive::{Drive, find_drive};
use crate::error::{Error, Result};
use crate::halt::{Halt, Liveness};
use crate::keysource::KeySource;
use crate::sector::{FileSectorSource, SectorSource};
use crate::sector::SectorSource;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -522,10 +522,7 @@ impl DiscSession {
/// carries. The returned reader is a fresh handle at the start of the image,
/// reusable by callers that need to sample ciphertext or feed a mux.
pub fn scan_iso(path: &Path, opts: ScanOptions) -> Result<(Disc, Box<dyn SectorSource>)> {
let mut reader = FileSectorSource::open(path)?;
let capacity = reader.capacity_sectors();
let disc = Disc::scan_image(&mut reader, capacity, &opts)?;
Ok((disc, Box::new(reader)))
crate::mux::source::probe_image(path, false, &opts)
}
// Sampled 6144-byte aligned units when judging whether a folder with `AACS/`
@@ -543,12 +540,7 @@ const AACS_PROBE_UNITS: usize = 8;
/// * none need decryption → `encrypted` is forced false, reason logged.
/// * any unit does → [`Error::DirImageEncrypted`] (`dir://` doesn't support it).
pub fn scan_dir(path: &Path, opts: ScanOptions) -> Result<(Disc, Box<dyn SectorSource>)> {
let mut reader = crate::dirimage::DirImage::open(path)?;
let capacity = reader.capacity_sectors();
let mut disc = Disc::scan_image(&mut reader, capacity, &opts)?;
apply_folder_encryption_verdict(&mut reader, &mut disc)?;
Ok((disc, Box::new(reader)))
crate::mux::source::probe_image(path, true, &opts)
}
// Re-judge a FOLDER's encryption verdict from its CONTENT (tree shape alone can be wrong for an