diff --git a/src/disc/extract.rs b/src/disc/extract.rs index ca39a7c..404ec26 100644 --- a/src/disc/extract.rs +++ b/src/disc/extract.rs @@ -237,6 +237,9 @@ impl Disc { let mut result = ExtractResult::default(); let total_bytes = required; let mut done_bytes: u64 = 0; + // Cumulative zero-filled-unreadable bytes, so the live progress channel + // can report the good/unreadable split instead of pinning unreadable at 0. + let mut done_unreadable: u64 = 0; // CSS per-VTS key cache; only consulted for CSS discs. let is_css = matches!(base_keys, DecryptKeys::Css { .. }); @@ -271,8 +274,15 @@ impl Disc { // non-tolerate), so extract_one_file already zero-filled it and // counted it in bytes_unreadable — one 'lost' bucket covers both // media damage and decrypt failure. - let (fr, halted) = - extract_one_file(&mut dec, dest, pf, total_bytes, &mut done_bytes, opts)?; + let (fr, halted) = extract_one_file( + &mut dec, + dest, + pf, + total_bytes, + &mut done_bytes, + &mut done_unreadable, + opts, + )?; result.bytes_good = result.bytes_good.saturating_add(fr.bytes_good); result.bytes_unreadable = result.bytes_unreadable.saturating_add(fr.bytes_unreadable); @@ -553,6 +563,7 @@ fn extract_one_file( pf: &PlannedFile, total_bytes: u64, done_bytes: &mut u64, + done_unreadable: &mut u64, opts: &ExtractOptions, ) -> Result<(FileResult, bool)> { let final_path = dest.join(&pf.host_rel); @@ -582,7 +593,7 @@ fn extract_one_file( finalize_file(writer, &partial_path, pf.size, &final_path)?; fr.complete = true; *done_bytes = done_bytes.saturating_add(pf.size); - report(opts, *done_bytes, total_bytes); + report(opts, *done_bytes, *done_unreadable, total_bytes); return Ok((fr, false)); } @@ -620,7 +631,7 @@ fn extract_one_file( left -= n as u64; } fr.bytes_good = fr.bytes_good.saturating_add(hole_bytes); - let cont = report(opts, *done_bytes, total_bytes); + let cont = report(opts, *done_bytes, *done_unreadable, total_bytes); if opts.cancelled(cont) { return Ok((fr, true)); } @@ -668,10 +679,11 @@ fn extract_one_file( } write_all(&mut writer, &buf[..usable], &partial_path)?; fr.bytes_unreadable = fr.bytes_unreadable.saturating_add(usable as u64); + *done_unreadable = done_unreadable.saturating_add(usable as u64); } written = written.saturating_add(usable as u64); *done_bytes = done_bytes.saturating_add(usable as u64); - let cont = report(opts, *done_bytes, total_bytes); + let cont = report(opts, *done_bytes, *done_unreadable, total_bytes); sector_off += batch; if opts.cancelled(cont) { // Leave the `.partial`; do NOT rename. The aggregate run @@ -784,15 +796,20 @@ fn finalize_file( /// Emit a progress report. Returns `true` to continue, `false` if the sink /// requested an early stop (or there is no sink — always continue). -fn report(opts: &ExtractOptions, done: u64, total: u64) -> bool { +fn report(opts: &ExtractOptions, done: u64, unreadable: u64, total: u64) -> bool { match opts.progress { Some(p) => { let pp = crate::progress::PassProgress { kind: crate::progress::PassKind::Mux, work_done: done, work_total: total, - bytes_good_total: done, - bytes_unreadable_total: 0, + // `done` counts good AND zero-filled-unreadable bytes together; + // split them so a consumer driven only by the live progress + // channel sees a holed extraction as holed rather than as a clean + // climb to 100%. The final ExtractResult already carries the true + // split — this used to pin unreadable at 0 and call every byte good. + bytes_good_total: done.saturating_sub(unreadable), + bytes_unreadable_total: unreadable, bytes_pending_total: 0, bytes_retryable_total: 0, bytes_total_disc: total, diff --git a/src/disc/hddvd.rs b/src/disc/hddvd.rs index f10a0f0..6aa082f 100644 --- a/src/disc/hddvd.rs +++ b/src/disc/hddvd.rs @@ -907,10 +907,32 @@ impl Disc { } // Authored clip order from the VTI clip table (empty if no VTI). - let order: Vec = vti_name - .and_then(|n| udf_fs.read_file(reader, &format!("/HVDVD_TS/{n}")).ok()) - .map(|b| parse_vti_clip_order(&b)) - .unwrap_or_default(); + // + // The VTI name came from `ts_dir.entries`, so a failed read here is a + // real I/O error (a scratched sector under the `.vti`), never an absent + // file. `.ok()` used to flatten the two — dropping the authored order + // with no diagnostic, so a split feature then composed from the per-clip + // heuristic and the operator was never told the authored order existed + // but could not be read. The clip-extent arms below log every read + // failure with its own code; this one now does too. The fallback itself + // is unchanged (no order => per-clip, exactly as an unauthored disc), + // because there is nothing to compose from without the table. Logging is + // exempt from the no-English rule (errors stay numeric). + let order: Vec = match vti_name { + None => Vec::new(), + Some(n) => match udf_fs.read_file(reader, &format!("/HVDVD_TS/{n}")) { + Ok(bytes) => parse_vti_clip_order(&bytes), + Err(e) => { + tracing::warn!( + target: "freemkv::disc", + vti = ?n, + code = e.code(), + "authored clip order unreadable; falling back to the per-clip heuristic" + ); + Vec::new() + } + }, + }; // Resolve each clip's physical extents once, keyed by lower-case name. let mut clip_extents: BTreeMap)> = BTreeMap::new(); diff --git a/src/labels/mod.rs b/src/labels/mod.rs index 4911b61..b4d93ca 100644 --- a/src/labels/mod.rs +++ b/src/labels/mod.rs @@ -1952,14 +1952,15 @@ mod apply_tests { subtitle(0x12A2, "fra"), ], )]; - // Capture through the crate's ONE serialised sink. A process-wide - // `set_global_default` here would poison every other test's callsite - // interest cache for the rest of the binary — `tracing` caches interest - // GLOBALLY, and a global subscriber that answers `never` for foreign - // callsites hard-disables them, so `testlog::capture`'s scoped captures - // (e.g. the `freemkv::disc` log-accounting tests) then see nothing and - // flake. `testlog::capture` serialises every capture under one lock and - // installs no global default, which is the invariant those tests rely on. + // Capture through the crate's ONE global `tracing` subscriber: + // `testlog::capture` installs it exactly once and routes each event to a + // thread-local sink. A process-wide `set_global_default` HERE instead + // would poison every other test's callsite interest cache for the rest of + // the binary — `tracing` caches interest GLOBALLY — and only the first + // `set_global_default` in a process takes effect anyway. The shared + // subscriber answers interest for every callsite and isolates concurrent + // captures per thread, which is the invariant these log-accounting + // assertions rely on. let ((), events) = crate::testlog::capture(|| { apply_labels(&labels, &mut titles); }); diff --git a/src/mux/ps.rs b/src/mux/ps.rs index a70903b..2a0efce 100644 --- a/src/mux/ps.rs +++ b/src/mux/ps.rs @@ -17,7 +17,7 @@ use super::codec::startcode::find_start_code; const PACK_HEADER_ID: u8 = 0xBA; /// System header start code suffix. -const SYSTEM_HEADER_ID: u8 = 0xBB; +const SYSTEM_HEADER_ID: u8 = crate::consts::pes_stream_id::SYSTEM_HEADER; /// Program end start code suffix. const PROGRAM_END_ID: u8 = 0xB9;