WO-2 (delete SectorReader trait): - The 0.18 trait split into SectorSource (read-only) and SectorSink (write-only) is final; the legacy SectorReader alias was a bridge. - Renames every internal &mut dyn SectorReader (~25 sites) to &mut dyn SectorSource. The trait method capacity() becomes capacity_sectors() with a default of 0 (preserves SectorReader's default-0 behavior). - Deletes the SectorReader trait, its blanket-to-Source bridge, and the FileSectorReader type alias. Adds explicit forwarding impls for Box<dyn SectorSource> and &mut dyn SectorSource so generic decorators like DecryptingSectorSource<S: SectorSource> compose. WO-3a (extract Disc::patch): - Moves Disc::patch (1230 lines) and bytes_bad_in_title from disc/mod.rs into disc/patch.rs as a split inherent impl. Zero behavior change — pure mechanical relocation. disc/mod.rs drops from 3,945 to 2,714 LOC. WO-6 (partial): - Deletes src/labels/png_filenames.rs — was a 72-LOC stub with detect() returning false, never wired into the PARSERS registry. project docs doc drift fixes (audited 2026-05-13): - JUMP_BASE_SECTORS: 256→1024 (64 MB base for UHD, not 8 MB) - PASSN_DAMAGE_THRESHOLD_PCT: 12→6 - PASSN_SKIP_SECTORS_BASE: 64→32 - MAX_RANGE_SECS=180: replaced by proportional range_sectors × 25, capped at RANGE_BUDGET_CAP_SECS=1800.
147 lines
4.7 KiB
Rust
147 lines
4.7 KiB
Rust
//! Criterion Collection — `streamproperties.xml` + `playbackconfig.xml`
|
|
//!
|
|
//! Clean structured XML with Content/Qualifier per stream and
|
|
//! stream number mapping via playbackconfig.
|
|
|
|
use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, xml};
|
|
use crate::sector::SectorSource;
|
|
use crate::udf::UdfFs;
|
|
use std::collections::HashMap;
|
|
|
|
pub fn detect(udf: &UdfFs) -> bool {
|
|
super::jar_file_exists(udf, "streamproperties.xml")
|
|
}
|
|
|
|
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
|
|
let sp_data = super::read_jar_file(reader, udf, "streamproperties.xml")?;
|
|
let sp_text = std::str::from_utf8(&sp_data).ok()?;
|
|
|
|
let stream_infos = parse_stream_infos(sp_text);
|
|
if stream_infos.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
// Stream number mapping from playbackconfig.xml
|
|
let mut stream_map: HashMap<String, u16> = HashMap::new();
|
|
if let Some(pc_data) = super::read_jar_file(reader, udf, "playbackconfig.xml") {
|
|
if let Ok(pc_text) = std::str::from_utf8(&pc_data) {
|
|
parse_playback_config(pc_text, &mut stream_map);
|
|
}
|
|
}
|
|
|
|
let mut labels = Vec::new();
|
|
let mut audio_idx: u16 = 1;
|
|
let mut sub_idx: u16 = 1;
|
|
|
|
for info in &stream_infos {
|
|
let stream_num =
|
|
stream_map
|
|
.get(&info.id)
|
|
.copied()
|
|
.unwrap_or_else(|| match info.stream_type {
|
|
StreamLabelType::Audio => {
|
|
let n = audio_idx;
|
|
audio_idx += 1;
|
|
n
|
|
}
|
|
StreamLabelType::Subtitle => {
|
|
let n = sub_idx;
|
|
sub_idx += 1;
|
|
n
|
|
}
|
|
});
|
|
|
|
labels.push(StreamLabel {
|
|
stream_number: stream_num,
|
|
stream_type: info.stream_type,
|
|
language: info.language.clone(),
|
|
name: String::new(),
|
|
purpose: info.purpose,
|
|
qualifier: info.qualifier,
|
|
codec_hint: String::new(),
|
|
variant: info.variant.clone(),
|
|
});
|
|
}
|
|
|
|
if labels.is_empty() {
|
|
return None;
|
|
}
|
|
// High confidence: streamproperties.xml is fully structured.
|
|
Some(ParseResult::high(labels))
|
|
}
|
|
|
|
struct StreamInfo {
|
|
id: String,
|
|
stream_type: StreamLabelType,
|
|
language: String,
|
|
variant: String,
|
|
purpose: LabelPurpose,
|
|
qualifier: LabelQualifier,
|
|
}
|
|
|
|
fn parse_stream_infos(text: &str) -> Vec<StreamInfo> {
|
|
let mut infos = Vec::new();
|
|
|
|
for (tag_name, stream_type) in [
|
|
("AudioStreamInfos", StreamLabelType::Audio),
|
|
("SubtitleStreamInfos", StreamLabelType::Subtitle),
|
|
] {
|
|
let mut from = 0;
|
|
while let Some((start, end)) = xml::find_element(text, tag_name, from) {
|
|
let block = &text[start..end];
|
|
let id = xml::text(block, "ID").unwrap_or_default();
|
|
let lang_id = xml::text(block, "LangInfoID").unwrap_or_default();
|
|
let content = xml::text(block, "Content").unwrap_or_default();
|
|
let qualifier_str = xml::text(block, "Qualifier").unwrap_or_default();
|
|
|
|
let (language, variant) = if lang_id.contains('_') {
|
|
let parts: Vec<&str> = lang_id.splitn(2, '_').collect();
|
|
(parts[0].to_lowercase(), parts[1].to_string())
|
|
} else {
|
|
(lang_id.to_lowercase(), String::new())
|
|
};
|
|
|
|
let purpose = if content.eq_ignore_ascii_case("COMMENTARY") {
|
|
LabelPurpose::Commentary
|
|
} else {
|
|
LabelPurpose::Normal
|
|
};
|
|
|
|
let qualifier = match qualifier_str.to_ascii_uppercase().as_str() {
|
|
"SDH" => LabelQualifier::Sdh,
|
|
"DS" => LabelQualifier::DescriptiveService,
|
|
_ => LabelQualifier::None,
|
|
};
|
|
|
|
infos.push(StreamInfo {
|
|
id,
|
|
stream_type,
|
|
language,
|
|
variant,
|
|
purpose,
|
|
qualifier,
|
|
});
|
|
from = end;
|
|
}
|
|
}
|
|
infos
|
|
}
|
|
|
|
fn parse_playback_config(text: &str, map: &mut HashMap<String, u16>) {
|
|
for tag_name in ["AudioStreams", "SubtitlesStreams"] {
|
|
let mut from = 0;
|
|
while let Some((start, end)) = xml::find_element(text, tag_name, from) {
|
|
let block = &text[start..end];
|
|
if let (Some(stream_id_str), Some(info_id)) = (
|
|
xml::text(block, "StreamID"),
|
|
xml::text(block, "StreamInfo_ID"),
|
|
) {
|
|
if let Ok(stream_num) = stream_id_str.parse::<u16>() {
|
|
map.insert(info_id, stream_num);
|
|
}
|
|
}
|
|
from = end;
|
|
}
|
|
}
|
|
}
|