mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
fix(#45): BD-J menu-walk — authoritative feature-resolution layer
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.
This commit is contained in:
@@ -0,0 +1,432 @@
|
||||
//! Minimal, read-only parser for `/BDMV/BDJO/*.bdjo` — the BD-J Object files.
|
||||
//!
|
||||
//! Only the Application Management Table (AMT) is decoded, and only the four
|
||||
//! fields the issue #45 Tier-2 menu-walk needs from each application record:
|
||||
//! its `application_control_code` (1 = AUTOSTART), `base_directory`,
|
||||
//! `classpath_extension`, and `initial_class` (the Xlet's fully-qualified class
|
||||
//! name). Everything else in the file is skipped by width.
|
||||
//!
|
||||
//! Layout mirrors libbluray's `bdjo_parse.c` (`_parse_header` +
|
||||
//! `_parse_terminal_info` + `_parse_app_cache_info` +
|
||||
//! `_parse_accessible_playlists` + `_parse_app_management_table`): the sections
|
||||
//! are laid out sequentially right after a fixed 48-byte header (8-byte
|
||||
//! magic+version, then a 40-byte section-address table that real players skip).
|
||||
//!
|
||||
//! This is a DOCUMENTED BINARY FORMAT read as data, never executed. Every field
|
||||
//! is bounds-checked and any malformed input yields `None` — panic-free like
|
||||
//! [`crate::bdnav::index`]. Tier-2 falls back to scanning every jar when this
|
||||
//! returns `None`.
|
||||
|
||||
/// One application record from a BDJO Application Management Table — only the
|
||||
/// fields the menu-walk consumes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct BdjoApp {
|
||||
/// `application_control_code`: 1 = AUTOSTART, 2 = PRESENT.
|
||||
pub control_code: u8,
|
||||
/// `base_directory` — the primary jar id (e.g. "00000"), naming
|
||||
/// `/BDMV/JAR/<base_directory>.jar`.
|
||||
pub base_dir: String,
|
||||
/// `classpath_extension` — additional `;`-separated jar ids on the
|
||||
/// classpath, or empty.
|
||||
pub classpath_extension: String,
|
||||
/// `initial_class` — the autostart Xlet's fully-qualified class name
|
||||
/// (e.g. "com.foxbd.StandardMenuXlet").
|
||||
pub initial_class: String,
|
||||
}
|
||||
|
||||
impl BdjoApp {
|
||||
/// Whether this application autostarts (the one whose Xlet drives the disc).
|
||||
pub fn is_autostart(&self) -> bool {
|
||||
self.control_code == AUTOSTART
|
||||
}
|
||||
|
||||
/// The jar ids that make up this app's classpath: `base_directory` first,
|
||||
/// then each `;`-separated `classpath_extension` entry. Empty tokens are
|
||||
/// dropped. These name `/BDMV/JAR/<id>.jar`.
|
||||
pub fn jar_ids(&self) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let base = self.base_dir.trim();
|
||||
if !base.is_empty() {
|
||||
out.push(base.to_string());
|
||||
}
|
||||
for part in self.classpath_extension.split(';') {
|
||||
let part = part.trim();
|
||||
if !part.is_empty() && !out.iter().any(|e| e == part) {
|
||||
out.push(part.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
const AUTOSTART: u8 = 1;
|
||||
|
||||
// "BDJO" magic. The 4-byte version that follows ("0100"/"0200"/"0240"/"0300")
|
||||
// is read but not validated — a future revision must still let Tier-2 fall back
|
||||
// gracefully rather than mis-reject a readable table.
|
||||
const MAGIC: u32 = u32::from_be_bytes(*b"BDJO");
|
||||
|
||||
// Cap on applications parsed from one AMT. `number_of_applications` is a u8, so
|
||||
// 255 is the hard ceiling already; the cap is a belt-and-suspenders bound.
|
||||
const MAX_APPS: usize = 256;
|
||||
|
||||
/// Parse a `.bdjo` file's Application Management Table. Returns the list of
|
||||
/// application records (in file order), or `None` on any structural problem.
|
||||
pub(crate) fn parse(data: &[u8]) -> Option<Vec<BdjoApp>> {
|
||||
let mut r = BitReader::new(data);
|
||||
|
||||
// ── Header ──────────────────────────────────────────────────────────────
|
||||
if r.read(32)? as u32 != MAGIC {
|
||||
return None;
|
||||
}
|
||||
r.skip(32)?; // version_number (not load-bearing for resolution)
|
||||
r.skip(40 * 8)?; // section-address table (players read sections sequentially)
|
||||
|
||||
// ── TerminalInfo ────────────────────────────────────────────────────────
|
||||
r.skip(32)?; // length
|
||||
r.skip(5 * 8)?; // default_font (5 bytes)
|
||||
r.skip(4 + 1 + 1)?; // initial_havi_config_id + menu_call_mask + title_search_mask
|
||||
r.skip(34)?; // padding
|
||||
|
||||
// ── AppCacheInfo ──────────────────────────────────────────────────────────
|
||||
r.skip(32)?; // length
|
||||
let num_item = r.read(8)? as usize;
|
||||
r.skip(8)?; // padding
|
||||
for _ in 0..num_item {
|
||||
r.skip(12 * 8)?; // each item: type(1) + ref_to_name(5) + lang_code(3) + pad(3)
|
||||
}
|
||||
|
||||
// ── AccessiblePlaylists ───────────────────────────────────────────────────
|
||||
r.skip(32)?; // length
|
||||
let num_pl = r.read(11)? as usize;
|
||||
r.skip(1 + 1)?; // access_to_all_flag + autostart_first_playlist_flag
|
||||
r.skip(19)?; // padding
|
||||
for _ in 0..num_pl {
|
||||
r.skip(6 * 8)?; // each: name(5) + pad(1)
|
||||
}
|
||||
|
||||
// ── ApplicationManagementTable ────────────────────────────────────────────
|
||||
r.skip(32)?; // length
|
||||
let num_app = r.read(8)? as usize;
|
||||
r.skip(8)?; // padding
|
||||
if num_app > MAX_APPS {
|
||||
return None;
|
||||
}
|
||||
let mut apps = Vec::with_capacity(num_app);
|
||||
for _ in 0..num_app {
|
||||
apps.push(parse_app(&mut r)?);
|
||||
}
|
||||
Some(apps)
|
||||
}
|
||||
|
||||
// Parse one Application() record (`_parse_bdjo_app` in libbluray).
|
||||
fn parse_app(r: &mut BitReader<'_>) -> Option<BdjoApp> {
|
||||
let control_code = r.read(8)? as u8;
|
||||
r.skip(4)?; // application_type
|
||||
r.skip(4)?; // reserved
|
||||
r.skip(32)?; // organization_id
|
||||
r.skip(16)?; // application_id
|
||||
r.skip(80)?; // descriptor tag + length (10 bytes)
|
||||
|
||||
// application descriptor
|
||||
let num_profile = r.read(4)? as usize;
|
||||
r.skip(12)?; // padding
|
||||
for _ in 0..num_profile {
|
||||
r.skip(6 * 8)?; // profile_number(2) + major/minor/micro(3) + pad(1)
|
||||
}
|
||||
|
||||
r.skip(8)?; // application_priority
|
||||
r.skip(2 + 2 + 4)?; // binding + visibility + reserved
|
||||
|
||||
// application_name section: u16 data_length, then that many bytes, word-aligned.
|
||||
let names_len = r.read(16)? as usize;
|
||||
r.skip(names_len * 8)?;
|
||||
if !names_len.is_multiple_of(2) {
|
||||
r.skip(8)?;
|
||||
}
|
||||
|
||||
// icon_locator (word-aligned string), then icon_flags(16).
|
||||
read_app_string(r)?;
|
||||
r.skip(16)?;
|
||||
|
||||
let base_dir = read_app_string(r)?;
|
||||
let classpath_extension = read_app_string(r)?;
|
||||
let initial_class = read_app_string(r)?;
|
||||
|
||||
// application_parameters: u8 data_length, that many bytes, word-aligned.
|
||||
let params_len = r.read(8)? as usize;
|
||||
r.skip(params_len * 8)?;
|
||||
if params_len.is_multiple_of(2) {
|
||||
r.skip(8)?;
|
||||
}
|
||||
|
||||
Some(BdjoApp {
|
||||
control_code,
|
||||
base_dir,
|
||||
classpath_extension,
|
||||
initial_class,
|
||||
})
|
||||
}
|
||||
|
||||
// A word-aligned length-prefixed string (`_read_app_string`): u8 length, then
|
||||
// `length` bytes, then one pad byte when `length` is EVEN. Bytes are Latin-1
|
||||
// decoded (FQCNs/jar ids are ASCII); trailing NULs are trimmed.
|
||||
fn read_app_string(r: &mut BitReader<'_>) -> Option<String> {
|
||||
let len = r.read(8)? as usize;
|
||||
let mut s = String::with_capacity(len);
|
||||
for _ in 0..len {
|
||||
s.push(r.read(8)? as u8 as char);
|
||||
}
|
||||
if len.is_multiple_of(2) {
|
||||
r.skip(8)?;
|
||||
}
|
||||
Some(s.trim_end_matches('\0').to_string())
|
||||
}
|
||||
|
||||
// ── MSB-first bit reader ─────────────────────────────────────────────────────
|
||||
// Mirrors libbluray's BITSTREAM: bits are consumed most-significant-first.
|
||||
// Every read/skip is bounds-checked and returns `None` past the end.
|
||||
struct BitReader<'a> {
|
||||
data: &'a [u8],
|
||||
bit_pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> BitReader<'a> {
|
||||
fn new(data: &'a [u8]) -> Self {
|
||||
BitReader { data, bit_pos: 0 }
|
||||
}
|
||||
|
||||
fn total_bits(&self) -> usize {
|
||||
self.data.len() * 8
|
||||
}
|
||||
|
||||
// Read up to 64 bits, MSB-first. `None` if fewer than `n` bits remain.
|
||||
fn read(&mut self, n: u32) -> Option<u64> {
|
||||
if n == 0 {
|
||||
return Some(0);
|
||||
}
|
||||
if n > 64 {
|
||||
return None;
|
||||
}
|
||||
let end = self.bit_pos.checked_add(n as usize)?;
|
||||
if end > self.total_bits() {
|
||||
return None;
|
||||
}
|
||||
let mut v: u64 = 0;
|
||||
for _ in 0..n {
|
||||
let byte = self.data[self.bit_pos >> 3];
|
||||
let bit = (byte >> (7 - (self.bit_pos & 7))) & 1;
|
||||
v = (v << 1) | bit as u64;
|
||||
self.bit_pos += 1;
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
|
||||
// Advance `n` bits. `None` if that would run past the end.
|
||||
fn skip(&mut self, n: usize) -> Option<()> {
|
||||
let end = self.bit_pos.checked_add(n)?;
|
||||
if end > self.total_bits() {
|
||||
return None;
|
||||
}
|
||||
self.bit_pos = end;
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── Bit reader unit tests ──────────────────────────────────────────────
|
||||
#[test]
|
||||
fn bit_reader_reads_msb_first_and_bounds_checks() {
|
||||
let data = [0b1011_0010u8, 0b0100_0001];
|
||||
let mut r = BitReader::new(&data);
|
||||
assert_eq!(r.read(1), Some(1));
|
||||
assert_eq!(r.read(3), Some(0b011));
|
||||
assert_eq!(r.read(4), Some(0b0010));
|
||||
assert_eq!(r.read(8), Some(0b0100_0001));
|
||||
// Nothing left.
|
||||
assert_eq!(r.read(1), None);
|
||||
assert_eq!(r.read(0), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bit_reader_skip_past_end_is_none() {
|
||||
let data = [0u8; 2];
|
||||
let mut r = BitReader::new(&data);
|
||||
assert_eq!(r.skip(16), Some(()));
|
||||
assert_eq!(r.skip(1), None);
|
||||
}
|
||||
|
||||
// ── Fixture builder ─────────────────────────────────────────────────────
|
||||
// Byte-aligned BDJO builder for a single-app AMT. All bit fields here happen
|
||||
// to fall on byte boundaries, so the fixture can be assembled from bytes.
|
||||
struct AppSpec {
|
||||
control_code: u8,
|
||||
base_dir: &'static str,
|
||||
classpath_extension: &'static str,
|
||||
initial_class: &'static str,
|
||||
}
|
||||
|
||||
fn push_app_string(buf: &mut Vec<u8>, s: &str) {
|
||||
buf.push(s.len() as u8);
|
||||
buf.extend_from_slice(s.as_bytes());
|
||||
if s.len().is_multiple_of(2) {
|
||||
buf.push(0); // word-align pad on even length
|
||||
}
|
||||
}
|
||||
|
||||
fn build_bdjo(apps: &[AppSpec]) -> Vec<u8> {
|
||||
let mut b = Vec::new();
|
||||
// Header
|
||||
b.extend_from_slice(b"BDJO");
|
||||
b.extend_from_slice(b"0200");
|
||||
b.extend_from_slice(&[0u8; 40]); // section-address table
|
||||
|
||||
// TerminalInfo: length(4) + default_font(5) + 1 byte (havi+masks) + pad(34 bits)
|
||||
b.extend_from_slice(&[0u8; 4]);
|
||||
b.extend_from_slice(&[0u8; 5]);
|
||||
// 4+1+1 = 6 bits then 34 bits padding = 40 bits = 5 bytes total.
|
||||
b.extend_from_slice(&[0u8; 5]);
|
||||
|
||||
// AppCacheInfo: length(4) + num_item(1)=0 + pad(1)
|
||||
b.extend_from_slice(&[0u8; 4]);
|
||||
b.push(0);
|
||||
b.push(0);
|
||||
|
||||
// AccessiblePlaylists: length(4) + [num_pl(11)+flags(2)+pad(19)] = 4 bytes, num_pl=0
|
||||
b.extend_from_slice(&[0u8; 4]);
|
||||
b.extend_from_slice(&[0u8; 4]);
|
||||
|
||||
// AppManagementTable: length(4) + num_app(1) + pad(1)
|
||||
b.extend_from_slice(&[0u8; 4]);
|
||||
b.push(apps.len() as u8);
|
||||
b.push(0);
|
||||
|
||||
for a in apps {
|
||||
b.push(a.control_code); // control_code(8)
|
||||
b.push(0); // type(4)+reserved(4)
|
||||
b.extend_from_slice(&[0u8; 4]); // org_id(32)
|
||||
b.extend_from_slice(&[0u8; 2]); // app_id(16)
|
||||
b.extend_from_slice(&[0u8; 10]); // descriptor tag+length(80)
|
||||
b.push(0); // num_profile(4)+pad(4)... wait 4+12=16 bits
|
||||
b.push(0); // (num_profile nibble high, then 12 bits pad) => 2 bytes, 0 profiles
|
||||
b.push(0); // priority(8)
|
||||
b.push(0); // binding(2)+visibility(2)+reserved(4)
|
||||
// application_name: data_length(16)=0, no word-align (even 0)
|
||||
b.extend_from_slice(&[0u8; 2]);
|
||||
// icon_locator (empty word-aligned string): len=0 + pad
|
||||
push_app_string(&mut b, "");
|
||||
b.extend_from_slice(&[0u8; 2]); // icon_flags(16)
|
||||
push_app_string(&mut b, a.base_dir);
|
||||
push_app_string(&mut b, a.classpath_extension);
|
||||
push_app_string(&mut b, a.initial_class);
|
||||
// application_parameters: data_length(8)=0 + word-align (even) pad
|
||||
b.push(0);
|
||||
b.push(0);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_autostart_app_fqcn_and_jar_ids() {
|
||||
let bytes = build_bdjo(&[AppSpec {
|
||||
control_code: 1,
|
||||
base_dir: "00000",
|
||||
classpath_extension: "00001;00002",
|
||||
initial_class: "com.foxbd.StandardMenuXlet",
|
||||
}]);
|
||||
let apps = parse(&bytes).expect("parses");
|
||||
assert_eq!(apps.len(), 1);
|
||||
let a = &apps[0];
|
||||
assert!(a.is_autostart());
|
||||
assert_eq!(a.initial_class, "com.foxbd.StandardMenuXlet");
|
||||
assert_eq!(a.base_dir, "00000");
|
||||
assert_eq!(a.jar_ids(), vec!["00000", "00001", "00002"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_multiple_apps_and_finds_the_autostart_one() {
|
||||
let bytes = build_bdjo(&[
|
||||
AppSpec {
|
||||
control_code: 2, // PRESENT, not autostart
|
||||
base_dir: "00009",
|
||||
classpath_extension: "",
|
||||
initial_class: "com.studio.Helper",
|
||||
},
|
||||
AppSpec {
|
||||
control_code: 1, // AUTOSTART
|
||||
base_dir: "00000",
|
||||
classpath_extension: "",
|
||||
initial_class: "com.studio.MainXlet",
|
||||
},
|
||||
]);
|
||||
let apps = parse(&bytes).expect("parses");
|
||||
assert_eq!(apps.len(), 2);
|
||||
let auto: Vec<&BdjoApp> = apps.iter().filter(|a| a.is_autostart()).collect();
|
||||
assert_eq!(auto.len(), 1);
|
||||
assert_eq!(auto[0].initial_class, "com.studio.MainXlet");
|
||||
assert_eq!(auto[0].jar_ids(), vec!["00000"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_magic() {
|
||||
let mut bytes = build_bdjo(&[AppSpec {
|
||||
control_code: 1,
|
||||
base_dir: "00000",
|
||||
classpath_extension: "",
|
||||
initial_class: "X",
|
||||
}]);
|
||||
bytes[0..4].copy_from_slice(b"NOPE");
|
||||
assert!(parse(&bytes).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_yields_none_never_panics() {
|
||||
let bytes = build_bdjo(&[AppSpec {
|
||||
control_code: 1,
|
||||
base_dir: "00000",
|
||||
classpath_extension: "",
|
||||
initial_class: "com.studio.MainXlet",
|
||||
}]);
|
||||
// Every truncation length must return None (or Some), never panic.
|
||||
for cut in 0..bytes.len() {
|
||||
let _ = parse(&bytes[..cut]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_never_panics_on_random_bytes() {
|
||||
let mut state: u64 = 0xB0D0_DEAD_BEEF_1234u64;
|
||||
for _ in 0..500 {
|
||||
// xorshift64*
|
||||
let mut x = state;
|
||||
x ^= x << 13;
|
||||
x ^= x >> 7;
|
||||
x ^= x << 17;
|
||||
state = x;
|
||||
let len = (x % 300) as usize;
|
||||
let mut buf = vec![0u8; len];
|
||||
for (i, b) in buf.iter_mut().enumerate() {
|
||||
*b = ((x >> (i % 57)) & 0xFF) as u8;
|
||||
}
|
||||
// Give some of them a valid magic to exercise deeper paths.
|
||||
if len >= 4 && x & 1 == 0 {
|
||||
buf[0..4].copy_from_slice(b"BDJO");
|
||||
}
|
||||
let _ = parse(&buf);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jar_ids_dedups_and_skips_empties() {
|
||||
let a = BdjoApp {
|
||||
control_code: 1,
|
||||
base_dir: "00000".into(),
|
||||
classpath_extension: "00000;;00003; ".into(),
|
||||
initial_class: "X".into(),
|
||||
};
|
||||
assert_eq!(a.jar_ids(), vec!["00000", "00003"]);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
//!
|
||||
//! Contract: read-only, bounded, and never panics or hard-fails.
|
||||
|
||||
pub(crate) mod bdjo;
|
||||
pub(crate) mod index;
|
||||
pub(crate) mod mobj;
|
||||
pub(crate) mod vm;
|
||||
|
||||
+65
-12
@@ -155,12 +155,25 @@ pub(crate) fn labels_from_dcx(text: &str) -> Vec<StreamLabel> {
|
||||
labels
|
||||
}
|
||||
|
||||
// Pick the richest `<playlist name="feature">` element (ties to first) —
|
||||
// Fox presses one per regional variant. Returning ONE avoids merging two
|
||||
// STN tables. See docs/fox.md for full rationale.
|
||||
// A feature runs at least this long (`durs` is seconds — durs=7628 is a 2h7m
|
||||
// feature). A stated sub-minute `name="feature"` is a decoy and is never
|
||||
// selected; the guard is inert when no `durs` is present. Mirrors paramount.
|
||||
const MIN_FEATURE_SECS: u64 = 60;
|
||||
|
||||
// The `durs` (seconds) of a `<playlist>` element, digits only, when stated.
|
||||
fn playlist_duration_secs(element: &str) -> Option<u64> {
|
||||
let v = xml::attr(element, "durs")?;
|
||||
let digits: String = v.chars().filter(|c| c.is_ascii_digit()).collect();
|
||||
digits.parse::<u64>().ok().filter(|&d| d > 0)
|
||||
}
|
||||
|
||||
// Pick the richest `<playlist name="feature">` (most nested streams; a `durs`
|
||||
// tiebreak breaks a count tie by longest time; first feature wins a full tie; a
|
||||
// stated sub-minute feature is skipped). See docs/fox.md for full rationale.
|
||||
fn select_feature_playlist(text: &str) -> Option<&str> {
|
||||
let mut best: Option<&str> = None;
|
||||
let mut best_streams = 0usize;
|
||||
// rank = (nested stream count, durs seconds). None durs sorts as 0 so it
|
||||
// never displaces a stream-count-equal sibling that states a longer time.
|
||||
let mut best: Option<(&str, (usize, u64))> = None;
|
||||
let mut from = 0;
|
||||
while let Some((s, e)) = xml::find_element(text, "playlist", from) {
|
||||
let element = &text[s..e];
|
||||
@@ -173,16 +186,21 @@ fn select_feature_playlist(text: &str) -> Option<&str> {
|
||||
if !is_feature {
|
||||
continue;
|
||||
}
|
||||
let dur = playlist_duration_secs(element);
|
||||
// A stated sub-minute feature is a decoy — never the real feature.
|
||||
if dur.is_some_and(|d| d < MIN_FEATURE_SECS) {
|
||||
continue;
|
||||
}
|
||||
let streams = count_elements(element, "audio") + count_elements(element, "subtitle");
|
||||
// First feature wins the tie (matches the registry's first-wins rule);
|
||||
// a strictly richer table displaces it. `best.is_none()` so a feature
|
||||
// with zero nested streams is still selected — its id feeds the hint.
|
||||
if best.is_none() || streams > best_streams {
|
||||
best_streams = streams;
|
||||
best = Some(element);
|
||||
let rank = (streams, dur.unwrap_or(0));
|
||||
// Strictly-better rank displaces; a full tie keeps the first feature.
|
||||
// `best.is_none()` so a feature with zero nested streams is still
|
||||
// selected — its id feeds the hint.
|
||||
if best.is_none_or(|(_, br)| rank > br) {
|
||||
best = Some((element, rank));
|
||||
}
|
||||
}
|
||||
best
|
||||
best.map(|(el, _)| el)
|
||||
}
|
||||
|
||||
/// The authoring id (digits only, e.g. "00800") of the selected feature
|
||||
@@ -485,6 +503,41 @@ mod tests {
|
||||
assert_eq!(feature_playlist_id(menus_only), None);
|
||||
}
|
||||
|
||||
/// On an equal nested-stream count, the longer `durs` (seconds) wins the
|
||||
/// tiebreak rather than document order.
|
||||
#[test]
|
||||
fn feature_duration_breaks_a_stream_count_tie() {
|
||||
let doc = r#"<dcx><disc>
|
||||
<playlist id="00800" lang="eng" name="feature" durs="6000">
|
||||
<audio id="01" lang="eng" type="feature"/>
|
||||
<subtitle id="01" lang="eng" type="feature"/>
|
||||
</playlist>
|
||||
<playlist id="00900" lang="eng" name="feature" durs="8000">
|
||||
<audio id="01" lang="eng" type="feature"/>
|
||||
<subtitle id="01" lang="eng" type="feature"/>
|
||||
</playlist>
|
||||
</disc></dcx>"#;
|
||||
assert_eq!(feature_playlist_id(doc), Some("00900".to_string()));
|
||||
}
|
||||
|
||||
/// A stated sub-minute `name="feature"` is a decoy and is skipped in favour
|
||||
/// of the real (longer) feature.
|
||||
#[test]
|
||||
fn sub_minute_feature_is_skipped() {
|
||||
let doc = r#"<dcx><disc>
|
||||
<playlist id="00050" lang="eng" name="feature" durs="4">
|
||||
<audio id="01" lang="eng" type="feature"/>
|
||||
<audio id="02" lang="fra" type="feature"/>
|
||||
<subtitle id="01" lang="eng" type="feature"/>
|
||||
</playlist>
|
||||
<playlist id="00800" lang="eng" name="feature" durs="7628">
|
||||
<audio id="01" lang="eng" type="feature"/>
|
||||
</playlist>
|
||||
</disc></dcx>"#;
|
||||
// 00050 has more nested streams but is sub-minute → rejected; 00800 wins.
|
||||
assert_eq!(feature_playlist_id(doc), Some("00800".to_string()));
|
||||
}
|
||||
|
||||
/// A feature playlist with no nested streams (an authoring edge) produces
|
||||
/// no labels rather than a spurious empty-slot entry.
|
||||
#[test]
|
||||
|
||||
@@ -18,6 +18,11 @@ use zip::ZipArchive;
|
||||
// pre-sizing. See docs/jar.md — MAX_CLASS_BYTES rationale.
|
||||
const MAX_CLASS_BYTES: u64 = 64 * 1024 * 1024;
|
||||
|
||||
// Cap on bytes read from one non-`.class` resource entry (the dcx.xml /
|
||||
// playlists.xml / *.properties manifests the Tier-1 sweep reads). Same
|
||||
// attacker-controlled-size defence as MAX_CLASS_BYTES — see docs/jar.md.
|
||||
const MAX_RESOURCE_BYTES: u64 = 16 * 1024 * 1024;
|
||||
|
||||
/// In-memory zip archive: backed by a `Vec<u8>` read from UDF. Owns
|
||||
/// the buffer; callers pass it to [`has_path_prefix`], [`for_each_class`],
|
||||
/// etc.
|
||||
@@ -119,6 +124,44 @@ where
|
||||
None
|
||||
}
|
||||
|
||||
/// Iterate every NON-`.class` entry in the jar, read its bytes (bounded by
|
||||
/// [`MAX_RESOURCE_BYTES`]), and call `f` with `(entry_name, &bytes)`. Returns
|
||||
/// the first `Some(R)` the callback produces, or `None` when every resource was
|
||||
/// visited without a hit.
|
||||
///
|
||||
/// This is the Tier-1 menu-walk sweep: newer discs embed the same
|
||||
/// `dcx.xml`/`playlists.xml` manifests INSIDE a jar rather than as loose
|
||||
/// `/BDMV/JAR/<id>/` files. Directory entries and `.class` files are skipped;
|
||||
/// unreadable entries are silently ignored (robustness over completeness).
|
||||
pub fn try_each_resource<R, F>(archive: &mut Jar, mut f: F) -> Option<R>
|
||||
where
|
||||
F: FnMut(&str, &[u8]) -> Option<R>,
|
||||
{
|
||||
for i in 0..archive.len() {
|
||||
let Ok(entry) = archive.by_index(i) else {
|
||||
continue;
|
||||
};
|
||||
let name = entry.name().to_string();
|
||||
if name.ends_with('/') || name.to_ascii_lowercase().ends_with(".class") {
|
||||
continue;
|
||||
}
|
||||
// Declared uncompressed size is attacker-controlled — grow the buffer
|
||||
// incrementally and cap the read rather than pre-sizing from it.
|
||||
let mut bytes = Vec::new();
|
||||
if entry
|
||||
.take(MAX_RESOURCE_BYTES)
|
||||
.read_to_end(&mut bytes)
|
||||
.is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(r) = f(&name, &bytes) {
|
||||
return Some(r);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -292,4 +335,36 @@ mod tests {
|
||||
for_each_class(&mut jar, |_, _| visited += 1);
|
||||
assert_eq!(visited, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_each_resource_reads_non_class_entries_and_skips_classes() {
|
||||
let xml = b"<dcx><disc/></dcx>";
|
||||
// A .class entry must be skipped; the .xml resource must be surfaced.
|
||||
let mut jar = {
|
||||
use std::io::{Cursor, Write as _};
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut w = zip::ZipWriter::new(Cursor::new(&mut buf));
|
||||
let opts = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Stored);
|
||||
w.start_file("com/x/Menu.class", opts).unwrap();
|
||||
w.write_all(MINIMAL_CLASS).unwrap();
|
||||
w.start_file("00000/dcx.xml", opts).unwrap();
|
||||
w.write_all(xml).unwrap();
|
||||
w.finish().unwrap();
|
||||
}
|
||||
ZipArchive::new(Cursor::new(buf)).expect("valid zip")
|
||||
};
|
||||
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
let found: Option<Vec<u8>> = try_each_resource(&mut jar, |name, bytes| {
|
||||
seen.push(name.to_string());
|
||||
name.ends_with("dcx.xml").then(|| bytes.to_vec())
|
||||
});
|
||||
assert_eq!(found.as_deref(), Some(&xml[..]));
|
||||
assert!(
|
||||
!seen.iter().any(|n| n.ends_with(".class")),
|
||||
"a .class entry must never be offered as a resource"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+99
-2
@@ -8,6 +8,7 @@
|
||||
//! (set [`ParseResult::confidence`] to drive tie-breaking)
|
||||
//! 4. Add `mod myformat;` below and one line to `PARSERS` array
|
||||
|
||||
mod bdj_feature;
|
||||
mod bdmt;
|
||||
pub(crate) mod class_reader;
|
||||
pub mod clpi_audit;
|
||||
@@ -252,13 +253,35 @@ pub fn apply(
|
||||
udf: &UdfFs,
|
||||
titles: &mut [DiscTitle],
|
||||
) -> Option<FeaturePlaylistHint> {
|
||||
let (labels, feature_playlist) =
|
||||
let (labels, winner_hint) =
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| extract(reader, udf)))
|
||||
.unwrap_or_default();
|
||||
if !labels.is_empty() {
|
||||
apply_labels(&labels, titles);
|
||||
}
|
||||
feature_playlist.filter(|h| !h.is_empty())
|
||||
// The feature hint rides its own pass, independent of whether any parser
|
||||
// produced labels (like bdmt): a jar-only disc still yields a hint. Wrapped
|
||||
// separately so a menu-walk fault can't lose an already-extracted hint.
|
||||
let hint = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
resolve_feature_hint(reader, udf, winner_hint)
|
||||
}))
|
||||
.ok()
|
||||
.flatten();
|
||||
hint.filter(|h| !h.is_empty())
|
||||
}
|
||||
|
||||
// The disc's feature-playlist hint: the winning parser's manifest hint when it
|
||||
// carries one, else the generic BD-J menu-walk (`bdj_feature::resolve`). Kept
|
||||
// separate from extraction so a hint survives an empty/absent label set.
|
||||
fn resolve_feature_hint(
|
||||
reader: &mut dyn SectorSource,
|
||||
udf: &UdfFs,
|
||||
winner_hint: Option<FeaturePlaylistHint>,
|
||||
) -> Option<FeaturePlaylistHint> {
|
||||
if let Some(hint) = winner_hint.filter(|h| !h.is_empty()) {
|
||||
return Some(hint);
|
||||
}
|
||||
bdj_feature::resolve(reader, udf)
|
||||
}
|
||||
|
||||
// Min streams of one type before a language sequence anchors the label
|
||||
@@ -3376,3 +3399,77 @@ mod clpi_orphan_tests {
|
||||
assert_eq!(labels.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── apply(): independent feature-hint pass (issue #45 menu-walk) ─────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod feature_hint_pass_tests {
|
||||
use super::*;
|
||||
use crate::udf::fixture::*;
|
||||
use std::io::{Cursor, Write as _};
|
||||
|
||||
// Zip entries into an in-memory jar (Stored, no compression).
|
||||
fn build_jar(entries: &[(&str, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut w = zip::ZipWriter::new(Cursor::new(&mut buf));
|
||||
let opts = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Stored);
|
||||
for (name, data) in entries {
|
||||
w.start_file(*name, opts).unwrap();
|
||||
w.write_all(data).unwrap();
|
||||
}
|
||||
w.finish().unwrap();
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
/// A jar-only disc — no loose manifest, no vendor labels, no parser matches —
|
||||
/// still yields a feature hint, because the menu-walk hint pass runs
|
||||
/// independently of `labels.is_empty()`. This is the whole point of routing
|
||||
/// the hint through `resolve_feature_hint` rather than dropping it with the
|
||||
/// (empty) label set. The embedded `playlists.xml` names the feature 00800.
|
||||
#[test]
|
||||
fn apply_returns_hint_for_a_jar_only_disc_with_no_labels() {
|
||||
let xml = br#"<playlists>
|
||||
<playlist name="Feature" id="00800" aud="eng,fra,spa" duration="7000" />
|
||||
<playlist name="Preview" id="00050" aud="eng" duration="90" />
|
||||
</playlists>"#;
|
||||
let jar = build_jar(&[
|
||||
("com/studio/Menu.class", Vec::new()), // an entry, not a parseable class
|
||||
("00000/playlists.xml", xml.to_vec()),
|
||||
]);
|
||||
let jar_dir = DirSpec {
|
||||
name: "JAR".to_string(),
|
||||
icb_lba: 30,
|
||||
dir_data_lba: 31,
|
||||
files: vec![file_with("00000.jar", 32, 4000, jar, true)],
|
||||
subdirs: vec![],
|
||||
};
|
||||
let bdmv = DirSpec {
|
||||
name: "BDMV".to_string(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![jar_dir],
|
||||
};
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![bdmv],
|
||||
};
|
||||
let mut disc = MemDisc::new();
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
// No titles to label; the hint pass must still fire.
|
||||
let mut titles: Vec<DiscTitle> = Vec::new();
|
||||
let hint =
|
||||
apply(&mut disc, &udf, &mut titles).expect("a jar-only disc still yields a hint");
|
||||
assert_eq!(hint.playlist_id, Some(800));
|
||||
assert_eq!(hint.filename.as_deref(), Some("00800.mpls"));
|
||||
}
|
||||
}
|
||||
|
||||
+189
-24
@@ -212,37 +212,137 @@ fn labels_from_feature(feature: &str) -> Vec<StreamLabel> {
|
||||
labels
|
||||
}
|
||||
|
||||
/// Find the feature playlist element (the one with the most non-empty
|
||||
/// audio slots).
|
||||
/// The feature playlist hint for a whole `playlists.xml` document — selects the
|
||||
/// feature element and derives its id/filename. Exposed for the generic BD-J
|
||||
/// menu-walk (`bdj_feature`) Tier-1 sweep, which finds this manifest embedded
|
||||
/// inside a jar rather than as a loose file.
|
||||
pub(crate) fn feature_hint_from_xml(text: &str) -> Option<super::FeaturePlaylistHint> {
|
||||
let feature = find_feature_playlist(text)?;
|
||||
feature_hint(&feature)
|
||||
}
|
||||
|
||||
// A playlist must run at least this long (seconds) to be the feature — kills the
|
||||
// `_Start_Angle` 2-second decoy (Sony SM3 UHD id 00243). Applied only when a
|
||||
// duration is actually stated; absent, it stays inert.
|
||||
const MIN_FEATURE_SECS: u64 = 60;
|
||||
|
||||
// A `<playlist>` element's stated running time in seconds, if any. Read from the
|
||||
// first present of a set of duration-like attributes (the corpus is not a spec;
|
||||
// `durs` matches the sibling Fox manifest's seconds convention). Digits only.
|
||||
fn playlist_duration_secs(element: &str) -> Option<u64> {
|
||||
for key in ["duration", "durs", "dur", "runtime", "length", "len"] {
|
||||
if let Some(v) = xml::attr(element, key) {
|
||||
let digits: String = v.chars().filter(|c| c.is_ascii_digit()).collect();
|
||||
if let Ok(secs) = digits.parse::<u64>()
|
||||
&& secs > 0
|
||||
{
|
||||
return Some(secs);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// A feature-selection candidate: the element text, its non-empty audio-slot
|
||||
// count, and its stated duration (seconds) when known.
|
||||
struct Candidate {
|
||||
element: String,
|
||||
aud: usize,
|
||||
dur: Option<u64>,
|
||||
}
|
||||
|
||||
impl Candidate {
|
||||
// A candidate whose duration is stated AND sub-minute can never be the
|
||||
// feature — the _Start_Angle decoy guard.
|
||||
fn is_sub_minute(&self) -> bool {
|
||||
self.dur.is_some_and(|d| d < MIN_FEATURE_SECS)
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the feature playlist element. Order, refined for Sony SM3 UHD:
|
||||
/// 1. exact `name="Feature"` (case-insensitive) — longest-duration among any,
|
||||
/// else first;
|
||||
/// 2. else any name matching `/feature/i` (`_Feature`, `Feature_A`, …) —
|
||||
/// longest-duration among them, else the most-audio one;
|
||||
/// 3. else the most-audio playlist overall, breaking ties by longest duration.
|
||||
///
|
||||
/// A playlist whose stated duration is sub-minute is never returned (the
|
||||
/// `_Start_Angle` decoy).
|
||||
fn find_feature_playlist(text: &str) -> Option<String> {
|
||||
let mut best: Option<String> = None;
|
||||
let mut best_aud_count = 0;
|
||||
let mut exact: Vec<Candidate> = Vec::new();
|
||||
let mut feature_like: Vec<Candidate> = Vec::new();
|
||||
let mut all: Vec<Candidate> = Vec::new();
|
||||
let mut from = 0;
|
||||
|
||||
while let Some((start, end)) = xml::find_element(text, "playlist", from) {
|
||||
let element = &text[start..end];
|
||||
|
||||
// Prefer name="Feature" explicitly.
|
||||
if let Some(name) = xml::attr(element, "name")
|
||||
&& name.eq_ignore_ascii_case("Feature")
|
||||
{
|
||||
return Some(element.to_string());
|
||||
}
|
||||
|
||||
// Otherwise pick the one with the most audio streams. Count only
|
||||
// non-empty slots so a malformed `aud=",,,,,"` can't outscore a
|
||||
// legitimate feature.
|
||||
if let Some(aud) = xml::attr(element, "aud") {
|
||||
let count = aud.split(',').filter(|s| !s.trim().is_empty()).count();
|
||||
if count > best_aud_count {
|
||||
best_aud_count = count;
|
||||
best = Some(element.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
from = end;
|
||||
|
||||
// Count only non-empty audio slots so a malformed `aud=",,,,,"` can't
|
||||
// outscore a legitimate feature.
|
||||
let aud = xml::attr(element, "aud")
|
||||
.map(|a| a.split(',').filter(|s| !s.trim().is_empty()).count())
|
||||
.unwrap_or(0);
|
||||
let dur = playlist_duration_secs(element);
|
||||
let make = || Candidate {
|
||||
element: element.to_string(),
|
||||
aud,
|
||||
dur,
|
||||
};
|
||||
|
||||
match xml::attr(element, "name") {
|
||||
Some(name) if name.eq_ignore_ascii_case("Feature") => exact.push(make()),
|
||||
Some(name) if name.to_ascii_lowercase().contains("feature") => {
|
||||
feature_like.push(make())
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
all.push(make());
|
||||
}
|
||||
best
|
||||
|
||||
// Tier 1: exact name="Feature" — longest duration, else first. A
|
||||
// zero-audio exact feature is still valid (its id feeds the hint).
|
||||
if let Some(el) = pick(&exact, Key::Duration, false) {
|
||||
return Some(el);
|
||||
}
|
||||
// Tier 2: /feature/i names (Sony `_Feature`, `Feature_A`, `Feature_B`) —
|
||||
// longest duration among them, else most audio.
|
||||
if let Some(el) = pick(&feature_like, Key::Duration, true) {
|
||||
return Some(el);
|
||||
}
|
||||
// Tier 3: most audio across all playlists, longest duration as the tiebreak.
|
||||
pick(&all, Key::Audio, true)
|
||||
}
|
||||
|
||||
// Which signal dominates candidate ranking: duration (tiers 1-2) or audio-slot
|
||||
// count (tier 3). The other is the tiebreak.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Key {
|
||||
Duration,
|
||||
Audio,
|
||||
}
|
||||
|
||||
// Choose the best candidate under `key`: skip sub-minute decoys; first-wins on a
|
||||
// full tie (strict `>`). When `require_audio`, a candidate needs at least one
|
||||
// audio slot to be eligible. `None` duration sorts below any stated duration.
|
||||
fn pick(cands: &[Candidate], key: Key, require_audio: bool) -> Option<String> {
|
||||
let rank = |c: &Candidate| match key {
|
||||
Key::Duration => (c.dur, c.aud as u64),
|
||||
Key::Audio => (Some(c.aud as u64), c.dur.unwrap_or(0)),
|
||||
};
|
||||
let mut best: Option<&Candidate> = None;
|
||||
for c in cands {
|
||||
if c.is_sub_minute() {
|
||||
continue;
|
||||
}
|
||||
if require_audio && c.aud == 0 {
|
||||
continue;
|
||||
}
|
||||
if best.is_none_or(|b| rank(c) > rank(b)) {
|
||||
best = Some(c);
|
||||
}
|
||||
}
|
||||
best.map(|c| c.element.clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -750,4 +850,69 @@ mod tests {
|
||||
"first playlist must win a tie, got: {feature}"
|
||||
);
|
||||
}
|
||||
|
||||
// Sony SM3 UHD regression: `_Start_Angle` (id 00243, 2s) shares the
|
||||
// feature's audio slots and comes first — `/feature/i` matching plus the
|
||||
// sub-minute guard now reject the decoy the old rule picked.
|
||||
#[test]
|
||||
fn feature_selection_rejects_start_angle_decoy() {
|
||||
let xml = r#"
|
||||
<playlist name="_Start_Angle" id="00243" aud="eng,fra,spa" duration="2" />
|
||||
<playlist name="_Feature" id="00800" aud="eng,fra,spa" duration="7000" />
|
||||
<playlist name="Feature_A" id="00801" aud="eng,fra,spa" duration="7000" />
|
||||
"#;
|
||||
let feature = find_feature_playlist(xml).expect("a feature is found");
|
||||
assert!(
|
||||
!feature.contains("_Start_Angle"),
|
||||
"the 2-second angle decoy must never be the feature: {feature}"
|
||||
);
|
||||
// Tie between the two /feature/i playlists → first wins (_Feature, 00800).
|
||||
let h = feature_hint_from_xml(xml).expect("hint");
|
||||
assert_eq!(h.playlist_id, Some(800));
|
||||
assert_eq!(h.filename.as_deref(), Some("00800.mpls"));
|
||||
}
|
||||
|
||||
// Among /feature/i playlists, the longest duration wins — not document order.
|
||||
#[test]
|
||||
fn feature_like_longest_duration_wins() {
|
||||
let xml = r#"
|
||||
<playlist name="_Feature" id="00800" aud="eng,fra" duration="6000" />
|
||||
<playlist name="Feature_B" id="00802" aud="eng,fra" duration="8000" />
|
||||
"#;
|
||||
let h = feature_hint_from_xml(xml).expect("hint");
|
||||
assert_eq!(
|
||||
h.playlist_id,
|
||||
Some(802),
|
||||
"the longer /feature/i playlist wins"
|
||||
);
|
||||
}
|
||||
|
||||
// The sub-minute guard also applies when the ONLY /feature/i playlist is a
|
||||
// decoy: it is rejected and selection falls through to the real feature.
|
||||
#[test]
|
||||
fn sub_minute_feature_name_is_rejected() {
|
||||
let xml = r#"
|
||||
<playlist name="Feature_Trailer" id="00050" aud="eng,fra,spa" duration="30" />
|
||||
<playlist name="MainMovie" id="00800" aud="eng,fra,spa" duration="7000" />
|
||||
"#;
|
||||
let feature = find_feature_playlist(xml).expect("a feature is found");
|
||||
// Feature_Trailer is /feature/i but sub-minute → rejected; tier 3 picks
|
||||
// MainMovie (equal audio, far longer duration).
|
||||
assert!(feature.contains(r#"id="00800""#), "got {feature}");
|
||||
}
|
||||
|
||||
// Duration attribute reading: several key spellings, digits only, zero → None.
|
||||
#[test]
|
||||
fn playlist_duration_reads_known_attrs() {
|
||||
assert_eq!(
|
||||
playlist_duration_secs(r#"<playlist duration="7000" />"#),
|
||||
Some(7000)
|
||||
);
|
||||
assert_eq!(
|
||||
playlist_duration_secs(r#"<playlist durs="7628" />"#),
|
||||
Some(7628)
|
||||
);
|
||||
assert_eq!(playlist_duration_secs(r#"<playlist dur="0" />"#), None);
|
||||
assert_eq!(playlist_duration_secs(r#"<playlist name="x" />"#), None);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user