Empty-tray optical drives couldn't be opened because the macOS backend
resolved drives by media-child `BSD Name`, which doesn't exist without a
disc loaded. Result: `list_drives()` returned nothing for a drive with no
media, and any attempt to `open("disc://")` on that state failed.
Fix: enumerate `IOBDServices` directly. With media present, the returned
selector is the familiar `/dev/diskN` path (unchanged). With no media,
the selector is an opaque `ioreg:<registry-id>` string that resolves to
the same IOBDServices entry — Disk Arbitration and unmount are skipped
because there's no media node.
- `src/drive/macos.rs`, `src/drive/mod.rs`, `src/scsi/mod.rs`: threads
the selector through the transport so empty-tray drives are openable
without punning it as a filesystem path.
- `src/scsi/macos.rs`, `src/scsi/macos_shim.c`: the C shim now accepts
both `/dev/diskN` and `ioreg:<id>` in `shim_open_exclusive`, and
`shim_list_drives` reports the correct selector for each drive.
- `tests/macos_drive_tests.rs` (new): covers empty-tray discovery.
- `docs/drive-access.md`, `docs/scsi-macos.md`: document the two-path
selector model.
[skip-media]
The 1.7.6 unified release ships no rip-path change (Linux desktop shell,
notifications, menu_layout refactor, XDG paths). Real-media QA on EC2
adds ~2h + real money to the release cycle to prove code that never
enters the rip pipeline. This empty commit puts `[skip-media]` in qa's
HEAD so the media matrix in qa.yml auto-skips per the documented bypass;
the full-platform builds/tests still run.
Same fix as the other 7 repos: qa held two release-orchestrate commits
(v1.7.5 bump + post-release freemkv-unlock path restore) that never
propagated back to dev, so promote's fast-forward gate refused. Ours-merge
records qa as an ancestor without applying its content; dev has already
moved past 1.7.5 to 1.7.6.
The freemkv 1.7.6 Linux target compiles a GTK4 + libadwaita desktop shell,
so this job's downstream `cargo test`/`cargo check` on freemkv hits
`gtk4-sys`/`glib-sys` pkg-config probes that fail on a stock ubuntu
runner. Adds the apt-get step alongside the existing sibling checkout,
mirroring the fix in freemkv's own ci.yml `cli-linux` job.
Kept local (not in the shared reusable checkout action) — only `freemkv`
links against GTK; adding the deps to every downstream consumer would tax
the six that don't.
Bumps Cargo.toml + Cargo.lock to 1.7.6. Also fixes a CI-shape defect the
previous release left behind: dev's CHANGELOG still said
`## [1.7.5] — UNRELEASED` after 1.7.5 shipped (release-orchestrate dated
main but did not push a fresh UNRELEASED heading back to dev). Corrects
that here so the changelog job accepts 1.7.6 on the way to qa:
## [1.7.6] — UNRELEASED
- Version aligned; unified release driven by freemkv 1.7.6.
## [1.7.5] — 2026-09-23
- Version aligned (or actual content, per the shipped main entry).
The driver for 1.7.6 is the freemkv Linux desktop shell (GTK4 +
libadwaita) + rip-finished desktop notification (issue #56).
Version aligned to 1.7.5. No functional change to this crate — the release
is driven by freemkv-unlock mirroring the freemkv-firmware 0.9.0 ABI, where
the drive's separate Ake (0x06) and Bus (0x07) levers are retired into a
single Encryption (0x06) lever.
Convert qa.yml to the central pattern already applied to ci.yml (86d66d3):
- add top-level concurrency (cancel-in-progress) group
- replace per-job freemkv-unlock checkout (ref qa) with the
freemkv/.github checkout-siblings@v1 composite (checkout: freemkv-unlock)
in release-tests, cross-lint and windows-build
- standardize actions/checkout to @v5
No heredoc patching existed in any job, so no patch inputs. The composite
resolves siblings 3-way, so qa runs now pin siblings at qa automatically.
Replaces the 8x-repeated sibling-checkout block with freemkv/.github checkout-siblings@v1.
Adds concurrency cancel-in-progress. All jobs (incl. consumers running dependent suites) preserved.
Thin callers to freemkv/.github@v1; delete the now-duplicated local ci/ scripts.
Adds concurrency cancel-in-progress and drops the push+PR double-fire (fork PRs
still run). doc-links now resolves toolchain from rust-version (was hardcoded 1.98).
Test gaps (guarded code was untested):
- disc: cover is_complete_feature_presentation's CHAPTER_SPAN_MIN_FRAC (0.5)
boundary — clustered marks (span < 0.5*dur) are not a feature; marks at the
0.5 threshold are. Adds bd_title_chaptered_span helper (issue #45).
- hevc: exercise the length-prefix self-check WIRING in HevcParser::parse
end-to-end — a desynced access unit is dropped, a well-formed one emitted.
Adds a test-only FORCE_FRAMING_DESYNC seam (frame_data is length-prefixed by
construction, so no real input desyncs it) (issue #52).
- labels: cover resolve_feature_hint's winner_hint early-return — a non-empty
winner_hint wins over the Tier-2 bdj_feature menu-walk result (issue #46).
Doc drift:
- keysource.md: resolve_unit_keys + UnitKeyResolution (matched/miss_path/
matched_entry/store_entries de-conflation).
- aacs-trace.md: MatchedEntry, KeyStep.matched_entry/store_entries, and the
KeyNode::NoDerivableKey node.
- hevc.md: add the "length-prefix self-check (issue #52)" section the hevc.rs
comment points to.
Craft:
- labels: hoist the duplicated MIN_FEATURE_SECS (60) from fox.rs and
paramount.rs to a single shared const in labels/mod.rs.
Adds a generic BD-J feature-playlist resolver that runs when no framework
parser surfaced a manifest hint, plus a refinement to Sony/Fox feature
selection. The hint now rides an independent pass so it survives even when
the winning parser (or no parser) produced any labels.
- labels/mod.rs: resolve_feature_hint() independent hint pass wired into
apply(); register mod bdj_feature.
- labels/paramount.rs (+ fox.rs mirror): refine feature selection —
exact name="Feature" → /feature/i by longest duration → most-audio by
duration; hard-reject sub-minute playlists (kills the _Start_Angle 2s
decoy). Fixes Sony SM3 UHD. feature_hint_from_xml() exposed for Tier-1.
- labels/bdj_feature.rs (NEW): Tier-1 jar-space resource sweep
(dcx.xml/playlists.xml/*.properties) reusing fox/paramount hints; Tier-2
autostart-Xlet locator scan (BDJO AMT → autostart jar → NNNNN.mpls /
PLAYLIST:N String constants → intersect real PLAYLIST set →
longest-dominant multi-audio wins; integer-only never emits).
- bdnav/bdjo.rs (NEW): minimal bounds-checked /BDMV/BDJO/*.bdjo
Application Management Table reader (autostart initial_class + jar ids),
panic-free like bdnav/index.rs.
- labels/jar.rs: try_each_resource() helper for the Tier-1 sweep.
No changes to disc/mod.rs selection or bdnav/vm.rs.
Fix (B) — the reproducible bug behind ffmpeg "Timestamps are unset in a
packet for stream N" on PGS subtitle tracks (worst on the sparse forced
track). Subtitle tracks carry no DefaultDuration, so a frame emitted with
duration_ns: None became a bare SimpleBlock with nothing to bound the cue.
- codec/pgs.rs: synthesize a fallback dwell (DEFAULT_PGS_DURATION_NS, 5 s)
for every emission that was duration_ns: None — the EOS trailing set and
the no-PTS clear/display, truncated, and lone-pass-through arms. emit_pending
keeps its real computed span but clamps a pathologically large one
(missing intermediate PCS) past MAX_PGS_DURATION_NS back to the fallback.
- mkv.rs: track_is_subtitle (track_type == 17), mirroring track_is_video. In
write_frame_at a subtitle frame with no duration now substitutes a minimum
fallback (MIN_SUBTITLE_BLOCK_DURATION_NS, 1 s) so it is emitted as a
BlockGroup WITH BlockDuration, never a bare SimpleBlock (defense in depth).
Fix (A) — defensive HEVC guard (the "Invalid NAL unit size" error is not
reproducible in our clean disc->MKV path; this makes our output structurally
incapable of originating it):
- codec/hevc.rs: length_prefix_tiles() self-consistency check — the
[u32-BE len][body] records must EXACTLY tile the frame; on mismatch the
desynced access unit is dropped (logging src position) rather than emitted.
- LENGTH_PREFIX_SIZE constant + regression test that hvcC byte-21
lengthSizeMinusOne+1 equals the prefix width push_length_prefixed writes.
Tests: pgs.rs None-arm duration assertions updated to the fallback + a
clamp/no-clamp pair; mkv.rs subtitle-BlockGroup and two-track (dense+sparse)
"every block has a BlockDuration" cases; hevc.rs tiling accept/reject,
byte-21 regression, and a positive parsed-AU-tiles-exactly test.
A BD-J disc (Alita) has no authoritative static playlist chain, so main-
feature selection is a structural failsafe. Its chaptered feature 00800 is
a SUPERSET of an un-chaptered body subset 00703 that is 0.97 of its size,
so the size-only seamless bumper limb fired and demoted the real feature,
ripping the body instead.
Gate ONLY the bumper limb on is_complete_feature_presentation(q): the
wrapped/subset title must carry a real chapter table (>= MIN_FEATURE_CHAPTERS
marks spanning >= CHAPTER_SPAN_MIN_FRAC of its runtime). The concat limb
(much-shorter cut) is untouched. This is the ABSOLUTE-threshold predicate;
a one-line swap to the RELATIVE form (p.chapters.len() <= q.chapters.len())
is documented at the call site and in the predicate doc, pending a hoard
sweep of SM3's real chapter counts.
Also add lowest playlist_id as the final canonical_title_order tie-break so
equal-size seamless siblings (00800-00808) pick 00800 deterministically.
Fixtures re-based on the real chapter discriminator (not a size coincidence)
and new tests cover both topologies (Alita superset-feature, SM3 subset-
feature) plus the un-chaptered-subset keep-superset case.
The keydb lookup returned an empty Vec for two very different outcomes:
a true hash miss, and a MATCHED disc that derived no key (e.g. an MK-only
entry with no VID). Both rendered as `keydb > no entry`, misleading
reporters whose disc IS present in the keydb.
Add KeySource::resolve_unit_keys -> UnitKeyResolution { keys, matched,
miss_path, matched_entry, store_entries }; the trait default delegates to
get_unit_keys (matched=false) so every existing source is unchanged.
resolve_and_apply_traced now renders MatchedDisc + NoVid/NoDerivableKey
for a matched-but-no-key disc and reserves NoEntry for a genuine miss.
New KeyNode::NoDerivableKey, a MatchedEntry shape (booleans + lengths, no
key material), and KeyStep.{matched_entry, store_entries} carry the
diagnostics to the app-layer renderer. Tests cover both verdict paths.
Bus content-range de-bus:
- F1 disc/mod.rs: always install content ranges (extracted wire_bus_removal);
empty ranges => Some([]) => de-bus NOTHING (fail-safe), not None => de-bus
everything — closes silent clear-sector corruption on the AACS cert route.
- F4 aacs/content.rs, F5 decrypt.rs: content gate is now a partition_point
binary search (O(log n)/sector) over the sorted, merged-disjoint ranges,
replacing the per-sector O(ranges) linear scan on the de-bus hot path.
- F2 scsi/linux.rs: RECOVERY_THREADS cap uses atomic reserve-then-check
(fetch_add first, fall back on overshoot) — removes the TOCTOU that let
concurrent multi-drive rips exceed MAX_RECOVERY_THREADS.
- F3 pes.rs: PesFrame::deserialize reads each chunk straight into data's tail;
drops the scratch Vec + double-copy, keeps the incremental-growth DoS guard.
- F7 disc/mod.rs: six inline rank_titles thresholds -> named consts.
Tests: bus fail-safe + scan wiring (both routes), per-sector gate straddle,
read_directory type-3 continuation/AED-skip/cycle-guard, strengthened
without_bus_removal no-op guard. F6 (pub decrypt_bus) left by-design.
Relocate AACS bus decryption to one spot at the bottom of the sector chain:
the Drive's SectorSource impl applies decrypt_bus_in_content (gated to
encrypted-content ranges), with the stage decided once from the unlock/
handshake result (BusStage::from_read_data_key). Remove read_data_key from
DecryptKeys::Aacs and AacsState and the two downstream decrypt_bus call sites;
every reader (key sampler, mux, unit-key validation) now sees already-de-bussed
content and never reasons about bus encryption. Add BusRemovalSectorSource
stream + tests; keep the AacsBusKeyUnavailable fail-loud gate.