mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e3327a569 | ||
|
|
0081955686 | ||
|
|
3244fdd683 | ||
|
|
ef5487e5a5 | ||
|
|
e7dffa63d2 | ||
|
|
e5989c8270 | ||
|
|
e1c938b82a | ||
|
|
ed801a708b | ||
|
|
d94451954c | ||
|
|
385c9f094c | ||
|
|
9f2a13739d |
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 ─────
|
||||
|
||||
Reference in New Issue
Block a user