Compare commits

...
11 Commits
Author SHA1 Message Date
MattJackson 9e3327a569 v0.18.8: bump version (Pass-1 fast-skip never reached autorip 0.18.7 — Cargo.lock mis-resolved) 2026-05-10 14:01:23 -07:00
MattJackson 0081955686 labels: add dbp parser (Magnolia Pictures BD-J framework)
5th BD-J authoring framework recognized. Discriminator: any top-
level .jar in /BDMV/JAR/ that contains com/dbp/ package paths.
Identified during the 2026-05-10 corpus session via string-mining
disc-07's BD-J jar — perm files reference bd-live.magpictures.com
(Magnolia / Magnet Releasing).

Stream labels live as plain ASCII strings inside compiled .class
files in the form

    LTextField,Audio1,English Dolby Atmos,Fontstrip_Composite,...
    HTextField,Subtitle1,English SDH,Fontstrip_Composite,...
    ATextField,Subtitle0,None,Fontstrip_Composite,...

— a quirk of the menu-rendering layer encoding TextField positions
and content as constant strings the Java compiler retained in the
class string pool. The leading single-letter prefix is string-pool
ordering noise; parser anchors on `TextField,`. Subtitle0 is the
disable-subtitles button and is skipped.

The parser:
  - reads each top-level .jar via udf.read_file
  - opens it with the existing zip dependency
  - confirms com/dbp/ presence in the central directory
  - walks .class entries, extracts printable strings, matches the
    `TextField,(Audio|Subtitle)<N>,<label>,...` pattern
  - maps human-readable language names ("English", "Castilian
    Spanish", "Brazilian Portuguese", "Canadian French", ...) to
    ISO 639-2 codes via a parser-local table (per the rules-of-
    engagement memo, each parser knows its own format)
  - preserves the full disc-authored label string in `name` so
    consumers display it raw without the lib guessing further
    structure
  - detects SDH / Forced qualifiers and Commentary / Descriptive
    purposes from substring matches; everything else falls through
    to fill_defaults using BD-spec MPLS data

Verified live on the corpus: disc-07 (Civil War UHD) now matches
parser=dbp with 3 audio + 2 subtitle labels, exactly the count
visible in the disc's authored TextField definitions and what BD
spec MPLS reports.

Limitation: dbp's detect() returns true for ANY top-level .jar in
/BDMV/JAR/ (every BD-J disc has one), since the discriminator
trait function takes only `&UdfFs` and can't read jar contents.
parse() does the real com/dbp/ check — a non-dbp disc gets
parsed-as-dbp, archive_has_dbp returns false, parse() returns
None, and we fall through. Diagnostic noise: parsers_detected
includes "dbp" on non-dbp BD-J discs. Real fix is refactoring the
DetectFn signature to take a SectorReader; deferred.

7 unit tests cover the TextField extraction, language detection
(simple + compound: "Brazilian Portuguese", "Castilian Spanish",
"Canadian French", "Latin American Spanish", "Australian English"
plus the disc-corpus typo "Austrailian English"), SDH/Forced/RNIB
qualifier detection, and Commentary/Descriptive purpose detection.
Don't-guess discipline preserved: unknown languages return ""
(consumer falls back to MPLS spec data via fill_defaults).
2026-05-10 13:15:07 -07:00
MattJackson 3244fdd683 v0.18.7: Pass 1 fast-skip, defer recovery to Pass N
Pass 1 sweep was grinding through damage zones because the marginal-
media handler returned `Bisect` for every failed 32-sector batch —
forcing 32 single-sector reads per bad block at ~5s each on a real
BU40N-vs-Dune-Pt-2 trace. AND the JumpAhead trigger required a 16-
block damage window to fill before firing, so entry into a
contiguous damage zone took ~40 minutes of grinding before the
first jump fired. Architecturally wrong: Pass 1's job is "fast and
accurate, get the most data in the shortest time." Bisection +
recovery is Pass N's purpose-built role.

ReadCtx now carries two new fields:
  - `consecutive_outer_failures: u64` — outer-batch failures since
    last outer success. Bisect inner failures don't count.
  - `bisect_on_marginal: bool` — whether to return Bisect on a
    marginal-media batch failure.
  - `fast_jump_threshold: u64` — outer-failures count that triggers
    JumpAhead before the damage window has filled.

`for_sweep` (Pass 1) sets `bisect_on_marginal=false`,
`fast_jump_threshold=4`, and zeroes the post-failure pause. Failed
batches become SkipBlock → whole block NonTrimmed → advance, no
sleep. After 4 consecutive outer failures: JumpAhead with the
existing escalating multiplier.

`for_patch` (Pass N) sets `bisect_on_marginal=true`,
`fast_jump_threshold=u64::MAX`, keeps the original cooldown pauses.
Pass N's whole reason to exist is to grind on bad ranges with
proper recovery semantics — single-sector reads, 60s recovery
timeout, retry budget, escalating skip — and that's unchanged.

`on_success` resets `consecutive_outer_failures` only when not
bisecting, so a good single-sector read inside Pass N's bisect
doesn't pretend we've escaped the damaged batch.

Tests:
  - `pass_n_marginal_with_batch_gt_1_bisects` — Pass N still bisects.
  - `pass_1_marginal_skips_instead_of_bisecting` — Pass 1 doesn't.
  - `pass_1_jumps_after_4_consecutive_outer_failures` — fast-entry.
  - `pass_n_does_not_fast_jump` — fast-entry is Pass-1-only.
  - `outer_success_resets_consecutive_outer_failures` — counter reset.
  - `bisect_inner_success_does_not_reset_outer_counter` — semantics.
  - `pass_1_does_not_pause_on_skip` — explicit zero-pause contract.
  - `long_failure_streak_extends_pause_on_pass_n` — Pass N still
    extends pauses on long failure streaks (renamed from the old
    sweep-based test).

Integration test `test_disc_copy_marks_failed_ecc_blocks_as_nontrimmed`
updated: it used to assert Pass 1 recovers all sectors via bisect
(bytes_good=total). New contract: Pass 1 marks NonTrimmed; Pass N
recovers. Test now asserts Pass-1-only outcome (bytes_pending=total,
complete=false) consistent with the redesign.

Real-world impact on the user's BU40N + Dune Pt 2 trace from this
session: a damage zone that was on track to take ~40 minutes of
Pass-1 grinding will now jump in ~20 seconds. Pass N still has the
full 7-pass recovery budget to revisit those NonTrimmed ranges.
2026-05-10 12:50:12 -07:00
MattJackson ef5487e5a5 labels: distinguish "parser detected" from "parser succeeded"
Adds `parsers_detected: Vec<&'static str>` to `LabelAnalysis`. Records
every parser whose discriminator matched, regardless of whether its
parse() then returned Some/None.

Why: when `parser=None` we currently can't tell apart:
  (a) no parser recognized this disc — missing parser, candidate for a
      new module
  (b) a parser recognized it but parse() returned None / empty —
      capture truncated, or genuine empty authoring data, or a parser
      bug

Surfaced concretely on the 11-disc capture session 2026-05-10:
disc-04 had `bluray_project.bin` in jar_inventory (pixelogic detect()
returned true) but parse() returned None because file content was
past the 1 GB capture window. Old API: parser=None — looked like
"missing parser." New API: detected=[pixelogic], parser=None — clearly
"capture problem, not a parser gap."

The tracing log line on the no-parser-emitted-labels path now
distinguishes the two cases too.

No behavior change to production rip path. extract() is unchanged;
only the diagnostic analyze() returns the richer result.
2026-05-10 12:36:05 -07:00
MattJackson e7dffa63d2 v0.18.6: bump version (unified release with bdemu/freemkv/autorip) 2026-05-10 10:03:03 -07:00
MattJackson e5989c8270 labels: expose analyze() for corpus regression tooling
Promotes `mod labels` to `pub mod labels` and adds `analyze()` plus
`LabelAnalysis` (both `#[doc(hidden)]`) so an out-of-tree diagnostic
binary (freemkv-tools labels-analyze) can introspect which BD-J parser
matched a given disc, what JAR files the discriminators saw, and what
labels came out — without going through the production `apply()` path
that mutates DiscTitles.

Also adds `tracing::info!(parser = name, "label parser matched")` /
"no label parser matched" inside `extract()`. Dev-only signal: users
get the same seamless behavior; developers can finally tell whether a
disc hit a real parser or fell through to the codec-name fallback in
fill_defaults().

The new `jar_inventory()` helper deduplicates and sorts filenames
under any `/BDMV/JAR/<x>/` subdirectory — same plumbing the existing
`jar_file_exists()` discriminators use, just enumerated rather than
predicate-tested. Used by `analyze()` to surface unrecognized
parser-source files when no parser matches, which is the input to
"do we need a new parser?" triage.

No behavior change to the production label path. `apply()` and
`extract()` remain functionally identical; the new public surface
exists alongside.
2026-05-10 07:42:45 -07:00
MattJackson e1c938b82a ci: drop --locked from libfreemkv workflows
libfreemkv is a library — Cargo.lock is gitignored (standard for
libs). --locked refuses to create a lockfile on a fresh runner,
so it always fails CI. --locked stays in the binary crates
(freemkv, autorip, bdemu) which DO track Cargo.lock and benefit
from the dependency-race hard-fail behaviour.
2026-05-09 20:40:31 -07:00
MattJackson ed801a708b fmt: rustfmt-mandated reflow of canonical_title_order tests
The 0.18.4 commit landed with rustfmt diffs in the new
canonical_order tests because my local validation script piped
'cargo fmt --check' to 'tail -1', masking the diff output and
reporting green when fmt was actually unhappy. CI's lint job
caught it immediately. No code change — pure formatting.
2026-05-09 20:34:40 -07:00
MattJackson d94451954c v0.18.4: cargo --locked everywhere — hard-fail dependency races 2026-05-09 20:30:34 -07:00
MattJackson 385c9f094c v0.18.3: canonical_title_order — main feature first on branching UHDs 2026-05-09 20:08:56 -07:00
MattJackson 9f2a13739d Disc title order: main feature first on branching UHDs
Disc::titles previously sorted purely by duration_secs descending,
which puts a play-all virtual playlist at index 0 on UHDs that ship
one. Such playlists reference the same source clips multiple times
for seamless alternate-angle / alternate-ending playback and report
inflated duration AND inflated size_bytes that exceeds the disc's
physical capacity.

Concrete observed case (The Amateur 2025 4K UHD, 58.5 GB BD-100):
  Title 1 — 00020.mpls — 4h13m — 92.4 GB — 253 clips  ← impossible
  Title 2 — 00800.mpls — 2h02m — 57.2 GB — 1 clip      ← the movie

92.4 GB > 58.5 GB capacity is proof of clip double-counting. With
the duration-only sort, freemkv -t 1 / disc.titles.first() / autorip's
main-feature picker all selected the 4-hour composite instead of the
2-hour movie.

New canonical_title_order:
  1. Real titles (size_bytes <= capacity_bytes) before virtual
     composites — capacity gate is hard physical truth.
  2. Among real titles, fewer clips first (1-clip wins as the
     canonical main feature; multi-clip is either chapter-stitched
     or composite).
  3. Tiebreak on longer duration first.

Behaviour:
- Non-branching discs: unchanged. The longest 1-clip title is
  already the movie.
- Branching UHDs: virtual composite drops to the back, the real
  movie surfaces at index 0.

Comparator exposed as Disc::canonical_title_order for downstream
consumers that need the same logic on custom title sets.

Three regression tests (disc::tests::canonical_order_*):
- pushes_oversize_play_all_behind_real_main (The Amateur)
- preserves_natural_ranking_on_normal_disc
- fewer_clips_wins_tiebreak
2026-05-09 19:57:07 -07:00
10 changed files with 896 additions and 34 deletions
+4
View File
@@ -14,6 +14,10 @@ jobs:
with:
components: clippy, rustfmt
- run: cargo fmt --check
# libfreemkv is a library — Cargo.lock is gitignored. --locked
# would always fail on a fresh runner because there's no committed
# lockfile to lock against. The binary crates (freemkv, autorip,
# bdemu) track Cargo.lock and DO use --locked.
- run: cargo clippy -- -D warnings
test:
+2
View File
@@ -28,6 +28,8 @@ jobs:
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@1.86.0
# libfreemkv is a library — Cargo.lock isn't tracked, so --locked
# would always fail (no lockfile to lock against on a fresh runner).
- run: cargo test
publish:
+52
View File
@@ -1,5 +1,57 @@
# Changelog
## 0.18.4 (2026-05-09)
### Build / CI hardening — no library code changes
- All `cargo` invocations in `.github/workflows/*.yml` now use
`--locked`. Previously `cargo build` / `cargo test` could silently
re-resolve `Cargo.lock` if a lock-pinned version wasn't yet on
crates.io — masking dependency races between same-tag releases of
sibling crates. The 0.18.3 release hit this: autorip's docker image
built `libfreemkv v0.18.2` because libfreemkv 0.18.3 hadn't
published yet at the time autorip's CI ran, and cargo silently fell
back to the previous version. With `--locked`, that race now hard-
fails the build with a clear "package X is not in registry" error
and we retrigger after the upstream lands.
- `cargo publish --locked` in the release workflow guards against
publishing a lockfile-mismatched release.
## 0.18.3 (2026-05-09)
### Behaviour change
- **`Disc::titles[0]` is now the canonical main feature on branching
discs, not the longest playlist.** Previously titles were sorted
purely by `duration_secs` descending, which puts a "play-all"
virtual playlist (alternate angles / seamless branching) at index 0
on UHDs that ship one — its inflated duration overshoots the real
movie. Concrete example: *The Amateur (2025)* 4K UHD on a 58.5 GB
disc has Title 1 = `00020.mpls` 4h13m / 92.4 GB / 253 clips
(impossible — the size exceeds the disc capacity, proving it's a
virtual composite) and Title 2 = `00800.mpls` 2h02m / 57.2 GB /
1 clip (the actual film, matching TMDB).
New sort priority:
1. Real titles (`size_bytes <= capacity_bytes`) before virtual
composites.
2. Among real titles, fewer clips first (1-clip wins).
3. Tiebreak on longer duration first.
**Migration:** consumers calling `disc.titles.first()` /
`disc.titles[0]` automatically get the corrected title — no code
changes needed. CLI users invoking `freemkv -t 1 disc:// …` now hit
the actual main feature on branching discs (this was the user-
visible bug). On non-branching discs the order is unchanged.
The comparator is exposed as
[`Disc::canonical_title_order`](#) for callers that need to
re-sort a custom title set with the same logic.
Regression tests: `disc::tests::canonical_order_*` (three cases:
branching-UHD, normal disc, clip-count tiebreak).
## 0.18.2 (2026-05-09)
### Bug fixes
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "0.18.2"
version = "0.18.8"
edition = "2024"
rust-version = "1.86"
license = "AGPL-3.0-only"
+149 -5
View File
@@ -1134,11 +1134,15 @@ impl Disc {
} else {
(Vec::new(), ContentFormat::BdTs)
};
titles.sort_by(|a, b| {
b.duration_secs
.partial_cmp(&a.duration_secs)
.unwrap_or(std::cmp::Ordering::Equal)
});
// Title ordering: titles[0] should be the canonical main feature.
//
// Naive "longest duration first" misranks branching UHDs (see
// `canonical_title_order` for the full rationale). Sort the
// titles so the consumer-side `-t 1` / autorip's main-feature
// picker / `disc.titles.first()` all converge on the actual
// movie instead of the virtual play-all composite.
let capacity_bytes = capacity as u64 * 2048;
titles.sort_by(|a, b| Self::canonical_title_order(a, b, capacity_bytes));
// 4. Metadata + labels
let meta_title = Self::read_meta_title(reader, &udf_fs);
@@ -1177,6 +1181,57 @@ impl Disc {
// ── Internal helpers ────────────────────────────────────────────────────
/// Detect disc format from the main title's video streams.
/// Total ordering used to sort `Disc::titles` so `titles[0]` is the
/// canonical main feature.
///
/// **Why not just sort by duration descending?** Branching UHDs
/// (and some BD authoring) ship a "play-all" virtual playlist that
/// references the same source clips multiple times for seamless
/// alternate-angle / alternate-ending playback. Those playlists
/// report an inflated `duration_secs` (often 4+ hours) and an
/// inflated `size_bytes` greater than the disc's physical
/// capacity. Example seen in the wild — *The Amateur (2025)* UHD,
/// 58.5 GB BD-100 disc:
///
/// | Title | Playlist | Duration | Size | Clips |
/// |-------|--------------|----------|---------|-------|
/// | 1 | 00020.mpls | 4h 13m | 92.4 GB | 253 |
/// | 2 | 00800.mpls | 2h 02m | 57.2 GB | 1 |
///
/// Title 1's 92.4 GB cannot fit on a 58.5 GB disc unless the same
/// clip data is referenced multiple times — proof it's a virtual
/// composite. A duration-only sort would put it at `titles[0]`,
/// so `freemkv -t 1`, `disc.titles.first()`, and autorip's
/// main-feature picker all grab the 4-hour composite instead of
/// the 2-hour movie that actually matches TMDB.
///
/// **Sort priority (titles[0] = most likely main feature):**
/// 1. Real titles (`size_bytes ≤ capacity_bytes`) before virtual
/// composites. The capacity check is a hard "physically
/// possible data on this disc" gate.
/// 2. Among real titles, fewer clips first. A 1-clip playlist is
/// the canonical main feature; multi-clip playlists are either
/// chapter-stitched (small count) or virtual composites
/// (large count). Fewer wins.
/// 3. Tiebreak on longer duration first.
///
/// **Effect on non-branching discs:** unchanged — the main movie
/// is already the longest 1-clip title.
/// **Effect on branching UHDs:** the virtual play-all playlist is
/// pushed to the back, the actual movie surfaces at index 0.
pub fn canonical_title_order(
a: &DiscTitle,
b: &DiscTitle,
capacity_bytes: u64,
) -> std::cmp::Ordering {
let a_oversize = a.size_bytes > capacity_bytes;
let b_oversize = b.size_bytes > capacity_bytes;
a_oversize
.cmp(&b_oversize)
.then_with(|| a.clips.len().cmp(&b.clips.len()))
.then_with(|| b.duration_secs.total_cmp(&a.duration_secs))
}
fn detect_format(titles: &[DiscTitle]) -> DiscFormat {
for title in titles.iter().take(3) {
for stream in &title.streams {
@@ -3233,6 +3288,95 @@ mod tests {
}
}
/// Build a DiscTitle with full control over the fields the title
/// sorter cares about. Used by the canonical-title-order tests.
fn title_with(
playlist: &str,
duration_secs: f64,
size_bytes: u64,
n_clips: usize,
) -> DiscTitle {
let mut t = title_with_video(Codec::Hevc, Resolution::R2160p);
t.playlist = playlist.into();
t.duration_secs = duration_secs;
t.size_bytes = size_bytes;
t.clips = (0..n_clips)
.map(|i| Clip {
clip_id: format!("{i:05}"),
in_time: 0,
out_time: 1,
duration_secs: 1.0,
source_packets: 0,
})
.collect();
t
}
/// Regression for branching-UHD title ordering. Mirrors the live
/// observed *The Amateur (2025)* layout: a 4h13m / 92.4 GB / 253-clip
/// virtual play-all playlist alongside the real 2h02m / 57.2 GB /
/// 1-clip main feature. Disc capacity 58.5 GB. After sorting,
/// titles[0] must be the main feature, not the virtual composite.
#[test]
fn canonical_order_pushes_oversize_play_all_behind_real_main() {
const CAPACITY: u64 = 58_500_000_000; // 58.5 GB
let mut titles = vec![
// Title 1 in the raw MPLS order — virtual play-all
title_with(
"00020.mpls",
4.0 * 3600.0 + 13.0 * 60.0,
92_400_000_000,
253,
),
// Title 2 — actual movie
title_with("00800.mpls", 2.0 * 3600.0 + 2.0 * 60.0, 57_200_000_000, 1),
];
titles.sort_by(|a, b| Disc::canonical_title_order(a, b, CAPACITY));
assert_eq!(
titles[0].playlist, "00800.mpls",
"main feature should land at index 0"
);
assert_eq!(
titles[1].playlist, "00020.mpls",
"virtual play-all should be pushed back"
);
}
/// Non-branching disc: longest 1-clip title is the movie. Sort
/// must not change behaviour — the existing "duration descending"
/// expectation holds when no titles overflow capacity.
#[test]
fn canonical_order_preserves_natural_ranking_on_normal_disc() {
const CAPACITY: u64 = 60_000_000_000;
let mut titles = vec![
title_with("00100.mpls", 600.0, 5_000_000_000, 1), // 10 min menu
title_with("00800.mpls", 7320.0, 55_000_000_000, 1), // 2h02m main feature
title_with("00200.mpls", 1800.0, 2_000_000_000, 1), // 30 min extra
];
titles.sort_by(|a, b| Disc::canonical_title_order(a, b, CAPACITY));
assert_eq!(
titles[0].playlist, "00800.mpls",
"longest valid title still wins"
);
assert_eq!(titles[1].playlist, "00200.mpls");
assert_eq!(titles[2].playlist, "00100.mpls");
}
/// Tiebreak: equal duration + equal capacity-validity → fewer
/// clips wins. A chapter-stitched 3-clip movie should beat a
/// 50-clip virtual composite of the same duration.
#[test]
fn canonical_order_fewer_clips_wins_tiebreak() {
const CAPACITY: u64 = 100_000_000_000;
let mut titles = vec![
title_with("00050.mpls", 7200.0, 50_000_000_000, 50),
title_with("00800.mpls", 7200.0, 50_000_000_000, 3),
];
titles.sort_by(|a, b| Disc::canonical_title_order(a, b, CAPACITY));
assert_eq!(titles[0].playlist, "00800.mpls");
assert_eq!(titles[1].playlist, "00050.mpls");
}
#[test]
fn detect_format_uhd() {
let titles = vec![title_with_video(Codec::Hevc, Resolution::R2160p)];
+204 -17
View File
@@ -25,11 +25,25 @@ pub struct ReadCtx {
/// Failed reads since the last success. Resets to 0 on success.
/// Drives long-pause escalation on persistent failure.
pub consecutive_failures: u64,
/// Failed OUTER batch reads since the last outer success — bisect
/// inner-sector failures are NOT counted here. Drives the
/// fast-entry damage-jump on Pass 1 (skip the disc-level grind
/// once we're clearly in a damaged region; Pass N will recover
/// the actual sectors). Reset on outer success.
pub consecutive_outer_failures: u64,
/// Sliding window of recent read outcomes (true=ok, false=fail).
/// Capped at `damage_window_max`. Drives damage-jump decisions.
pub damage_window: Vec<bool>,
pub damage_window_max: usize,
pub damage_threshold_pct: usize,
/// Trigger a damage-jump after this many consecutive outer-batch
/// failures, even when the damage_window isn't full yet. Pass 1
/// uses a small value (4) so we don't spend ~40 minutes grinding
/// to fill a 16-block window before the first jump on a damage
/// zone we entered cleanly. Pass N uses a larger value (or
/// disables this — see `bisect_on_marginal`) because Pass N's
/// whole job IS to grind on the bad ranges.
pub fast_jump_threshold: u64,
/// Multiplier applied to damage-jump distance. Doubles each jump,
/// resets to 1 after `damage_window_max` consecutive good reads.
pub jump_multiplier: u64,
@@ -43,38 +57,64 @@ pub struct ReadCtx {
/// failed batch, so the handler doesn't recursively request another
/// bisect on the inner-sector failures.
pub bisecting: bool,
/// Whether to return `Bisect` on a marginal-media batch failure.
/// Pass 1 sweep sets this false: a failed batch becomes
/// SkipBlock (mark the whole 32-sector ECC block NonTrimmed,
/// advance, let Pass N recover the salvageable sectors with
/// proper recovery semantics). Pass N sets this true: bisection
/// is its core job, and it has the right tools (single-sector
/// reads, 60s recovery timeout, retry budget, escalating skip).
pub bisect_on_marginal: bool,
}
impl ReadCtx {
/// Initial context for a Pass 1 sweep with the documented constants.
/// Initial context for a Pass 1 sweep. The job is "fast and
/// accurate, get the most data in the shortest time" — Pass N
/// is the one that grinds on the bad ranges. So bisect-on-
/// marginal is OFF (failed batches become SkipBlock; whole 32-
/// sector blocks marked NonTrimmed for Pass N to revisit), and
/// the damage-jump fast-path triggers after just 4 consecutive
/// outer-batch failures.
pub fn for_sweep(batch: u16) -> Self {
Self {
batch,
consecutive_good: 0,
consecutive_failures: 0,
consecutive_outer_failures: 0,
damage_window: Vec::with_capacity(16),
damage_window_max: 16,
damage_threshold_pct: 12,
fast_jump_threshold: 4,
jump_multiplier: 1,
not_ready_retries: 0,
bridge_degradation_count: 0,
bisecting: false,
bisect_on_marginal: false,
}
}
/// Initial context for a Pass 2-N patch.
/// Initial context for a Pass 2-N patch. Pass N's whole reason to
/// exist is to recover sectors Pass 1 skipped — bisection on
/// marginal media is part of the job, and the fast-jump
/// threshold is loose so we don't bail too early on a range that
/// has scattered good sectors mixed in.
pub fn for_patch(batch: u16) -> Self {
Self {
batch,
consecutive_good: 0,
consecutive_failures: 0,
consecutive_outer_failures: 0,
damage_window: Vec::with_capacity(16),
damage_window_max: 16,
damage_threshold_pct: 12,
// Pass N is allowed to grind: window-based jump only,
// matching the historical behaviour for patch passes.
fast_jump_threshold: u64::MAX,
jump_multiplier: 1,
not_ready_retries: 0,
bridge_degradation_count: 0,
bisecting: false,
bisect_on_marginal: true,
}
}
@@ -83,6 +123,12 @@ impl ReadCtx {
self.consecutive_good += 1;
self.consecutive_failures = 0;
self.not_ready_retries = 0;
// Outer-success only: a good single-sector read inside a
// bisect doesn't mean we've left the damaged batch. Only an
// outer-batch success resets the outer-failure counter.
if !self.bisecting {
self.consecutive_outer_failures = 0;
}
self.damage_window.push(true);
if self.damage_window.len() > self.damage_window_max {
self.damage_window.remove(0);
@@ -137,6 +183,13 @@ const BRIDGE_DEGRADATION_MAX_RETRIES: u32 = 5;
pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
ctx.consecutive_failures += 1;
ctx.consecutive_good = 0;
// Outer-failure counter — only OUTER batch failures count toward
// the fast-jump trigger. Bisect inner failures are part of
// recovering an already-failed batch and don't represent
// independent damage signal.
if !ctx.bisecting {
ctx.consecutive_outer_failures += 1;
}
tracing::warn!(
target: "freemkv::disc",
@@ -198,11 +251,20 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
// individually. Bisect into single-sector reads (gentler on the
// bridge too — shorter SCSI transactions). Avoid recursive
// bisect.
//
// Pass 1 sweep sets `bisect_on_marginal=false` to skip this:
// its job is "fast and accurate, get the most data in the
// shortest time." Pass N is purpose-built to recover
// individual sectors with proper recovery semantics, and Pass
// 1 grinding through 32-sector bisects costs ~2.5 min per bad
// block AND fills the damage window slower than it should.
// Whole-block NonTrimmed → SkipBlock → advance → Pass N
// revisits.
let is_marginal = matches!(
sense_key,
scsi::SENSE_KEY_MEDIUM_ERROR | scsi::SENSE_KEY_ABORTED_COMMAND
);
if is_marginal && ctx.batch > 1 && !ctx.bisecting {
if is_marginal && ctx.batch > 1 && !ctx.bisecting && ctx.bisect_on_marginal {
return ReadAction::Bisect;
}
@@ -228,22 +290,49 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
bad_count * 100 / ctx.damage_window.len()
};
let pause_secs = if ctx.consecutive_failures >= CONSECUTIVE_FAIL_LONG_PAUSE_THRESHOLD {
// Pass 1's job is to get to the end of the disc fast. Inter-
// block pauses help the drive cool down on Pass N (gentle
// recovery on bad ranges) but on Pass 1 they just turn a 30 s
// damage zone into 30 minutes of dead-air sleep. Pass N
// (`bisect_on_marginal=true`) keeps the original cooldown logic.
let pause_secs = if !ctx.bisect_on_marginal {
0
} else if ctx.consecutive_failures >= CONSECUTIVE_FAIL_LONG_PAUSE_THRESHOLD {
CONSECUTIVE_FAIL_LONG_PAUSE_SECS
} else {
POST_FAILURE_PAUSE_SECS
};
// 7. Damage-jump: too many failures in window → skip ahead by an
// escalating gap. Multiplier capped so we can't accidentally
// skip the entire rest of the disc (observed 2026-05-07: a
// saturated multiplier produced a 56 GB jump). Saturating
// arithmetic on the sector calc as defence in depth.
// 7. Damage-jump: too many failures → skip ahead by an escalating
// gap. Multiplier capped so we can't accidentally skip the
// entire rest of the disc (observed 2026-05-07: a saturated
// multiplier produced a 56 GB jump). Saturating arithmetic on
// the sector calc as defence in depth.
//
// Two triggers, evaluated in order:
//
// a. **Fast-entry** — `consecutive_outer_failures >= fast_jump_threshold`.
// Fires on Pass 1 (threshold=4) so we don't spend ~40 min
// grinding to fill a 16-block damage window before the
// first jump on a damage zone we entered cleanly. Doesn't
// fire on Pass N (threshold=u64::MAX).
//
// b. **Window-based** — original behaviour: 12% bad in a
// sliding window of 16 outer reads. Pass N's only path,
// and Pass 1's fallback if the failures are scattered
// enough that we don't hit the consecutive threshold.
const MAX_JUMP_MULTIPLIER: u64 = 64; // 64 × 256 × batch sectors
if ctx.damage_window.len() >= ctx.damage_window_max && bad_pct >= ctx.damage_threshold_pct {
let fast_trigger = !ctx.bisecting && ctx.consecutive_outer_failures >= ctx.fast_jump_threshold;
let window_trigger =
ctx.damage_window.len() >= ctx.damage_window_max && bad_pct >= ctx.damage_threshold_pct;
if fast_trigger || window_trigger {
let mult = ctx.jump_multiplier.min(MAX_JUMP_MULTIPLIER);
let sectors = 256u64.saturating_mul(ctx.batch as u64).saturating_mul(mult);
ctx.jump_multiplier = (ctx.jump_multiplier.saturating_mul(2)).min(MAX_JUMP_MULTIPLIER);
// Reset the outer-failure counter so a long damaged region
// doesn't keep firing fast-jump every read after the initial
// jump fired. The window-based trigger handles further jumps.
ctx.consecutive_outer_failures = 0;
return ReadAction::JumpAhead {
sectors,
pause_secs: pause_secs + POST_JUMP_EXTRA_PAUSE_SECS,
@@ -286,15 +375,28 @@ mod tests {
}
#[test]
fn medium_error_with_batch_gt_1_bisects() {
let mut ctx = ReadCtx::for_sweep(32);
fn pass_n_marginal_with_batch_gt_1_bisects() {
let mut ctx = ReadCtx::for_patch(32);
let action = handle_read_error(&medium_err(), &mut ctx);
assert_eq!(action, ReadAction::Bisect);
}
#[test]
fn pass_1_marginal_skips_instead_of_bisecting() {
// Pass 1's job is "fast and accurate" — leave bisection to
// Pass N. A failed batch becomes SkipBlock (whole 32-sector
// block marked NonTrimmed for Pass N to revisit).
let mut ctx = ReadCtx::for_sweep(32);
let action = handle_read_error(&medium_err(), &mut ctx);
match action {
ReadAction::SkipBlock { .. } => {}
other => panic!("expected SkipBlock for Pass 1, got {other:?}"),
}
}
#[test]
fn medium_error_with_batch_1_skips() {
let mut ctx = ReadCtx::for_sweep(1);
let mut ctx = ReadCtx::for_patch(1);
let action = handle_read_error(&medium_err(), &mut ctx);
match action {
ReadAction::SkipBlock { pause_secs } => assert!(pause_secs >= 1),
@@ -304,7 +406,7 @@ mod tests {
#[test]
fn medium_error_while_bisecting_does_not_recurse() {
let mut ctx = ReadCtx::for_sweep(32);
let mut ctx = ReadCtx::for_patch(32);
ctx.bisecting = true;
let action = handle_read_error(&medium_err(), &mut ctx);
match action {
@@ -313,6 +415,75 @@ mod tests {
}
}
#[test]
fn pass_1_jumps_after_4_consecutive_outer_failures() {
// The fast-entry trigger: Pass 1 should JumpAhead after 4
// consecutive outer-batch failures, BEFORE the 16-block
// damage window has filled. Otherwise we spend ~40 minutes
// of bisecting/grinding to fill the window before the first
// jump on a damage zone we entered cleanly.
let mut ctx = ReadCtx::for_sweep(32);
// First three should NOT jump (still under threshold of 4).
for _ in 0..3 {
let a = handle_read_error(&medium_err(), &mut ctx);
assert!(
!matches!(a, ReadAction::JumpAhead { .. }),
"should not jump until 4 consecutive outer failures"
);
}
// Fourth should jump.
let a = handle_read_error(&medium_err(), &mut ctx);
assert!(
matches!(a, ReadAction::JumpAhead { .. }),
"expected JumpAhead at 4th consecutive outer failure, got {a:?}"
);
}
#[test]
fn pass_n_does_not_fast_jump() {
// Pass N's whole reason to exist is to grind on bad ranges.
// It should NOT bail after 4 consecutive failures the way
// Pass 1 does — it bisects and skips with proper recovery.
let mut ctx = ReadCtx::for_patch(32);
for _ in 0..4 {
let a = handle_read_error(&medium_err(), &mut ctx);
assert!(
!matches!(a, ReadAction::JumpAhead { .. }),
"Pass N must not fast-jump; got {a:?}"
);
}
}
#[test]
fn outer_success_resets_consecutive_outer_failures() {
let mut ctx = ReadCtx::for_sweep(32);
for _ in 0..3 {
handle_read_error(&medium_err(), &mut ctx);
}
assert_eq!(ctx.consecutive_outer_failures, 3);
// An outer-success (bisecting=false) should reset the counter.
ctx.bisecting = false;
ctx.on_success();
assert_eq!(ctx.consecutive_outer_failures, 0);
}
#[test]
fn bisect_inner_success_does_not_reset_outer_counter() {
let mut ctx = ReadCtx::for_patch(32);
for _ in 0..3 {
handle_read_error(&medium_err(), &mut ctx);
}
assert_eq!(ctx.consecutive_outer_failures, 3);
// A successful inner-sector read during bisect is not the
// same as escaping the bad outer batch.
ctx.bisecting = true;
ctx.on_success();
assert_eq!(
ctx.consecutive_outer_failures, 3,
"bisect inner success must not reset outer-failure counter"
);
}
#[test]
fn hardware_error_aborts() {
let mut ctx = ReadCtx::for_sweep(32);
@@ -321,12 +492,15 @@ mod tests {
}
#[test]
fn long_failure_streak_extends_pause() {
let mut ctx = ReadCtx::for_sweep(1);
fn long_failure_streak_extends_pause_on_pass_n() {
// Pass N keeps the cooldown behaviour: after many consecutive
// failures, pauses extend to give the drive time to recover.
// Pass 1 explicitly does NOT pause — see
// `pass_1_does_not_pause_on_skip` below.
let mut ctx = ReadCtx::for_patch(1);
for _ in 0..15 {
handle_read_error(&medium_err(), &mut ctx);
}
// After many consecutive failures we should be in the long-pause regime
let final_action = handle_read_error(&medium_err(), &mut ctx);
match final_action {
ReadAction::SkipBlock { pause_secs } => {
@@ -339,6 +513,19 @@ mod tests {
}
}
#[test]
fn pass_1_does_not_pause_on_skip() {
// Pass 1 must zoom — a damage zone is Pass N's problem to
// recover from. Sleeping between failed batches turned a 30s
// damaged region into a 30-minute Pass-1 grind on real discs.
let mut ctx = ReadCtx::for_sweep(32);
let action = handle_read_error(&medium_err(), &mut ctx);
match action {
ReadAction::SkipBlock { pause_secs } => assert_eq!(pause_secs, 0),
other => panic!("expected SkipBlock, got {other:?}"),
}
}
#[test]
fn damage_window_fills_then_jumps() {
let mut ctx = ReadCtx::for_sweep(1);
+365
View File
@@ -0,0 +1,365 @@
//! "dbp" framework — Magnolia Pictures BD-J authoring shop (per
//! `bd-live.magpictures.com` referenced in the disc's
//! `com/dbp/bluray.MenuXlet.perm`). Detected on UHD discs whose
//! `/BDMV/JAR/<x>.jar` (top-level, not in a subdir) contains
//! `com/dbp/` package paths.
//!
//! Stream labels live as plain ASCII strings inside compiled `.class`
//! files in the jar — a quirk of the menu-rendering layer encoding
//! its TextField positions and content as constant strings the
//! Java compiler retained in the class string pool. Format observed
//! in the corpus (Civil War UHD, 2024):
//!
//! ```text
//! LTextField,Audio1,English Dolby Atmos,Fontstrip_Composite,...
//! RTextField,Audio2,English Descriptive Audio,Fontstrip_Composite,...
//! HTextField,Subtitle1,English SDH,Fontstrip_Composite,...
//! ATextField,Subtitle0,None,Fontstrip_Composite,...
//! ```
//!
//! The single uppercase letter before `TextField` is string-pool
//! prefix noise — the parser anchors on `TextField,` regardless of
//! what precedes it. `Subtitle0` is the disable-subtitles menu
//! button and is skipped (not a real subtitle stream).
//!
//! Per `(internal)/memory/feedback_label_data_rules.md`: this
//! parser knows its own format, so we map human-readable language
//! names ("English", "Spanish", "Canadian French", ...) to ISO 639-2
//! codes locally. The full disc-authored display string is preserved
//! in the label `name` field — consumers display it raw without
//! freemkv guessing further structure.
use super::{LabelPurpose, LabelQualifier, StreamLabel, StreamLabelType};
use crate::sector::SectorReader;
use crate::udf::UdfFs;
/// dbp detect can't peek inside a jar without a SectorReader (the
/// trait function only takes `&UdfFs`), so we trigger on the cheap
/// signal "any top-level .jar in /BDMV/JAR/." That fires on every
/// BD-J disc, but parse() does the real `com/dbp/` check and
/// returns None on a mismatch — so this parser only ever consumes
/// time on discs that fell through every earlier parser. The
/// parse-side mismatch is bounded (read one .jar, list central
/// directory, walk class strings).
pub fn detect(udf: &UdfFs) -> bool {
let Some(jar_dir) = udf.find_dir("/BDMV/JAR") else {
return false;
};
jar_dir
.entries
.iter()
.any(|e| !e.is_dir && e.name.to_lowercase().ends_with(".jar"))
}
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let jar_dir = udf.find_dir("/BDMV/JAR")?;
for entry in &jar_dir.entries {
if entry.is_dir {
continue;
}
if !entry.name.to_lowercase().ends_with(".jar") {
continue;
}
let path = format!("/BDMV/JAR/{}", entry.name);
let Ok(bytes) = udf.read_file(reader, &path) else {
continue;
};
let cursor = std::io::Cursor::new(&bytes);
let Ok(mut archive) = zip::ZipArchive::new(cursor) else {
continue;
};
if !archive_has_dbp(&mut archive) {
continue;
}
let labels = scan_jar(&mut archive);
if !labels.is_empty() {
return Some(labels);
}
}
None
}
fn archive_has_dbp<R: std::io::Read + std::io::Seek>(archive: &mut zip::ZipArchive<R>) -> bool {
for i in 0..archive.len() {
if let Ok(f) = archive.by_index(i) {
if f.name().starts_with("com/dbp/") {
return true;
}
}
}
false
}
fn scan_jar<R: std::io::Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
) -> Vec<StreamLabel> {
use std::collections::BTreeMap;
// BTreeMap so we keep the highest-numbered (last-written) label
// for each stream slot deterministic across runs. Entries are
// collected from string-pool fragments scattered across hundreds
// of obfuscated .class files; the same TextField,Audio1,...
// string can appear in multiple classes (button-state variants,
// localization fallbacks). Last write wins — they should all
// agree on the label text, but the structure is defensive.
let mut audios: BTreeMap<u16, String> = BTreeMap::new();
let mut subs: BTreeMap<u16, String> = BTreeMap::new();
for i in 0..archive.len() {
let Ok(mut f) = archive.by_index(i) else {
continue;
};
if !f.name().ends_with(".class") {
continue;
}
let mut buf = Vec::new();
if std::io::Read::read_to_end(&mut f, &mut buf).is_err() {
continue;
}
for s in extract_printable(&buf) {
collect_textfield(&s, &mut audios, &mut subs);
}
}
let mut out = Vec::new();
for (num, label) in audios {
out.push(make_label(num, label, StreamLabelType::Audio));
}
for (num, label) in subs {
out.push(make_label(num, label, StreamLabelType::Subtitle));
}
out
}
fn collect_textfield(
s: &str,
audios: &mut std::collections::BTreeMap<u16, String>,
subs: &mut std::collections::BTreeMap<u16, String>,
) {
// Anchor on "TextField," — the prefix character before it varies
// (string-pool ordering inside compiled Java) and is irrelevant.
let Some(idx) = s.find("TextField,") else {
return;
};
let after = &s[idx + "TextField,".len()..];
let mut parts = after.splitn(3, ',');
let kind_n = parts.next().unwrap_or("").trim();
let label = parts.next().unwrap_or("").trim();
if label.is_empty() {
return;
}
if let Some(rest) = kind_n.strip_prefix("Audio") {
if let Ok(n) = rest.parse::<u16>() {
audios.insert(n, label.to_string());
}
} else if let Some(rest) = kind_n.strip_prefix("Subtitle") {
if let Ok(n) = rest.parse::<u16>() {
// Subtitle0 is conventionally the "None / Off" disable
// button, not an actual subtitle stream.
if n > 0 {
subs.insert(n, label.to_string());
}
}
}
}
fn make_label(num: u16, label: String, stream_type: StreamLabelType) -> StreamLabel {
let (language, qualifier, purpose) = parse_attributes(&label);
StreamLabel {
stream_number: num,
stream_type,
language,
name: label,
purpose,
qualifier,
codec_hint: String::new(),
variant: String::new(),
}
}
fn parse_attributes(label: &str) -> (String, LabelQualifier, LabelPurpose) {
let lower = label.to_lowercase();
let language = detect_language(&lower);
let qualifier = if lower.contains("sdh") {
LabelQualifier::Sdh
} else if lower.contains("descriptive service") || lower.contains(" rnib") {
LabelQualifier::DescriptiveService
} else if lower.contains("forced") {
LabelQualifier::Forced
} else {
LabelQualifier::None
};
let purpose = if lower.contains("commentary") {
LabelPurpose::Commentary
} else if lower.contains("descriptive") || lower.contains("audio description") {
LabelPurpose::Descriptive
} else {
LabelPurpose::Normal
};
(language, qualifier, purpose)
}
/// Map English-language label tokens to ISO 639-2 codes. Keep this
/// list conservative — only common tokens we've actually observed
/// or that have a canonical mapping. Returns "" when the token
/// isn't recognized; the consumer falls back to fill_defaults reading
/// MPLS spec language codes.
fn detect_language(lower: &str) -> String {
// Compound tokens first (multi-word language names).
for (needle, code) in [
("brazilian portuguese", "por"),
("euro portuguese", "por"),
("castilian spanish", "spa"),
("latin american spanish", "spa"),
("canadian french", "fra"),
("parisian french", "fra"),
("australian english", "eng"),
("austrailian english", "eng"), // disc-corpus typo, keep matching
] {
if lower.contains(needle) {
return code.to_string();
}
}
// Then bare tokens. Order matters where one is prefix of another.
for (needle, code) in [
("english", "eng"),
("spanish", "spa"),
("french", "fra"),
("german", "deu"),
("italian", "ita"),
("japanese", "jpn"),
("chinese", "zho"),
("portuguese", "por"),
("polish", "pol"),
("czech", "ces"),
("hungarian", "hun"),
("dutch", "nld"),
("korean", "kor"),
("arabic", "ara"),
("hindi", "hin"),
("turkish", "tur"),
("thai", "tha"),
("swedish", "swe"),
("norwegian", "nor"),
("danish", "dan"),
("finnish", "fin"),
("hebrew", "heb"),
("russian", "rus"),
] {
if lower.split_whitespace().next() == Some(needle)
|| lower.split_whitespace().any(|w| w == needle)
{
return code.to_string();
}
}
String::new()
}
fn extract_printable(data: &[u8]) -> Vec<String> {
let mut out = Vec::new();
let mut current = String::new();
for &b in data {
if (0x20..0x7f).contains(&b) {
current.push(b as char);
} else {
if current.len() >= 5 {
out.push(current.clone());
}
current.clear();
}
}
if current.len() >= 5 {
out.push(current);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn collect_extracts_audio_and_subtitle_indices() {
let mut audios = std::collections::BTreeMap::new();
let mut subs = std::collections::BTreeMap::new();
let lines = [
"LTextField,Audio1,English Dolby Atmos,Fontstrip_Composite,296,763,275,25,left",
"RTextField,Audio2,English Descriptive Audio,Fontstrip_Composite,296,803,275,25,left",
"RTextField,Audio3,Spanish 5.1 Dolby Digital,Fontstrip_Composite,296,843,275,25,left",
"ATextField,Subtitle0,None,Fontstrip_Composite,1312,843,275,25,left",
"HTextField,Subtitle1,English SDH,Fontstrip_Composite,1312,763,275,25,left",
"DTextField,Subtitle2,Spanish,Fontstrip_Composite,1312,803,275,25,left",
];
for s in &lines {
collect_textfield(s, &mut audios, &mut subs);
}
assert_eq!(audios.len(), 3);
assert_eq!(audios[&1], "English Dolby Atmos");
assert_eq!(audios[&2], "English Descriptive Audio");
assert_eq!(audios[&3], "Spanish 5.1 Dolby Digital");
// Subtitle0 ("None") is skipped — disable button, not a stream.
assert_eq!(subs.len(), 2);
assert_eq!(subs[&1], "English SDH");
assert_eq!(subs[&2], "Spanish");
}
#[test]
fn collect_ignores_non_textfield_strings() {
let mut audios = std::collections::BTreeMap::new();
let mut subs = std::collections::BTreeMap::new();
for s in [
"GraphicButton,SU_Audio",
"AudioMenu",
"CommentaryMenuAlternateScenes",
"PrimaryAudioControl",
] {
collect_textfield(s, &mut audios, &mut subs);
}
assert!(audios.is_empty());
assert!(subs.is_empty());
}
#[test]
fn parse_attributes_recognizes_sdh() {
let (lang, qual, purp) = parse_attributes("English SDH");
assert_eq!(lang, "eng");
assert_eq!(qual, LabelQualifier::Sdh);
assert_eq!(purp, LabelPurpose::Normal);
}
#[test]
fn parse_attributes_recognizes_descriptive_audio() {
let (lang, qual, purp) = parse_attributes("English Descriptive Audio");
assert_eq!(lang, "eng");
assert_eq!(qual, LabelQualifier::None);
assert_eq!(purp, LabelPurpose::Descriptive);
}
#[test]
fn parse_attributes_recognizes_commentary() {
let (lang, qual, purp) = parse_attributes("English Director's Commentary");
assert_eq!(lang, "eng");
assert_eq!(qual, LabelQualifier::None);
assert_eq!(purp, LabelPurpose::Commentary);
}
#[test]
fn parse_attributes_recognizes_compound_languages() {
assert_eq!(parse_attributes("Brazilian Portuguese 5.1").0, "por");
assert_eq!(parse_attributes("Castilian Spanish").0, "spa");
assert_eq!(parse_attributes("Canadian French Dolby Digital").0, "fra");
assert_eq!(parse_attributes("Latin American Spanish").0, "spa");
}
#[test]
fn parse_attributes_returns_empty_for_unknown_language() {
// Don't guess. Per the rules-of-engagement.
assert_eq!(parse_attributes("Klingon Dolby Atmos").0, "");
}
#[test]
fn parse_attributes_recognizes_rnib_descriptive_service() {
let (lang, qual, _) = parse_attributes("English RNIB");
assert_eq!(lang, "eng");
assert_eq!(qual, LabelQualifier::DescriptiveService);
}
}
+101 -1
View File
@@ -9,6 +9,7 @@
mod criterion;
mod ctrm;
mod dbp;
mod paramount;
mod pixelogic;
pub mod vocab;
@@ -79,6 +80,12 @@ const PARSERS: &[(&str, DetectFn, ParseFn)] = &[
("criterion", criterion::detect, criterion::parse),
("pixelogic", pixelogic::detect, pixelogic::parse),
("ctrm", ctrm::detect, ctrm::parse),
// dbp last: detects on any top-level .jar in /BDMV/JAR/ (every
// BD-J disc has one), so parse() does the real `com/dbp/` check
// and returns None on a mismatch. By placing dbp last, the
// earlier parsers' fast file-presence detects short-circuit and
// dbp only runs on discs that fell through everything else.
("dbp", dbp::detect, dbp::parse),
// ("deluxe", deluxe::detect, deluxe::parse), // TODO: bytecode parser
];
@@ -268,16 +275,109 @@ fn generate_audio_label(
}
fn extract(reader: &mut dyn SectorReader, udf: &UdfFs) -> Vec<StreamLabel> {
for (_name, detect, parse) in PARSERS {
for (name, detect, parse) in PARSERS {
if detect(udf) {
tracing::info!(parser = name, "label parser matched");
if let Some(labels) = parse(reader, udf) {
return labels;
}
}
}
tracing::info!("no label parser matched");
Vec::new()
}
/// Diagnostic introspection — returns the parser that matched, the
/// labels it emitted, and the inventory of files under `/BDMV/JAR/*/`
/// that the discriminators looked at. Intended for `freemkv-tools
/// labels-analyze` and corpus regression tooling, not production code
/// paths. The matching/parsing logic is identical to [`extract`]; only
/// the return shape is richer.
#[doc(hidden)]
pub fn analyze(reader: &mut dyn SectorReader, udf: &UdfFs) -> LabelAnalysis {
let inventory = jar_inventory(udf);
// Record every parser whose discriminator matched — even if its
// parse step then returned None — so the analyzer can distinguish
// "no parser recognized this disc" from "parser recognized it but
// couldn't read the file" (e.g. content past a truncated capture)
// or "parser ran but produced no labels."
let mut parsers_detected: Vec<&'static str> = Vec::new();
for (name, detect, parse) in PARSERS {
if detect(udf) {
tracing::info!(parser = name, "label parser matched");
parsers_detected.push(name);
if let Some(labels) = parse(reader, udf) {
return LabelAnalysis {
parser: Some(name),
parsers_detected,
jar_inventory: inventory,
labels,
};
}
}
}
if parsers_detected.is_empty() {
tracing::info!("no label parser matched");
} else {
tracing::info!(
detected = ?parsers_detected,
"label parsers detected but produced no labels"
);
}
LabelAnalysis {
parser: None,
parsers_detected,
jar_inventory: inventory,
labels: Vec::new(),
}
}
/// Result of [`analyze`].
#[doc(hidden)]
#[derive(Debug, Clone)]
pub struct LabelAnalysis {
/// Which parser matched ("paramount" / "criterion" / "pixelogic" /
/// "ctrm") AND emitted labels. `None` means either no parser
/// recognized the disc, OR a parser recognized it but its parse
/// step returned None (file unreadable, no parseable tokens). Use
/// `parsers_detected` to disambiguate.
pub parser: Option<&'static str>,
/// Every parser whose discriminator matched, in priority order.
/// Distinguishes "we recognized this disc but couldn't extract
/// labels" from "we don't recognize this disc at all" — the
/// former points at a parser bug or a truncated capture, the
/// latter points at a missing parser.
pub parsers_detected: Vec<&'static str>,
/// Filenames found under any `/BDMV/JAR/*/` subdirectory, deduped
/// and sorted. Helps spot unknown authoring formats when no
/// parser detected.
pub jar_inventory: Vec<String>,
/// Raw labels emitted by the matched parser (empty if `parser` is
/// `None`).
pub labels: Vec<StreamLabel>,
}
/// List filenames found under any `/BDMV/JAR/<x>/` subdirectory of
/// the disc. Deduped, sorted. Returns an empty vec if no JAR dir is
/// present.
fn jar_inventory(udf: &UdfFs) -> Vec<String> {
let Some(jar_dir) = udf.find_dir("/BDMV/JAR") else {
return Vec::new();
};
let mut out: Vec<String> = Vec::new();
for entry in &jar_dir.entries {
if entry.is_dir {
for child in &entry.entries {
if !child.is_dir && !out.contains(&child.name) {
out.push(child.name.clone());
}
}
}
}
out.sort();
out
}
// ── Shared helpers ─────────────────────────────────────────────────────────
/// Check if a file exists in any BDMV/JAR subdirectory.
+1 -1
View File
@@ -84,7 +84,7 @@ pub(crate) mod identity;
pub(crate) mod ifo;
pub(crate) mod io;
pub mod keydb;
pub(crate) mod labels;
pub mod labels;
pub(crate) mod mpls;
pub mod mux;
pub mod pes;
+17 -9
View File
@@ -624,19 +624,27 @@ fn test_disc_copy_marks_failed_ecc_blocks_as_nontrimmed() {
let _ = std::fs::remove_file(&iso_path);
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
// Pass 1 reads every batch at bpt=32 (no batch reduction, no skip-ahead).
// BlockSizeFailingReader fails on multi-sector reads but succeeds on single-sector.
// Bridge degradation handling retries failed batches as individual sectors,
// so all data is recovered as Finished. bytes_good == total_bytes is correct.
// Pass 1's job is "fast and accurate, get the most data in the
// shortest time." It no longer bisects on marginal media — that's
// Pass N's purpose-built role. So a BlockSizeFailingReader that
// fails on multi-sector reads and succeeds on single-sector
// results in: every batch fails → SkipBlock → whole 32-sector
// ECC block marked NonTrimmed → Pass N (Disc::patch) revisits and
// recovers via single-sector reads with proper recovery semantics.
//
// Pass 1 alone:
assert_eq!(
result.bytes_good, total_bytes,
"Pass 1 at bpt=32 recovers all sectors via single-sector retry on MEDIUM_ERROR"
result.bytes_good, 0,
"Pass 1 doesn't bisect on marginal media — failed batches become NonTrimmed for Pass N to revisit"
);
assert_eq!(
result.bytes_pending, 0,
"no pending sectors after full recovery"
result.bytes_pending, total_bytes,
"every sector is NonTrimmed (pending) after Pass 1, awaiting Pass N"
);
assert!(
!result.complete,
"complete=false because NonTrimmed regions remain (Pass N's work)"
);
assert!(result.complete, "complete=true when all sectors recovered");
}
// ── 9. PassProgress carries separate unreadable vs pending byte counts ─────