mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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.4"
|
||||
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)];
|
||||
|
||||
Reference in New Issue
Block a user