Propagate cancellation out of every title enumerator; account the .clpi read
A cancelled scan was indistinguishable from a disc that simply holds
fewer titles, in all three tree enumerators. Once the operator presses
Stop, `Drive::checked_exec` fails EVERY subsequent SCSI command with
`Error::Halted` and `Drive::read` deliberately preserves the variant —
so a cancel is not one failed read, it is every remaining read failing.
* `scan_bluray_titles` returned a bare `Vec<DiscTitle>` and
`parse_playlist` an `Option`, so neither had a channel to report a
halt. During a cancel each remaining `.mpls` read merely failed and
was skipped by an `if let Ok(..)`, and the scan returned a TRUNCATED
title list at rc=0. Both now return `Result`; `Ok(None)` from
`parse_playlist` keeps its two benign meanings (unparseable MPLS,
playlist under 30 s) and `Err` means the scan is over.
* `scan_dvd_titles` was worse: `Err(_) => return Vec::new()` turned a
cancel into ZERO titles at rc=0 — a DVD reported as carrying no
video at all. `ifo::parse_vmg` compounded it, treating a halted
title set as one of the placeholder TT_SRPT entries it is designed
to tolerate and continuing to the next; both now propagate.
* HD-DVD was already correct and is the model this follows, including
its reasoning: a cancelled enumeration that returned `Ok` would be
cached, displayed and ripped from as if it were the disc.
The cancel is propagated at ALL FOUR Blu-ray read sites, not just the
obvious one — the playlist read, the `.clpi` read, and both
`file_extents` calls in the clip resolver — and the halt is polled
between playlists and again after the loop, so the final iteration
cannot slip a truncated list through. The resolver's `note` closure
previously EXEMPTED `Halted` from its disc-defect classification, which
was right (a cancel is not an authoring hole) but left it swallowed:
with no exemption and no propagation a cancel landing on `file_extents`
returned a title claiming its full runtime with that clip's bytes
silently missing. Measured with the fix reverted: `Ok(Some((768000,
[])))`.
Second defect, same function: the `.clpi` read and `clpi::parse` sat
inside `if let Ok(..) && let Ok(..)`, with extent resolution nested
inside. A scratched CLIPINF sector, a missing `.clpi` or a malformed one
skipped the whole block — no extents, nothing added to `total_size` —
but `duration_ticks` had ALREADY been summed from the play items, so the
title still advertised the movie's full runtime. The `if let` discarded
the error, so nothing was logged either: a failure that looks like
success, which is this crate's flagship defect class. It is now a
`match` that drops the title and warns with the error's OWN code, the
same classification the extent resolver below it already uses. Logging a
fixed code would account a scratched disc as an authoring hole.
Deliberately NOT added: a post-loop "every unique clip_id must appear in
`spans`" invariant. `file_extents` can also return `Ok(vec![])`, or a
vector whose every entry the `sectors > 0 && lba > 0` filter discards,
and it is not settled that an empty-but-Ok resolve is always a defect
rather than a legitimate healthy-disc state (zero-length placeholder
ADs). Dropping healthy titles is a worse failure than that residual gap,
so the route stays open, with a comment at the site recording the
decision so the next audit finds it instead of re-deriving it.
Tests: `parse_playlist_missing_clpi_yields_no_extent_no_size` asserted a
title WAS returned with `size_bytes == 0` and empty extents, and its
docstring quoted the buggy control flow as the specification — it
blessed the defect. Inverted and renamed. `parse_playlist_keeps_exactly_
30_seconds` encodes correct behaviour but used a fixture wiring no
STREAM/CLIPINF, so the `.clpi` fix would have dropped its title for a
reason unrelated to the boundary it pins; repointed at the fully wired
fixture rather than weakening the assertion. Four new halt-propagation
tests (BD playlist read, BD `.clpi` read, BD extent resolve, DVD title
set + VMG), each proven red before green.
This commit is contained in:
+556
-169
@@ -20,40 +20,85 @@ const CLIP_STREAM_EXTS: [&str; 3] = ["m2ts", "fmts", "ssif"];
|
||||
|
||||
impl Disc {
|
||||
/// Scan Blu-ray titles from MPLS playlists.
|
||||
///
|
||||
/// Cancellation: `halt` is polled between playlists and once more after
|
||||
/// the loop, and a read that fails with [`Error::Halted`] — how a live
|
||||
/// drive reports a Stop, since `Drive::checked_exec` fails EVERY command
|
||||
/// once its flag is set and `Drive::read` preserves the variant — is
|
||||
/// propagated rather than swallowed. `Halted` is the only error this
|
||||
/// returns from the enumeration itself; an unreadable or unparseable
|
||||
/// playlist keeps its best-effort skip.
|
||||
///
|
||||
/// It has to be an error and not a short title list, for the same reason
|
||||
/// spelled out on [`Disc::scan_hddvd_titles`]: a cancelled enumeration
|
||||
/// that returned `Ok` would be indistinguishable from a disc that
|
||||
/// genuinely holds fewer titles, and the caller would cache and act on
|
||||
/// it. Before this, a Stop pressed mid-scan failed every REMAINING
|
||||
/// `.mpls` read in turn, each one silently skipped, and the scan returned
|
||||
/// `Ok(truncated)` at rc=0.
|
||||
pub(super) fn scan_bluray_titles(
|
||||
reader: &mut dyn SectorSource,
|
||||
udf_fs: &udf::UdfFs,
|
||||
) -> Vec<DiscTitle> {
|
||||
halt: Option<&crate::halt::Halt>,
|
||||
) -> Result<Vec<DiscTitle>> {
|
||||
let mut titles = Vec::new();
|
||||
if let Some(playlist_dir) = udf_fs.find_dir("/BDMV/PLAYLIST") {
|
||||
for entry in &playlist_dir.entries {
|
||||
if halt.is_some_and(|h| h.is_cancelled()) {
|
||||
return Err(Error::Halted);
|
||||
}
|
||||
if !entry.is_dir && entry.name.to_lowercase().ends_with(".mpls") {
|
||||
let path = format!("/BDMV/PLAYLIST/{}", entry.name);
|
||||
if let Ok(mpls_data) = udf_fs.read_file(reader, &path)
|
||||
&& let Some(title) =
|
||||
Self::parse_playlist(reader, udf_fs, &entry.name, &mpls_data)
|
||||
let mpls_data = match udf_fs.read_file(reader, &path) {
|
||||
Ok(data) => data,
|
||||
// A cancel that lands on the LAST `.mpls` used to end
|
||||
// the loop with nothing to show for it and still
|
||||
// return `Ok`, so the truncation was invisible.
|
||||
Err(Error::Halted) => return Err(Error::Halted),
|
||||
// Every other read failure keeps the pre-existing
|
||||
// best-effort skip: one unreadable playlist is not a
|
||||
// reason to abandon the disc.
|
||||
Err(_) => continue,
|
||||
};
|
||||
if let Some(title) =
|
||||
Self::parse_playlist(reader, udf_fs, &entry.name, &mpls_data)?
|
||||
{
|
||||
titles.push(title);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
titles
|
||||
// Polled again AFTER the loop: a cancel raised during the final
|
||||
// iteration's reads has nothing left to poll, so without this the
|
||||
// last playlist could still slip a truncated list through as success.
|
||||
if halt.is_some_and(|h| h.is_cancelled()) {
|
||||
return Err(Error::Halted);
|
||||
}
|
||||
Ok(titles)
|
||||
}
|
||||
|
||||
/// Parse one MPLS playlist into a [`DiscTitle`].
|
||||
///
|
||||
/// Sums PlayItem durations; returns `None` if the playlist is under
|
||||
/// 30 seconds (skips menu / clip-info stub playlists) or fails to
|
||||
/// parse. Physical sector extents are pulled from the UDF allocation
|
||||
/// descriptors of each referenced `.m2ts` (deduplicated by clip_id).
|
||||
/// Sums PlayItem durations; returns `Ok(None)` if the playlist is under
|
||||
/// 30 seconds (skips menu / clip-info stub playlists), fails to
|
||||
/// parse, or names a clip that cannot be resolved. Physical sector
|
||||
/// extents are pulled from the UDF allocation descriptors of each
|
||||
/// referenced `.m2ts` (deduplicated by clip_id).
|
||||
///
|
||||
/// `Ok(None)` keeps its two benign meanings (unparseable MPLS, sub-30 s
|
||||
/// playlist) plus the deliberate "drop this title" outcomes below. `Err`
|
||||
/// means the SCAN is over: today that is only [`Error::Halted`], the
|
||||
/// operator's Stop, which must not be reported as a disc that simply
|
||||
/// holds fewer titles.
|
||||
pub(super) fn parse_playlist(
|
||||
reader: &mut dyn SectorSource,
|
||||
udf_fs: &udf::UdfFs,
|
||||
filename: &str,
|
||||
data: &[u8],
|
||||
) -> Option<DiscTitle> {
|
||||
let parsed = mpls::parse(data).ok()?;
|
||||
) -> Result<Option<DiscTitle>> {
|
||||
let Ok(parsed) = mpls::parse(data) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Calculate duration from play items
|
||||
let duration_ticks: u64 = parsed
|
||||
@@ -65,7 +110,7 @@ impl Disc {
|
||||
|
||||
// Skip very short playlists (< 30 seconds)
|
||||
if duration_secs < 30.0 {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Parse each clip for size, duration, and sector extents
|
||||
@@ -98,130 +143,190 @@ impl Disc {
|
||||
|
||||
for play_item in &parsed.play_items {
|
||||
let clip_dur = play_item.out_time.saturating_sub(play_item.in_time) as f64 / 45000.0;
|
||||
let mut pkt_count: u32 = 0;
|
||||
|
||||
let clpi_path = format!("/BDMV/CLIPINF/{}.clpi", play_item.clip_id);
|
||||
if let Ok(clpi_data) = udf_fs.read_file(reader, &clpi_path)
|
||||
&& let Ok(clip_info) = clpi::parse(&clpi_data)
|
||||
// A `.clpi` that cannot be read or parsed is NOT a benign miss.
|
||||
// `duration_ticks` was already summed from the PlayItems above, so
|
||||
// the title keeps claiming its full runtime; skipping this block
|
||||
// (what the old `if let Ok(..) && let Ok(..)` did) pushed no
|
||||
// extents and added nothing to `total_size`, so the title shipped
|
||||
// with the clip's bytes silently absent — and, because the `if let`
|
||||
// DISCARDED the error, without a single log line to say so. Same
|
||||
// classification as the extent resolver below: drop the title, and
|
||||
// log the error's OWN code.
|
||||
let clip_info = match udf_fs
|
||||
.read_file(reader, &clpi_path)
|
||||
.and_then(|clpi_data| clpi::parse(&clpi_data))
|
||||
{
|
||||
pkt_count = clip_info.source_packet_count;
|
||||
Ok(info) => info,
|
||||
// The operator's Stop, not a disc defect. Every remaining
|
||||
// command fails the same way once the drive's flag is set, so
|
||||
// classifying it as an unresolvable clip would drop each
|
||||
// remaining playlist in turn and hand back a truncated title
|
||||
// list at success.
|
||||
Err(Error::Halted) => return Err(Error::Halted),
|
||||
Err(e) => {
|
||||
// The REAL code, not a fixed one: a scratched CLIPINF
|
||||
// sector (DiscRead), a missing `.clpi` (UdfNotFound) and a
|
||||
// malformed one (ClpiParse) are different populations, and
|
||||
// flattening them would send anyone triaging the first
|
||||
// after the third.
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
playlist = ?filename,
|
||||
clip = ?play_item.clip_id,
|
||||
"E{}", e.code()
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let pkt_count: u32 = clip_info.source_packet_count;
|
||||
|
||||
// Mark the clip seen ONLY after its .clpi parses — a transient
|
||||
// read/parse failure on the first PlayItem referencing a clip
|
||||
// must not permanently suppress its extents/size for a later
|
||||
// PlayItem referencing the same clip that succeeds.
|
||||
let first_ref = seen_clips.insert(play_item.clip_id.clone());
|
||||
// The clip is marked seen only after its .clpi parses. That
|
||||
// ordering used to matter because a transient failure on the first
|
||||
// PlayItem referencing a clip must not permanently suppress its
|
||||
// extents/size for a later PlayItem referencing the same clip;
|
||||
// a failure now drops the whole title, so the ordering is kept
|
||||
// simply because it is still the correct place for it.
|
||||
let first_ref = seen_clips.insert(play_item.clip_id.clone());
|
||||
|
||||
// Only fetch/push the physical extents and add to the
|
||||
// total size the first time this clip_id is seen.
|
||||
if first_ref {
|
||||
total_size += pkt_count as u64 * 192;
|
||||
// Only fetch/push the physical extents and add to the
|
||||
// total size the first time this clip_id is seen.
|
||||
if first_ref {
|
||||
total_size += pkt_count as u64 * 192;
|
||||
|
||||
// Get stream file extents from UDF allocation descriptors.
|
||||
// Dual-layer discs split files across layers — UDF knows the real layout.
|
||||
//
|
||||
// The clip's stream file is normally `.m2ts`, but AACS 2.1
|
||||
// (FMTS) discs name the main feature `.fmts` and 3D discs
|
||||
// use `.ssif` (see [`CLIP_STREAM_EXTS`]). A normal `.m2ts`
|
||||
// clip is unchanged — the fallback only runs when `.m2ts`
|
||||
// is absent, which is exactly when `file_extents` errors.
|
||||
// 3D discs interleave the left (base) and right (MVC
|
||||
// dependent) views in STREAM/SSIF/<clip>.ssif — note the
|
||||
// SSIF/ subdir. Prefer it when present: the SSIF is one
|
||||
// transport stream carrying BOTH eyes on distinct PIDs,
|
||||
// so muxing it captures the full 3D. 2D clips fall back to
|
||||
// the base .m2ts / .fmts as before.
|
||||
let ssif = format!("/BDMV/STREAM/SSIF/{}.ssif", play_item.clip_id);
|
||||
// A clip stream that carries an unrecorded (never-written)
|
||||
// extent cannot be turned into a truthful read plan — see
|
||||
// `UdfFs::file_extents`. Track it separately from an
|
||||
// ordinary "file absent" error: absence is what the
|
||||
// extension fallback exists for, whereas a hole means the
|
||||
// bytes this title needs do not exist on the disc.
|
||||
//
|
||||
// ABSENCE is the only benign failure. A missing `.ssif` is
|
||||
// the ordinary case (every 2D disc), and the extension
|
||||
// fallback below exists precisely for it. Every OTHER
|
||||
// error means the bytes this title needs could not be
|
||||
// resolved: a scratched sector under the clip's ICB
|
||||
// (DiscRead), an allocation-descriptor chain that never
|
||||
// terminated (UdfAdChainTooLong), a file whose data is
|
||||
// embedded rather than extent-mapped (UdfEmbeddedData).
|
||||
// Those used to fall through to the not-found path, so the
|
||||
// clip contributed zero extents while `total_size` and the
|
||||
// play-item timing still counted it — a title advertising
|
||||
// its full runtime with a piece silently missing, and no
|
||||
// log line anywhere.
|
||||
//
|
||||
// `Halted` is NOT a disc defect and must not be treated as
|
||||
// one. It is the operator cancelling: once the flag is
|
||||
// set, EVERY drive command returns it, so classifying it
|
||||
// here would drop each remaining playlist in turn and hand
|
||||
// back a truncated title list at success — the same shape
|
||||
// this refusal exists to prevent, wearing a cancel. This
|
||||
// function returns Option and has no channel to propagate
|
||||
// a halt, so the pre-existing behaviour is preserved
|
||||
// deliberately rather than made worse.
|
||||
let mut unresolved: Option<u16> = None;
|
||||
let mut note = |e: &Error| {
|
||||
if !matches!(e, Error::UdfNotFound { .. } | Error::Halted) {
|
||||
unresolved.get_or_insert(e.code());
|
||||
}
|
||||
};
|
||||
let file_exts = match udf_fs.file_extents(reader, &ssif) {
|
||||
Ok(exts) => {
|
||||
is_3d = true;
|
||||
Some(exts)
|
||||
}
|
||||
Err(e) => {
|
||||
note(&e);
|
||||
CLIP_STREAM_EXTS.iter().find_map(|ext| {
|
||||
let path = format!("/BDMV/STREAM/{}.{}", play_item.clip_id, ext);
|
||||
match udf_fs.file_extents(reader, &path) {
|
||||
Ok(exts) => Some(exts),
|
||||
Err(e) => {
|
||||
note(&e);
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
};
|
||||
// Nothing resolved AND a hole was the reason: drop the
|
||||
// whole title. Letting the clip contribute no extents
|
||||
// (the ordinary not-found path) would emit a title whose
|
||||
// feed is silently missing this clip's runtime while its
|
||||
// durations, spans and size still count it — data loss
|
||||
// wearing the shape of a normal rip.
|
||||
if let (None, Some(code)) = (&file_exts, unresolved) {
|
||||
// The REAL code, not a fixed one. A scratched sector
|
||||
// (E6000) and an over-long AD chain (E6016) logged as
|
||||
// E6017 would send anyone triaging them after authoring
|
||||
// holes and hide the population that actually exists.
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
playlist = ?filename,
|
||||
clip = ?play_item.clip_id,
|
||||
"E{}", code
|
||||
);
|
||||
return None;
|
||||
// Get stream file extents from UDF allocation descriptors.
|
||||
// Dual-layer discs split files across layers — UDF knows the real layout.
|
||||
//
|
||||
// The clip's stream file is normally `.m2ts`, but AACS 2.1
|
||||
// (FMTS) discs name the main feature `.fmts` and 3D discs
|
||||
// use `.ssif` (see [`CLIP_STREAM_EXTS`]). A normal `.m2ts`
|
||||
// clip is unchanged — the fallback only runs when `.m2ts`
|
||||
// is absent, which is exactly when `file_extents` errors.
|
||||
// 3D discs interleave the left (base) and right (MVC
|
||||
// dependent) views in STREAM/SSIF/<clip>.ssif — note the
|
||||
// SSIF/ subdir. Prefer it when present: the SSIF is one
|
||||
// transport stream carrying BOTH eyes on distinct PIDs,
|
||||
// so muxing it captures the full 3D. 2D clips fall back to
|
||||
// the base .m2ts / .fmts as before.
|
||||
let ssif = format!("/BDMV/STREAM/SSIF/{}.ssif", play_item.clip_id);
|
||||
// A clip stream that carries an unrecorded (never-written)
|
||||
// extent cannot be turned into a truthful read plan — see
|
||||
// `UdfFs::file_extents`. Track it separately from an
|
||||
// ordinary "file absent" error: absence is what the
|
||||
// extension fallback exists for, whereas a hole means the
|
||||
// bytes this title needs do not exist on the disc.
|
||||
//
|
||||
// ABSENCE is the only benign failure. A missing `.ssif` is
|
||||
// the ordinary case (every 2D disc), and the extension
|
||||
// fallback below exists precisely for it. Every OTHER
|
||||
// error means the bytes this title needs could not be
|
||||
// resolved: a scratched sector under the clip's ICB
|
||||
// (DiscRead), an allocation-descriptor chain that never
|
||||
// terminated (UdfAdChainTooLong), a file whose data is
|
||||
// embedded rather than extent-mapped (UdfEmbeddedData).
|
||||
// Those used to fall through to the not-found path, so the
|
||||
// clip contributed zero extents while `total_size` and the
|
||||
// play-item timing still counted it — a title advertising
|
||||
// its full runtime with a piece silently missing, and no
|
||||
// log line anywhere.
|
||||
//
|
||||
// `Halted` is NOT a disc defect and must not be treated as
|
||||
// one either. It is the operator cancelling: once the flag is
|
||||
// set, EVERY drive command returns it, so classifying it here
|
||||
// would drop each remaining playlist in turn and hand back a
|
||||
// truncated title list at success — the same shape this
|
||||
// refusal exists to prevent, wearing a cancel. It used to be
|
||||
// merely EXEMPTED (neither classified nor propagated, because
|
||||
// this function returned `Option`); exempting it is no longer
|
||||
// enough now that there is a channel. A cancel landing on
|
||||
// `file_extents` would otherwise return a title with this
|
||||
// clip's bytes silently missing — the flagship defect shape,
|
||||
// wearing a cancel. It is propagated below instead.
|
||||
let mut unresolved: Option<u16> = None;
|
||||
let mut halted = false;
|
||||
let mut note = |e: &Error| {
|
||||
if matches!(e, Error::Halted) {
|
||||
halted = true;
|
||||
} else if !matches!(e, Error::UdfNotFound { .. }) {
|
||||
unresolved.get_or_insert(e.code());
|
||||
}
|
||||
if let Some(file_exts) = file_exts {
|
||||
let span_start = feed_pos;
|
||||
for (lba, sectors) in file_exts {
|
||||
if sectors > 0 && lba > 0 {
|
||||
extents.push(Extent {
|
||||
start_lba: lba,
|
||||
sector_count: sectors,
|
||||
});
|
||||
feed_pos = feed_pos.saturating_add(
|
||||
sectors as u64 * crate::consts::SECTOR_BYTES as u64,
|
||||
);
|
||||
};
|
||||
let file_exts = match udf_fs.file_extents(reader, &ssif) {
|
||||
Ok(exts) => {
|
||||
is_3d = true;
|
||||
Some(exts)
|
||||
}
|
||||
Err(e) => {
|
||||
note(&e);
|
||||
CLIP_STREAM_EXTS.iter().find_map(|ext| {
|
||||
let path = format!("/BDMV/STREAM/{}.{}", play_item.clip_id, ext);
|
||||
match udf_fs.file_extents(reader, &path) {
|
||||
Ok(exts) => Some(exts),
|
||||
Err(e) => {
|
||||
note(&e);
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
};
|
||||
// Propagate the cancel BEFORE the classification below: a
|
||||
// halted `file_extents` resolves nothing, so the title would
|
||||
// otherwise be dropped (or, with a partial resolve, emitted
|
||||
// short) and the scan would carry on as if the disc were at
|
||||
// fault.
|
||||
if halted {
|
||||
return Err(Error::Halted);
|
||||
}
|
||||
// Nothing resolved AND a hole was the reason: drop the
|
||||
// whole title. Letting the clip contribute no extents
|
||||
// (the ordinary not-found path) would emit a title whose
|
||||
// feed is silently missing this clip's runtime while its
|
||||
// durations, spans and size still count it — data loss
|
||||
// wearing the shape of a normal rip.
|
||||
if let (None, Some(code)) = (&file_exts, unresolved) {
|
||||
// The REAL code, not a fixed one. A scratched sector
|
||||
// (E6000) and an over-long AD chain (E6016) logged as
|
||||
// E6017 would send anyone triaging them after authoring
|
||||
// holes and hide the population that actually exists.
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
playlist = ?filename,
|
||||
clip = ?play_item.clip_id,
|
||||
"E{}", code
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
// KNOWN GAP, deliberately left open: `file_extents` can also
|
||||
// return `Ok(vec![])`, or a vector every entry of which the
|
||||
// `sectors > 0 && lba > 0` filter below discards (zero-length
|
||||
// placeholder ADs — see `UdfFs::file_extents`). That clip then
|
||||
// contributes no extents and no span while `total_size` and
|
||||
// the play-item timing still count it: the same shape the
|
||||
// refusal above exists to prevent, reached by a different
|
||||
// route. It is NOT closed with a post-loop "every unique
|
||||
// clip_id must appear in `spans`" invariant because it is not
|
||||
// settled that an empty-but-Ok resolve is always a defect
|
||||
// rather than a legitimate healthy-disc state, and dropping
|
||||
// healthy titles is a worse failure than the residual gap.
|
||||
// Recorded here so the next audit finds the decision instead
|
||||
// of re-deriving it.
|
||||
if let Some(file_exts) = file_exts {
|
||||
let span_start = feed_pos;
|
||||
for (lba, sectors) in file_exts {
|
||||
if sectors > 0 && lba > 0 {
|
||||
extents.push(Extent {
|
||||
start_lba: lba,
|
||||
sector_count: sectors,
|
||||
});
|
||||
feed_pos = feed_pos.saturating_add(
|
||||
sectors as u64 * crate::consts::SECTOR_BYTES as u64,
|
||||
);
|
||||
}
|
||||
if feed_pos > span_start {
|
||||
spans.insert(play_item.clip_id.clone(), (span_start, feed_pos));
|
||||
}
|
||||
}
|
||||
if feed_pos > span_start {
|
||||
spans.insert(play_item.clip_id.clone(), (span_start, feed_pos));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -412,7 +517,7 @@ impl Disc {
|
||||
.unwrap_or(filename);
|
||||
let playlist_id = playlist_num.parse::<u16>().unwrap_or(0);
|
||||
|
||||
Some(DiscTitle {
|
||||
Ok(Some(DiscTitle {
|
||||
playlist: filename.to_string(),
|
||||
playlist_id,
|
||||
duration_secs,
|
||||
@@ -423,7 +528,7 @@ impl Disc {
|
||||
extents,
|
||||
content_format: ContentFormat::BdTs,
|
||||
codec_privates: Vec::new(),
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
/// Read disc title from META/DL/bdmt_eng.xml (Blu-ray Disc Meta Table).
|
||||
@@ -651,12 +756,21 @@ mod tests {
|
||||
);
|
||||
let udf = make_min_fs(&mut disc);
|
||||
assert!(
|
||||
Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).is_none(),
|
||||
Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls)
|
||||
.expect("scan")
|
||||
.is_none(),
|
||||
"playlists shorter than 30s must be skipped"
|
||||
);
|
||||
}
|
||||
|
||||
/// At exactly 30 s the playlist is kept (`< 30.0` is strict).
|
||||
///
|
||||
/// The fixture is a FULLY WIRED BDMV (`make_bdmv_fs`), not the bare
|
||||
/// `make_min_fs` this used to use. `make_min_fs` lays no STREAM/CLIPINF,
|
||||
/// so the play item's `.clpi` read now fails and the title drops — which
|
||||
/// is correct behaviour for an unresolvable clip but has nothing to do
|
||||
/// with the 30-second boundary this test exists to pin. Wiring the clip
|
||||
/// keeps the test measuring exactly one thing.
|
||||
#[test]
|
||||
fn parse_playlist_keeps_exactly_30_seconds() {
|
||||
let mut disc = MemDisc::new();
|
||||
@@ -670,8 +784,9 @@ mod tests {
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
let udf = make_min_fs(&mut disc);
|
||||
let udf = make_bdmv_fs(&mut disc, &[("00001", 100, 400, 5000)]);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls)
|
||||
.expect("scan")
|
||||
.expect("30s playlist must be kept");
|
||||
assert!((t.duration_secs - 30.0).abs() < 1e-6);
|
||||
}
|
||||
@@ -683,7 +798,11 @@ mod tests {
|
||||
let mut disc = MemDisc::new();
|
||||
let udf = make_min_fs(&mut disc);
|
||||
let junk = vec![0u8; 100];
|
||||
assert!(Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &junk).is_none());
|
||||
assert!(
|
||||
Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &junk)
|
||||
.expect("scan")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a full BDMV tree with one STREAM/.m2ts (Long-AD ICB) and one
|
||||
@@ -881,7 +1000,9 @@ mod tests {
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls)
|
||||
.expect("scan")
|
||||
.expect("title");
|
||||
// BD source packet = 192 bytes (188 TS + 4-byte timestamp header).
|
||||
assert_eq!(t.size_bytes, 4000 * 192);
|
||||
assert_eq!(t.extents.len(), 1, "one m2ts → one extent");
|
||||
@@ -910,7 +1031,9 @@ mod tests {
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls)
|
||||
.expect("scan")
|
||||
.expect("title");
|
||||
assert_eq!(t.clips.len(), 1);
|
||||
assert!(
|
||||
(t.clips[0].duration_secs - 75.0).abs() < 1e-6,
|
||||
@@ -939,7 +1062,9 @@ mod tests {
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls)
|
||||
.expect("scan")
|
||||
.expect("title");
|
||||
assert_eq!(t.size_bytes, 4000 * 192, "size from .clpi source packets");
|
||||
assert_eq!(
|
||||
t.extents.len(),
|
||||
@@ -978,7 +1103,9 @@ mod tests {
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls)
|
||||
.expect("scan")
|
||||
.expect("title");
|
||||
// Extent and size counted ONCE despite two PlayItems.
|
||||
assert_eq!(
|
||||
t.extents.len(),
|
||||
@@ -1022,19 +1149,33 @@ mod tests {
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls)
|
||||
.expect("scan")
|
||||
.expect("title");
|
||||
assert_eq!(t.extents.len(), 2);
|
||||
assert_eq!(t.extents[0].start_lba, PART_START + 5000);
|
||||
assert_eq!(t.extents[1].start_lba, PART_START + 9000);
|
||||
assert_eq!(t.size_bytes, (4000 + 2000) * 192);
|
||||
}
|
||||
|
||||
/// A clip whose .clpi is missing contributes NO size and NO extent
|
||||
/// (bluray.rs only fetches extents inside the `if let Ok(clpi_data)`
|
||||
/// and `if let Ok(clip_info)` blocks), but the Clip entry is still
|
||||
/// recorded with packet count 0. Never panics on the missing read.
|
||||
/// A clip whose `.clpi` is missing must yield NO TITLE.
|
||||
///
|
||||
/// This test used to assert the opposite — a title with `size_bytes == 0`
|
||||
/// and empty extents — and its docstring quoted the buggy control flow
|
||||
/// ("bluray.rs only fetches extents inside the `if let Ok(clpi_data)`")
|
||||
/// as if it were the specification. It was blessing the defect: the
|
||||
/// title's `duration_secs` is summed from the PlayItems BEFORE the clip is
|
||||
/// resolved, so the returned title advertised the movie's full runtime
|
||||
/// while carrying none of its bytes, and the discarded error meant not one
|
||||
/// log line said so. That is the flagship failure class of this crate — a
|
||||
/// failure that looks like success — reached through an ordinary missing
|
||||
/// or scratched CLIPINF file.
|
||||
///
|
||||
/// The correct behaviour is the same as for a clip whose extents cannot be
|
||||
/// resolved (see `parse_playlist_unreadable_clip_icb_yields_no_title`):
|
||||
/// drop the title and log the read's OWN error code.
|
||||
#[test]
|
||||
fn parse_playlist_missing_clpi_yields_no_extent_no_size() {
|
||||
fn parse_playlist_missing_clpi_yields_no_title() {
|
||||
let mut disc = MemDisc::new();
|
||||
// STREAM has the m2ts but CLIPINF is empty for this clip.
|
||||
let udf = make_bdmv_fs(&mut disc, &[]); // no clips wired
|
||||
@@ -1086,11 +1227,14 @@ mod tests {
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00009.mpls", &mpls).expect("title");
|
||||
assert_eq!(t.size_bytes, 0, "no clpi → no size contribution");
|
||||
assert!(t.extents.is_empty(), "no clpi → no extent fetched");
|
||||
assert_eq!(t.clips.len(), 1);
|
||||
assert_eq!(t.clips[0].source_packets, 0);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00009.mpls", &mpls).expect("scan");
|
||||
assert!(
|
||||
t.is_none(),
|
||||
"a clip with no .clpi cannot be sized or resolved, so offering the \
|
||||
title would advertise the full play-item runtime with none of the \
|
||||
clip's bytes behind it; got {:?}",
|
||||
t.map(|t| (t.size_bytes, t.extents))
|
||||
);
|
||||
}
|
||||
|
||||
/// A clip stream whose ICB declares an UNRECORDED (ECMA-167 4/14.14.1.1
|
||||
@@ -1192,7 +1336,7 @@ mod tests {
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("scan");
|
||||
assert!(
|
||||
t.is_none(),
|
||||
"the only clip has no truthful read plan, so offering the title \
|
||||
@@ -1276,7 +1420,7 @@ mod tests {
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("scan");
|
||||
assert!(
|
||||
t.is_none(),
|
||||
"a clip whose extents could not be resolved must drop the title, \
|
||||
@@ -1306,7 +1450,9 @@ mod tests {
|
||||
&[se_video(0x1011, 0x24)],
|
||||
&[],
|
||||
);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls)
|
||||
.expect("scan")
|
||||
.expect("title");
|
||||
let videos: Vec<_> = t
|
||||
.streams
|
||||
.iter()
|
||||
@@ -1338,7 +1484,9 @@ mod tests {
|
||||
&[se_video_hevc(0x1011, 1, 2)],
|
||||
&[],
|
||||
);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls)
|
||||
.expect("scan")
|
||||
.expect("title");
|
||||
let v = t
|
||||
.streams
|
||||
.iter()
|
||||
@@ -1367,7 +1515,9 @@ mod tests {
|
||||
&[se_video_hevc(0x1011, 2, 1)],
|
||||
&[],
|
||||
);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls)
|
||||
.expect("scan")
|
||||
.expect("title");
|
||||
let v = t
|
||||
.streams
|
||||
.iter()
|
||||
@@ -1400,7 +1550,9 @@ mod tests {
|
||||
&[se_pg(0x1100, 0x90, b"eng")],
|
||||
&[],
|
||||
);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls)
|
||||
.expect("scan")
|
||||
.expect("title");
|
||||
assert!(
|
||||
t.streams.iter().all(|s| !matches!(s, Stream::Audio(_))),
|
||||
"PGS in audio slot must NOT become an audio stream"
|
||||
@@ -1428,7 +1580,9 @@ mod tests {
|
||||
&[se_audio(0x1100, 0x81, b"eng")],
|
||||
&[],
|
||||
);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls)
|
||||
.expect("scan")
|
||||
.expect("title");
|
||||
let audios: Vec<_> = t
|
||||
.streams
|
||||
.iter()
|
||||
@@ -1463,7 +1617,9 @@ mod tests {
|
||||
&[se_audio(0x1a00, 0x83, b"eng")],
|
||||
&[],
|
||||
);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls)
|
||||
.expect("scan")
|
||||
.expect("title");
|
||||
let audios: Vec<_> = t
|
||||
.streams
|
||||
.iter()
|
||||
@@ -1494,7 +1650,9 @@ mod tests {
|
||||
&[se_pg(0x1200, 0x90, b"fra")],
|
||||
&[],
|
||||
);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls)
|
||||
.expect("scan")
|
||||
.expect("title");
|
||||
let subs: Vec<_> = t
|
||||
.streams
|
||||
.iter()
|
||||
@@ -1531,7 +1689,9 @@ mod tests {
|
||||
&[se_video(0x1011, 0x1B)],
|
||||
&[],
|
||||
);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls)
|
||||
.expect("scan")
|
||||
.expect("title");
|
||||
let videos: Vec<_> = t
|
||||
.streams
|
||||
.iter()
|
||||
@@ -1575,7 +1735,9 @@ mod tests {
|
||||
&[se_video(0x1011, 0x1B), se_video(0x1012, 0x1B)],
|
||||
&[],
|
||||
);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls)
|
||||
.expect("scan")
|
||||
.expect("title");
|
||||
let dep_count = t
|
||||
.streams
|
||||
.iter()
|
||||
@@ -1629,7 +1791,9 @@ mod tests {
|
||||
}, // reserved → drop
|
||||
],
|
||||
);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls)
|
||||
.expect("scan")
|
||||
.expect("title");
|
||||
assert_eq!(
|
||||
t.chapters.len(),
|
||||
2,
|
||||
@@ -1668,7 +1832,9 @@ mod tests {
|
||||
timestamp: pi1_in, // at the very start of PI1
|
||||
}],
|
||||
);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls)
|
||||
.expect("scan")
|
||||
.expect("title");
|
||||
assert_eq!(t.chapters.len(), 1);
|
||||
// preceding (PI0 = 60s) + within (timestamp - pi1.in_time = 0) = 60s.
|
||||
assert!(
|
||||
@@ -1710,7 +1876,9 @@ mod tests {
|
||||
timestamp: pi1_in + within_ticks,
|
||||
}],
|
||||
);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls)
|
||||
.expect("scan")
|
||||
.expect("title");
|
||||
assert_eq!(t.chapters.len(), 1);
|
||||
assert!(
|
||||
(t.chapters[0].time_secs - 65.0).abs() < 1e-6,
|
||||
@@ -1740,7 +1908,9 @@ mod tests {
|
||||
timestamp: 0, // before in_time → would be negative
|
||||
}],
|
||||
);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls)
|
||||
.expect("scan")
|
||||
.expect("title");
|
||||
assert_eq!(t.chapters.len(), 1);
|
||||
assert_eq!(t.chapters[0].time_secs, 0.0);
|
||||
}
|
||||
@@ -1765,7 +1935,9 @@ mod tests {
|
||||
timestamp: 0,
|
||||
}],
|
||||
);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls)
|
||||
.expect("scan")
|
||||
.expect("title");
|
||||
assert!(
|
||||
t.chapters.is_empty(),
|
||||
"out-of-range mark ref must be dropped"
|
||||
@@ -1793,7 +1965,9 @@ mod tests {
|
||||
&[],
|
||||
);
|
||||
// Uppercase suffix must still parse the numeric stem.
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00800.MPLS", &mpls).expect("title");
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00800.MPLS", &mpls)
|
||||
.expect("scan")
|
||||
.expect("title");
|
||||
assert_eq!(t.playlist_id, 800);
|
||||
assert_eq!(
|
||||
t.playlist, "00800.MPLS",
|
||||
@@ -1817,7 +1991,9 @@ mod tests {
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "MENU.mpls", &mpls).expect("title");
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "MENU.mpls", &mpls)
|
||||
.expect("scan")
|
||||
.expect("title");
|
||||
assert_eq!(t.playlist_id, 0);
|
||||
}
|
||||
|
||||
@@ -1843,7 +2019,9 @@ mod tests {
|
||||
// "00800zzzzz": stripping the last 5 bytes would leave "00800" (a
|
||||
// valid u16), but the suffix isn't ".mpls" so nothing may be
|
||||
// stripped -- the whole (non-numeric) string must fail to parse.
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00800zzzzz", &mpls).expect("title");
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00800zzzzz", &mpls)
|
||||
.expect("scan")
|
||||
.expect("title");
|
||||
assert_eq!(
|
||||
t.playlist_id, 0,
|
||||
"a filename not ending in .mpls must not have its last 5 bytes stripped"
|
||||
@@ -1923,7 +2101,7 @@ mod tests {
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
let titles = Disc::scan_bluray_titles(&mut disc, &udf);
|
||||
let titles = Disc::scan_bluray_titles(&mut disc, &udf, None).expect("scan");
|
||||
assert_eq!(titles.len(), 1, "only the 2h playlist should survive");
|
||||
assert_eq!(titles[0].playlist_id, 800);
|
||||
}
|
||||
@@ -1971,7 +2149,7 @@ mod tests {
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
let titles = Disc::scan_bluray_titles(&mut disc, &udf);
|
||||
let titles = Disc::scan_bluray_titles(&mut disc, &udf, None).expect("scan");
|
||||
assert!(
|
||||
titles.is_empty(),
|
||||
"a PLAYLIST entry not ending in .mpls must be skipped regardless of content"
|
||||
@@ -1984,7 +2162,216 @@ mod tests {
|
||||
fn scan_bluray_titles_no_playlist_dir_is_empty() {
|
||||
let mut disc = MemDisc::new();
|
||||
let udf = make_min_fs(&mut disc); // BDMV exists, no PLAYLIST
|
||||
assert!(Disc::scan_bluray_titles(&mut disc, &udf).is_empty());
|
||||
assert!(
|
||||
Disc::scan_bluray_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
/// A `SectorSource` that fails every read in `halt_range` with
|
||||
/// [`Error::Halted`] — exactly how a LIVE DRIVE behaves once the operator
|
||||
/// presses Stop: `Drive::checked_exec` fails every SCSI command with
|
||||
/// `Halted` from then on, and `Drive::read` deliberately preserves the
|
||||
/// variant. Reads outside the range still succeed, so a test can aim the
|
||||
/// cancel at ONE structure and leave the scan far enough along to have
|
||||
/// something to truncate.
|
||||
struct HaltingReader<'a> {
|
||||
inner: &'a mut MemDisc,
|
||||
halt_range: std::ops::Range<u32>,
|
||||
}
|
||||
impl SectorSource for HaltingReader<'_> {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
recovery: bool,
|
||||
) -> Result<usize> {
|
||||
if self.halt_range.contains(&lba) {
|
||||
return Err(Error::Halted);
|
||||
}
|
||||
self.inner.read_sectors(lba, count, buf, recovery)
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay a BDMV holding TWO 2-hour playlists (00800.mpls at data LBA 30000,
|
||||
/// 00801.mpls at 40000) over one fully wired clip. Both playlists are
|
||||
/// keepable, so a scan that returns fewer than two titles has LOST one.
|
||||
fn two_playlist_bd_fs(disc: &mut MemDisc) -> udf::UdfFs {
|
||||
let long_mpls = || {
|
||||
build_mpls(
|
||||
&[PiSpec {
|
||||
clip_id: *b"00001",
|
||||
in_time: 0,
|
||||
out_time: 7200 * 45000, // 2 h
|
||||
}],
|
||||
(0, 0, 0, 0, 0, 0, 0, 0),
|
||||
&[],
|
||||
&[],
|
||||
)
|
||||
};
|
||||
let stream = DirSpec {
|
||||
name: "STREAM".to_string(),
|
||||
icb_lba: 22,
|
||||
dir_data_lba: 23,
|
||||
files: vec![file("00001.m2ts", 100, 5000, 1000 * 2048, true)],
|
||||
subdirs: vec![],
|
||||
};
|
||||
let clipinf = DirSpec {
|
||||
name: "CLIPINF".to_string(),
|
||||
icb_lba: 24,
|
||||
dir_data_lba: 25,
|
||||
files: vec![file_with("00001.clpi", 102, 8000, build_clpi(4000), false)],
|
||||
subdirs: vec![],
|
||||
};
|
||||
let playlist = DirSpec {
|
||||
name: "PLAYLIST".to_string(),
|
||||
icb_lba: 26,
|
||||
dir_data_lba: 27,
|
||||
files: vec![
|
||||
file_with("00800.mpls", 104, 30000, long_mpls(), false),
|
||||
file_with("00801.mpls", 110, 40000, long_mpls(), false),
|
||||
],
|
||||
subdirs: vec![],
|
||||
};
|
||||
let bdmv = DirSpec {
|
||||
name: "BDMV".to_string(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![stream, clipinf, playlist],
|
||||
};
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![bdmv],
|
||||
};
|
||||
build_udf_skeleton(disc, 10);
|
||||
lay_dir(disc, &root);
|
||||
udf::read_filesystem(disc).expect("fs")
|
||||
}
|
||||
|
||||
/// A Stop on a LIVE DRIVE never touches `ScanOptions::halt`: `Drive` has
|
||||
/// its own flag and `checked_exec` fails every SCSI command with
|
||||
/// [`Error::Halted`] once it is set. The Blu-ray enumerator must not
|
||||
/// swallow that into a successful scan.
|
||||
///
|
||||
/// RED BEFORE GREEN: with the propagation reverted this returned
|
||||
/// `Ok([00800])` — the `if let Ok(mpls_data)` skipped the cancelled read
|
||||
/// of 00801.mpls, the loop ended, and a HALF-ENUMERATED disc came back at
|
||||
/// success. One title from a two-title disc is indistinguishable from a
|
||||
/// disc that genuinely holds one title, and the caller caches and rips
|
||||
/// from it.
|
||||
///
|
||||
/// The halt lands on the SECOND playlist deliberately: it is the last
|
||||
/// iteration, so nothing after it would poll a flag — only propagating
|
||||
/// the read's own error catches it.
|
||||
#[test]
|
||||
fn halted_playlist_read_is_not_reported_as_a_shorter_disc() {
|
||||
let mut disc = MemDisc::new();
|
||||
let udf = two_playlist_bd_fs(&mut disc);
|
||||
// Sanity: both playlists enumerate when nothing is cancelled, so a
|
||||
// truncated result below can only be the cancel.
|
||||
assert_eq!(
|
||||
Disc::scan_bluray_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.len(),
|
||||
2,
|
||||
"fixture must offer two keepable playlists"
|
||||
);
|
||||
let mut reader = HaltingReader {
|
||||
inner: &mut disc,
|
||||
halt_range: PART_START + 40000..u32::MAX, // 00801.mpls's data extent
|
||||
};
|
||||
let res = Disc::scan_bluray_titles(&mut reader, &udf, None);
|
||||
assert!(
|
||||
matches!(res, Err(Error::Halted)),
|
||||
"a read cancelled by the drive's own halt flag must surface as a \
|
||||
cancelled scan, not as a shorter title list; got {:?}",
|
||||
res.map(|ts| ts.iter().map(|t| t.playlist.clone()).collect::<Vec<_>>())
|
||||
);
|
||||
}
|
||||
|
||||
/// The same cancel landing on a `.clpi` read must not be classified as an
|
||||
/// unresolvable clip either.
|
||||
///
|
||||
/// RED BEFORE GREEN: with the `Err(Error::Halted)` arm removed from the
|
||||
/// CLIPINF match, the cancel fell into the generic "clip could not be
|
||||
/// resolved" arm, which logs a disc-defect code and drops the title —
|
||||
/// accounting an operator Stop as a scratched disc, and (in the scan loop)
|
||||
/// dropping every remaining playlist in turn for a truncated `Ok`. With
|
||||
/// the fix the cancel is propagated with its own variant intact.
|
||||
#[test]
|
||||
fn halted_clpi_read_is_not_accounted_as_an_unresolvable_clip() {
|
||||
let mut disc = MemDisc::new();
|
||||
let udf = two_playlist_bd_fs(&mut disc);
|
||||
let mpls = build_mpls(
|
||||
&[PiSpec {
|
||||
clip_id: *b"00001",
|
||||
in_time: 0,
|
||||
out_time: 7200 * 45000,
|
||||
}],
|
||||
(0, 0, 0, 0, 0, 0, 0, 0),
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
// Only the CLIPINF data extent is cancelled; the MPLS bytes are
|
||||
// already in hand and every other structure reads normally.
|
||||
let mut reader = HaltingReader {
|
||||
inner: &mut disc,
|
||||
halt_range: PART_START + 8000..PART_START + 8001,
|
||||
};
|
||||
let res = Disc::parse_playlist(&mut reader, &udf, "00800.mpls", &mpls);
|
||||
assert!(
|
||||
matches!(res, Err(Error::Halted)),
|
||||
"a cancelled .clpi read must propagate, not drop the title as a \
|
||||
disc defect; got {:?}",
|
||||
res.map(|t| t.map(|t| (t.size_bytes, t.extents)))
|
||||
);
|
||||
}
|
||||
|
||||
/// And the same cancel landing on `file_extents` — the clip's ICB, not its
|
||||
/// CLIPINF — must propagate too.
|
||||
///
|
||||
/// RED BEFORE GREEN: `note` used to EXEMPT `Halted` from the unresolved
|
||||
/// classification (correctly — a cancel is not an authoring hole) but had
|
||||
/// no way to propagate it, so the resolver simply produced no extents and
|
||||
/// `parse_playlist` returned a title with the clip's runtime counted, its
|
||||
/// `size_bytes` counted from the .clpi, and ZERO bytes behind it. Measured
|
||||
/// with the propagation reverted: `Ok(Some((768000, [])))` — the flagship
|
||||
/// defect shape, wearing a cancel.
|
||||
#[test]
|
||||
fn halted_extent_resolve_is_not_a_title_missing_its_clip() {
|
||||
let mut disc = MemDisc::new();
|
||||
let udf = two_playlist_bd_fs(&mut disc);
|
||||
let mpls = build_mpls(
|
||||
&[PiSpec {
|
||||
clip_id: *b"00001",
|
||||
in_time: 0,
|
||||
out_time: 7200 * 45000,
|
||||
}],
|
||||
(0, 0, 0, 0, 0, 0, 0, 0),
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
// ONE sector is cancelled: the .m2ts ICB (metadata LBA 100), which is
|
||||
// precisely and only where `file_extents` looks. The .clpi (ICB 102,
|
||||
// data 8000) still reads, so the clip is sized and the earlier
|
||||
// CLIPINF arm is not the thing under test here.
|
||||
let mut reader = HaltingReader {
|
||||
inner: &mut disc,
|
||||
halt_range: PART_START + 100..PART_START + 101,
|
||||
};
|
||||
let res = Disc::parse_playlist(&mut reader, &udf, "00800.mpls", &mpls);
|
||||
assert!(
|
||||
matches!(res, Err(Error::Halted)),
|
||||
"a cancelled extent resolve must propagate, not yield a title \
|
||||
claiming its full runtime with none of the clip's bytes; got {:?}",
|
||||
res.map(|t| t.map(|t| (t.size_bytes, t.extents)))
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
+161
-19
@@ -7,13 +7,32 @@ use crate::udf;
|
||||
|
||||
impl Disc {
|
||||
/// Scan DVD titles from IFO files (VIDEO_TS.IFO + VTS_XX_0.IFO).
|
||||
///
|
||||
/// Cancellation: `halt` is polled before the IFO tree is read, and an IFO
|
||||
/// read that fails with [`Error::Halted`] — how a live drive reports a
|
||||
/// Stop, since `Drive::checked_exec` fails EVERY command once its flag is
|
||||
/// set — is propagated rather than swallowed. Every other IFO failure
|
||||
/// keeps its best-effort `Ok(vec![])`.
|
||||
///
|
||||
/// It has to be an error and not an empty title list, for the same reason
|
||||
/// spelled out on [`Disc::scan_hddvd_titles`]: a cancelled enumeration
|
||||
/// that returned `Ok` would be indistinguishable from a disc that
|
||||
/// genuinely holds fewer titles. This one was the worst of the three
|
||||
/// enumerators — a bare `Err(_) => return Vec::new()` turned an operator
|
||||
/// Stop into ZERO titles at rc=0, a disc reported as carrying no video at
|
||||
/// all.
|
||||
pub(super) fn scan_dvd_titles(
|
||||
reader: &mut dyn SectorSource,
|
||||
udf_fs: &udf::UdfFs,
|
||||
) -> Vec<DiscTitle> {
|
||||
halt: Option<&crate::halt::Halt>,
|
||||
) -> Result<Vec<DiscTitle>> {
|
||||
if halt.is_some_and(|h| h.is_cancelled()) {
|
||||
return Err(Error::Halted);
|
||||
}
|
||||
let dvd_info = match ifo::parse_vmg(reader, udf_fs) {
|
||||
Ok(info) => info,
|
||||
Err(_) => return Vec::new(),
|
||||
Err(Error::Halted) => return Err(Error::Halted),
|
||||
Err(_) => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
let mut titles = Vec::new();
|
||||
@@ -243,7 +262,14 @@ impl Disc {
|
||||
}
|
||||
}
|
||||
|
||||
titles
|
||||
// Polled again AFTER the loop: a cancel raised during the IFO reads
|
||||
// that `parse_vmg` performs per title set has nothing left to poll,
|
||||
// so without this a partially enumerated disc could still be handed
|
||||
// back as success.
|
||||
if halt.is_some_and(|h| h.is_cancelled()) {
|
||||
return Err(Error::Halted);
|
||||
}
|
||||
Ok(titles)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,6 +554,118 @@ mod tests {
|
||||
// Tests
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/// A `SectorSource` that fails every read at or above `halt_at` with
|
||||
/// [`Error::Halted`] — how a LIVE DRIVE behaves once the operator presses
|
||||
/// Stop: `Drive::checked_exec` fails every SCSI command with `Halted` from
|
||||
/// then on, and `Drive::read` deliberately preserves the variant. Reads
|
||||
/// below the threshold still succeed, so the scan gets far enough to have
|
||||
/// something to truncate.
|
||||
struct HaltingReader<'a> {
|
||||
inner: &'a mut MemDisc,
|
||||
halt_at: u32,
|
||||
}
|
||||
impl SectorSource for HaltingReader<'_> {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
recovery: bool,
|
||||
) -> crate::error::Result<usize> {
|
||||
if lba >= self.halt_at {
|
||||
return Err(crate::error::Error::Halted);
|
||||
}
|
||||
self.inner.read_sectors(lba, count, buf, recovery)
|
||||
}
|
||||
}
|
||||
|
||||
/// A Stop on a LIVE DRIVE never touches `ScanOptions::halt`: `Drive` has
|
||||
/// its own flag and `checked_exec` fails every SCSI command with
|
||||
/// [`Error::Halted`] once it is set. The DVD enumerator must not swallow
|
||||
/// that into a successful scan.
|
||||
///
|
||||
/// This was the worst of the three enumerators. RED BEFORE GREEN, two
|
||||
/// distinct swallows, both measured with the fix reverted:
|
||||
/// * `ifo::parse_vmg` treats a failed title set as a placeholder entry
|
||||
/// and continues, so a cancel landing on VTS_02's IFO returned
|
||||
/// `Ok([VTS_01_1.VOB])` — one title from a two-title disc.
|
||||
/// * `scan_dvd_titles`'s `Err(_) => return Vec::new()` turned a cancel
|
||||
/// landing on VIDEO_TS.IFO itself into ZERO titles at rc=0 — a disc
|
||||
/// reported as holding no video at all.
|
||||
///
|
||||
/// Both are indistinguishable from a real disc, and both are now
|
||||
/// `Err(Error::Halted)`.
|
||||
#[test]
|
||||
fn halted_ifo_read_is_not_reported_as_a_shorter_disc() {
|
||||
// Two title sets: VTS_01's IFO data at PART_START+6000, VTS_02's at
|
||||
// PART_START+7000. Both ICBs sit far below, so the filesystem
|
||||
// metadata resolves and only the second title set's CONTENT is
|
||||
// cancelled — the truncation case.
|
||||
let mut disc = MemDisc::new();
|
||||
let vmg = build_vmg(&[(1, 1, 1), (1, 2, 1)]);
|
||||
let vts1 = build_vts(100, 0x00, &[], &[], &[(0, 9)], false);
|
||||
let vts2 = build_vts(200, 0x00, &[], &[], &[(0, 19)], false);
|
||||
let udf = build_video_ts_fs(
|
||||
&mut disc,
|
||||
&[
|
||||
FileSpec {
|
||||
name: "VIDEO_TS.IFO".into(),
|
||||
icb_lba: 60,
|
||||
data_lba: 5000,
|
||||
contents: vmg,
|
||||
},
|
||||
FileSpec {
|
||||
name: "VTS_01_0.IFO".into(),
|
||||
icb_lba: 62,
|
||||
data_lba: 6000,
|
||||
contents: vts1,
|
||||
},
|
||||
FileSpec {
|
||||
name: "VTS_02_0.IFO".into(),
|
||||
icb_lba: 64,
|
||||
data_lba: 7000,
|
||||
contents: vts2,
|
||||
},
|
||||
],
|
||||
);
|
||||
// Sanity: both title sets enumerate when nothing is cancelled, so a
|
||||
// short list below can only be the cancel.
|
||||
assert_eq!(
|
||||
Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.len(),
|
||||
2,
|
||||
"fixture must offer two title sets"
|
||||
);
|
||||
|
||||
let mut reader = HaltingReader {
|
||||
inner: &mut disc,
|
||||
halt_at: PART_START + 7000, // VTS_02_0.IFO's data extent
|
||||
};
|
||||
let res = Disc::scan_dvd_titles(&mut reader, &udf, None);
|
||||
assert!(
|
||||
matches!(res, Err(crate::error::Error::Halted)),
|
||||
"a cancelled title-set read must surface as a cancelled scan, not \
|
||||
as a disc with fewer titles; got {:?}",
|
||||
res.map(|ts| ts.iter().map(|t| t.playlist.clone()).collect::<Vec<_>>())
|
||||
);
|
||||
|
||||
// The same cancel one level up: VIDEO_TS.IFO itself. This is the
|
||||
// `Err(_) => Vec::new()` path — a cancel that used to report a DVD as
|
||||
// carrying no titles whatsoever.
|
||||
let mut reader = HaltingReader {
|
||||
inner: &mut disc,
|
||||
halt_at: PART_START + 5000,
|
||||
};
|
||||
let res = Disc::scan_dvd_titles(&mut reader, &udf, None);
|
||||
assert!(
|
||||
matches!(res, Err(crate::error::Error::Halted)),
|
||||
"a cancelled VMG read must surface as a cancelled scan, not as an \
|
||||
empty disc; got {:?}",
|
||||
res.map(|ts| ts.len())
|
||||
);
|
||||
}
|
||||
|
||||
/// scan_dvd_titles returns empty when VIDEO_TS.IFO can't be parsed
|
||||
/// (dvd.rs: `parse_vmg(...) Err → return Vec::new()`). Never panics.
|
||||
#[test]
|
||||
@@ -535,7 +673,11 @@ mod tests {
|
||||
let mut disc = MemDisc::new();
|
||||
// VIDEO_TS exists but VIDEO_TS.IFO is missing.
|
||||
let udf = build_video_ts_fs(&mut disc, &[]);
|
||||
assert!(Disc::scan_dvd_titles(&mut disc, &udf).is_empty());
|
||||
assert!(
|
||||
Disc::scan_dvd_titles(&mut disc, &udf, None)
|
||||
.expect("scan")
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
/// Single VTS, single title, one cell. Extent absolute LBA =
|
||||
@@ -571,7 +713,7 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let titles = Disc::scan_dvd_titles(&mut disc, &udf);
|
||||
let titles = Disc::scan_dvd_titles(&mut disc, &udf, None).expect("scan");
|
||||
assert_eq!(titles.len(), 1);
|
||||
let t = &titles[0];
|
||||
assert_eq!(t.extents.len(), 1);
|
||||
@@ -627,7 +769,7 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let titles = Disc::scan_dvd_titles(&mut disc, &udf);
|
||||
let titles = Disc::scan_dvd_titles(&mut disc, &udf, None).expect("scan");
|
||||
assert_eq!(titles.len(), 1);
|
||||
let t = &titles[0];
|
||||
assert_eq!(t.extents.len(), 1);
|
||||
@@ -685,7 +827,7 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None).expect("scan")[0];
|
||||
assert_eq!(t.extents.len(), 1);
|
||||
let got = t.extents[0].start_lba;
|
||||
// The one correct answer: all three terms summed (9000 + 700 + 33).
|
||||
@@ -743,7 +885,7 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None).expect("scan")[0];
|
||||
assert_eq!(t.extents.len(), 2);
|
||||
assert_eq!(t.extents[0].start_lba, 9500); // ifo_lba(9000) + 500 + 0
|
||||
assert_eq!(t.extents[0].sector_count, 100);
|
||||
@@ -784,7 +926,7 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None).expect("scan")[0];
|
||||
let v = t
|
||||
.streams
|
||||
.iter()
|
||||
@@ -839,7 +981,7 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None).expect("scan")[0];
|
||||
let v = t
|
||||
.streams
|
||||
.iter()
|
||||
@@ -900,7 +1042,7 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None).expect("scan")[0];
|
||||
let audios: Vec<_> = t
|
||||
.streams
|
||||
.iter()
|
||||
@@ -970,7 +1112,7 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None).expect("scan")[0];
|
||||
let audios: Vec<_> = t
|
||||
.streams
|
||||
.iter()
|
||||
@@ -1027,7 +1169,7 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None).expect("scan")[0];
|
||||
let subs: Vec<_> = t
|
||||
.streams
|
||||
.iter()
|
||||
@@ -1087,7 +1229,7 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None).expect("scan")[0];
|
||||
let sub = t
|
||||
.streams
|
||||
.iter()
|
||||
@@ -1141,7 +1283,7 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let titles = Disc::scan_dvd_titles(&mut disc, &udf);
|
||||
let titles = Disc::scan_dvd_titles(&mut disc, &udf, None).expect("scan");
|
||||
assert_eq!(titles.len(), 2);
|
||||
// title_number is a running counter across all title sets.
|
||||
assert_eq!(titles[0].playlist_id, 1);
|
||||
@@ -1178,7 +1320,7 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None).expect("scan")[0];
|
||||
// One program in the program map → one chapter time (0.0 for the
|
||||
// first program). Name is the ordinal from chapter_name(0).
|
||||
assert_eq!(t.chapters.len(), 1);
|
||||
@@ -1263,7 +1405,7 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None).expect("scan")[0];
|
||||
// The leading 0x90 cell is dropped: 2 feature extents, not 3.
|
||||
assert_eq!(t.extents.len(), 2, "leading angle sub-block cell dropped");
|
||||
// First extent starts at the feature cell (vob 1000 + 100), not at 1000+0.
|
||||
@@ -1315,7 +1457,7 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf, None).expect("scan")[0];
|
||||
// Nothing dropped: both cells become extents, starting at the very head.
|
||||
assert_eq!(t.extents.len(), 2);
|
||||
assert_eq!(t.extents[0].start_lba, 9000 + 1000); // ifo_lba + vtstt + 0, head intact
|
||||
@@ -1358,7 +1500,7 @@ mod tests {
|
||||
},
|
||||
],
|
||||
);
|
||||
let titles = Disc::scan_dvd_titles(&mut disc, &udf);
|
||||
let titles = Disc::scan_dvd_titles(&mut disc, &udf, None).expect("scan");
|
||||
let t = &titles[0];
|
||||
let audio_pids: Vec<u16> = t
|
||||
.streams
|
||||
|
||||
+2
-2
@@ -2056,7 +2056,7 @@ impl Disc {
|
||||
// derived below from the AACS MKB generation, not the tree.
|
||||
let (mut titles, content_format) = if udf_fs.find_dir("/BDMV").is_some() {
|
||||
(
|
||||
Self::scan_bluray_titles(reader, &udf_fs),
|
||||
Self::scan_bluray_titles(reader, &udf_fs, opts.halt.as_ref())?,
|
||||
ContentFormat::BdTs,
|
||||
)
|
||||
} else if udf_fs.find_dir("/HVDVD_TS").is_some() {
|
||||
@@ -2066,7 +2066,7 @@ impl Disc {
|
||||
)
|
||||
} else if udf_fs.find_dir("/VIDEO_TS").is_some() {
|
||||
(
|
||||
Self::scan_dvd_titles(reader, &udf_fs),
|
||||
Self::scan_dvd_titles(reader, &udf_fs, opts.halt.as_ref())?,
|
||||
ContentFormat::MpegPs,
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -425,6 +425,13 @@ pub fn parse_vmg(reader: &mut dyn SectorSource, udf: &UdfFs) -> Result<DvdInfo>
|
||||
for (&vts_number, titles_info) in &title_set_map {
|
||||
match parse_vts(reader, udf, vts_number, titles_info) {
|
||||
Ok(ts) => title_sets.push(ts),
|
||||
// The operator's Stop is not a placeholder title set. Once the
|
||||
// drive's halt flag is set EVERY command fails with `Halted`, so
|
||||
// swallowing it here would skip every REMAINING title set in turn
|
||||
// and return `Ok` with a truncated list — a cancelled scan that
|
||||
// is indistinguishable from a disc genuinely holding fewer
|
||||
// titles. `scan_dvd_titles` propagates it to the caller.
|
||||
Err(Error::Halted) => return Err(Error::Halted),
|
||||
Err(e) => {
|
||||
// Some discs carry placeholder TT_SRPT entries for title sets
|
||||
// that are not really there, so one failure is not fatal. But
|
||||
|
||||
Reference in New Issue
Block a user