use anyhow::{Context, Result};
use std::fs::File;
use std::io::Write;
use std::path::Path;
pub fn is_disc_image(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.map(|e| matches!(e.to_lowercase().as_str(), "iso" | "img"))
.unwrap_or(false)
}
#[derive(Debug, Clone, PartialEq)]
pub enum DiscType {
Bluray,
BlurayFlat,
Dvd,
Avchd,
BareMedia,
Encrypted { reason: String },
}
#[derive(Debug, Clone)]
pub struct IsoMediaFile {
pub path: String,
pub size: u64,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct DiscAnalysis {
pub disc_type: DiscType,
pub main_feature: Vec<IsoMediaFile>,
pub main_feature_size: u64,
pub extras: Vec<IsoMediaFile>,
pub multi_title_groups: Option<Vec<Vec<IsoMediaFile>>>,
}
pub fn analyze_disc(iso_path: &Path) -> Result<DiscAnalysis> {
let mut file =
File::open(iso_path).with_context(|| format!("Failed to open {:?}", iso_path))?;
let filename = iso_path.file_name().unwrap_or_default().to_string_lossy();
let root = isomage::detect_and_parse_filesystem(&mut file, &filename)
.map_err(|e| anyhow::anyhow!("{}", e))?;
if let Some(reason) = detect_encryption(&root) {
return Ok(DiscAnalysis {
disc_type: DiscType::Encrypted { reason },
main_feature: Vec::new(),
main_feature_size: 0,
extras: Vec::new(),
multi_title_groups: None,
});
}
let disc_type = detect_disc_type(&root);
match disc_type {
DiscType::Bluray => analyze_bluray(&root, "BDMV/STREAM"),
DiscType::BlurayFlat => analyze_bluray(&root, "STREAM"),
DiscType::Dvd => analyze_dvd(&root),
DiscType::Avchd => analyze_avchd(&root),
DiscType::BareMedia => analyze_bare_media(&root),
DiscType::Encrypted { .. } => unreachable!("handled above"),
}
}
fn detect_encryption(root: &isomage::TreeNode) -> Option<String> {
if find_dir(root, "AACS").is_some() {
return Some("AACS-encrypted Blu-ray detected (AACS/ directory present); hvac cannot decrypt commercial discs. Use makemkv to rip first.".to_string());
}
if find_dir(root, "BDSVM").is_some() {
return Some("BD+-protected Blu-ray detected (BDSVM/ directory present); hvac cannot decrypt commercial discs. Use makemkv to rip first.".to_string());
}
None
}
fn detect_disc_type(root: &isomage::TreeNode) -> DiscType {
if find_dir(root, "BDMV/STREAM").is_some() || find_dir(root, "bdmv/stream").is_some() {
return DiscType::Bluray;
}
if find_dir(root, "VIDEO_TS").is_some() || find_dir(root, "video_ts").is_some() {
return DiscType::Dvd;
}
if find_dir(root, "PRIVATE/AVCHD/BDMV/STREAM").is_some()
|| find_dir(root, "AVCHD/BDMV/STREAM").is_some()
{
return DiscType::Avchd;
}
if let Some(stream) = find_dir(root, "STREAM") {
let has_m2ts = stream.children.iter().any(|c| {
let ext = ext_lower(&c.name);
ext == "m2ts" || ext == "m2t" || ext == "mts"
});
if has_m2ts {
return DiscType::BlurayFlat;
}
}
DiscType::BareMedia
}
fn find_dir<'a>(root: &'a isomage::TreeNode, path: &str) -> Option<&'a isomage::TreeNode> {
let parts: Vec<&str> = path.split('/').collect();
let mut current = root;
for part in &parts {
let lower = part.to_lowercase();
let found = current
.children
.iter()
.find(|c| c.is_directory && c.name.to_lowercase() == lower);
match found {
Some(child) => current = child,
None => return None,
}
}
Some(current)
}
fn ext_lower(name: &str) -> String {
Path::new(name)
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.unwrap_or_default()
}
fn analyze_bluray(root: &isomage::TreeNode, stream_path: &str) -> Result<DiscAnalysis> {
let stream_dir = find_dir(root, stream_path)
.ok_or_else(|| anyhow::anyhow!("STREAM directory not found at {}", stream_path))?;
let mut media: Vec<IsoMediaFile> = stream_dir
.children
.iter()
.filter(|c| {
!c.is_directory && {
let ext = ext_lower(&c.name);
ext == "m2ts" || ext == "m2t" || ext == "mts"
}
})
.map(|c| IsoMediaFile {
path: format!("{}/{}", stream_path, c.name),
size: c.size,
})
.collect();
if media.is_empty() {
return Ok(DiscAnalysis {
disc_type: if stream_path.starts_with("BDMV") {
DiscType::Bluray
} else {
DiscType::BlurayFlat
},
main_feature: Vec::new(),
main_feature_size: 0,
extras: Vec::new(),
multi_title_groups: None,
});
}
media.sort_by(|a, b| a.path.cmp(&b.path));
let max_size = media.iter().map(|f| f.size).max().unwrap_or(0);
let threshold = max_size / 4;
let (main_feature, extras): (Vec<_>, Vec<_>) =
media.into_iter().partition(|f| f.size >= threshold);
let main_feature_size = main_feature.iter().map(|f| f.size).sum();
Ok(DiscAnalysis {
disc_type: if stream_path.starts_with("BDMV") {
DiscType::Bluray
} else {
DiscType::BlurayFlat
},
main_feature,
main_feature_size,
extras,
multi_title_groups: None,
})
}
fn analyze_dvd(root: &isomage::TreeNode) -> Result<DiscAnalysis> {
let video_ts = find_dir(root, "VIDEO_TS")
.or_else(|| find_dir(root, "video_ts"))
.ok_or_else(|| anyhow::anyhow!("VIDEO_TS directory not found"))?;
let mut title_sets: std::collections::BTreeMap<String, Vec<IsoMediaFile>> =
std::collections::BTreeMap::new();
let mut menu_files: Vec<IsoMediaFile> = Vec::new();
let video_ts_path = if video_ts.children.iter().any(|c| c.name.contains("VTS_")) {
"VIDEO_TS"
} else {
"video_ts"
};
for child in &video_ts.children {
if child.is_directory {
continue;
}
let ext = ext_lower(&child.name);
if ext != "vob" {
continue;
}
let upper = child.name.to_uppercase();
if upper == "VIDEO_TS.VOB" {
menu_files.push(IsoMediaFile {
path: format!("{}/{}", video_ts_path, child.name),
size: child.size,
});
continue;
}
if let Some(title_num) = parse_vts_title(&upper) {
title_sets.entry(title_num).or_default().push(IsoMediaFile {
path: format!("{}/{}", video_ts_path, child.name),
size: child.size,
});
}
}
for files in title_sets.values_mut() {
files.sort_by(|a, b| a.path.cmp(&b.path));
}
if title_sets.is_empty() {
return Ok(DiscAnalysis {
disc_type: DiscType::Dvd,
main_feature: Vec::new(),
main_feature_size: 0,
extras: menu_files,
multi_title_groups: None,
});
}
let mut set_sizes: Vec<(String, u64)> = title_sets
.iter()
.map(|(ts, files)| (ts.clone(), files.iter().map(|f| f.size).sum()))
.collect();
set_sizes.sort_by_key(|a| std::cmp::Reverse(a.1));
let largest_size = set_sizes[0].1;
let similarity_threshold = largest_size * 80 / 100;
let main_sets: std::collections::HashSet<String> = set_sizes
.iter()
.filter(|(_, size)| *size >= similarity_threshold)
.map(|(ts, _)| ts.clone())
.collect();
let mut main_feature: Vec<IsoMediaFile> = Vec::new();
let mut extras: Vec<IsoMediaFile> = Vec::new();
let mut main_groups: Vec<Vec<IsoMediaFile>> = Vec::new();
for (ts, files) in title_sets {
if main_sets.contains(&ts) {
main_groups.push(files.clone());
main_feature.extend(files);
} else {
extras.extend(files);
}
}
extras.extend(menu_files);
main_feature.sort_by(|a, b| a.path.cmp(&b.path));
let main_feature_size = main_feature.iter().map(|f| f.size).sum();
let multi_title_groups = if main_groups.len() >= 2 {
Some(main_groups)
} else {
None
};
Ok(DiscAnalysis {
disc_type: DiscType::Dvd,
main_feature,
main_feature_size,
extras,
multi_title_groups,
})
}
fn parse_vts_title(upper_name: &str) -> Option<String> {
if !upper_name.starts_with("VTS_") || !upper_name.ends_with(".VOB") {
return None;
}
let inner = &upper_name[4..upper_name.len() - 4]; let parts: Vec<&str> = inner.split('_').collect();
if parts.len() != 2 {
return None;
}
if parts[1] == "0" {
return None;
}
Some(parts[0].to_string())
}
fn analyze_avchd(root: &isomage::TreeNode) -> Result<DiscAnalysis> {
let stream_path;
let stream_dir = if let Some(d) = find_dir(root, "PRIVATE/AVCHD/BDMV/STREAM") {
stream_path = "PRIVATE/AVCHD/BDMV/STREAM";
d
} else if let Some(d) = find_dir(root, "AVCHD/BDMV/STREAM") {
stream_path = "AVCHD/BDMV/STREAM";
d
} else {
return Err(anyhow::anyhow!("AVCHD STREAM directory not found"));
};
let mut media: Vec<IsoMediaFile> = stream_dir
.children
.iter()
.filter(|c| {
!c.is_directory && {
let ext = ext_lower(&c.name);
ext == "mts" || ext == "m2ts" || ext == "m2t"
}
})
.map(|c| IsoMediaFile {
path: format!("{}/{}", stream_path, c.name),
size: c.size,
})
.collect();
media.sort_by(|a, b| a.path.cmp(&b.path));
let main_feature_size = media.iter().map(|f| f.size).sum();
Ok(DiscAnalysis {
disc_type: DiscType::Avchd,
main_feature: media,
main_feature_size,
extras: Vec::new(),
multi_title_groups: None,
})
}
fn analyze_bare_media(root: &isomage::TreeNode) -> Result<DiscAnalysis> {
let media_exts = [
"mkv", "mp4", "avi", "m2ts", "m2t", "mts", "ts", "mpg", "mpeg", "wmv", "mov", "vob", "m4v",
"webm", "flv",
];
let mut all_media: Vec<IsoMediaFile> = Vec::new();
collect_all_media(root, "", &media_exts, &mut all_media);
if all_media.is_empty() {
return Ok(DiscAnalysis {
disc_type: DiscType::BareMedia,
main_feature: Vec::new(),
main_feature_size: 0,
extras: Vec::new(),
multi_title_groups: None,
});
}
all_media.sort_by_key(|a| std::cmp::Reverse(a.size));
let max_size = all_media[0].size;
let threshold = max_size / 4;
let (mut main_feature, extras): (Vec<_>, Vec<_>) =
all_media.into_iter().partition(|f| f.size >= threshold);
main_feature.sort_by(|a, b| a.path.cmp(&b.path));
let main_feature_size = main_feature.iter().map(|f| f.size).sum();
Ok(DiscAnalysis {
disc_type: DiscType::BareMedia,
main_feature,
main_feature_size,
extras,
multi_title_groups: None,
})
}
fn collect_all_media(
node: &isomage::TreeNode,
prefix: &str,
exts: &[&str],
results: &mut Vec<IsoMediaFile>,
) {
for child in &node.children {
let path = if prefix.is_empty() {
child.name.clone()
} else {
format!("{}/{}", prefix, child.name)
};
if child.is_directory {
collect_all_media(child, &path, exts, results);
} else if exts.contains(&ext_lower(&child.name).as_str()) {
results.push(IsoMediaFile {
path,
size: child.size,
});
}
}
}
#[cfg(test)]
pub fn list_media_files(iso_path: &Path, media_extensions: &[String]) -> Result<Vec<String>> {
let mut file =
File::open(iso_path).with_context(|| format!("Failed to open {:?}", iso_path))?;
let filename = iso_path.file_name().unwrap_or_default().to_string_lossy();
let root = isomage::detect_and_parse_filesystem(&mut file, &filename)
.map_err(|e| anyhow::anyhow!("{}", e))?;
let ext_lower: Vec<String> = media_extensions.iter().map(|e| e.to_lowercase()).collect();
let mut results = Vec::new();
collect_media_files(&root, "", &ext_lower, &mut results);
Ok(results)
}
#[cfg(test)]
fn collect_media_files(
node: &isomage::TreeNode,
prefix: &str,
exts: &[String],
results: &mut Vec<String>,
) {
for child in &node.children {
let path = if prefix.is_empty() {
child.name.clone()
} else {
format!("{}/{}", prefix, child.name)
};
if child.is_directory {
collect_media_files(child, &path, exts, results);
} else if has_media_extension(&child.name, exts) {
results.push(path);
}
}
}
#[cfg(test)]
fn has_media_extension(name: &str, exts: &[String]) -> bool {
Path::new(name)
.extension()
.and_then(|e| e.to_str())
.map(|e| exts.contains(&e.to_lowercase()))
.unwrap_or(false)
}
pub fn cat_file<W: Write>(iso_path: &Path, inner_path: &str, writer: &mut W) -> Result<()> {
let mut file =
File::open(iso_path).with_context(|| format!("Failed to open {:?}", iso_path))?;
let filename = iso_path.file_name().unwrap_or_default().to_string_lossy();
let root = isomage::detect_and_parse_filesystem(&mut file, &filename)
.map_err(|e| anyhow::anyhow!("{}", e))?;
let node = root
.find_node(inner_path)
.ok_or_else(|| anyhow::anyhow!("File not found in ISO: {}", inner_path))?;
isomage::cat_node(&mut file, node, writer).map_err(|e| anyhow::anyhow!("{}", e))
}
pub fn cat_files<W: Write>(iso_path: &Path, inner_paths: &[String], writer: &mut W) -> Result<()> {
let mut file =
File::open(iso_path).with_context(|| format!("Failed to open {:?}", iso_path))?;
let filename = iso_path.file_name().unwrap_or_default().to_string_lossy();
let root = isomage::detect_and_parse_filesystem(&mut file, &filename)
.map_err(|e| anyhow::anyhow!("{}", e))?;
for inner_path in inner_paths {
let node = root
.find_node(inner_path)
.ok_or_else(|| anyhow::anyhow!("File not found in ISO: {}", inner_path))?;
isomage::cat_node(&mut file, node, writer).map_err(|e| anyhow::anyhow!("{}", e))?;
}
Ok(())
}
pub fn file_size(iso_path: &Path, inner_path: &str) -> Result<u64> {
let mut file =
File::open(iso_path).with_context(|| format!("Failed to open {:?}", iso_path))?;
let filename = iso_path.file_name().unwrap_or_default().to_string_lossy();
let root = isomage::detect_and_parse_filesystem(&mut file, &filename)
.map_err(|e| anyhow::anyhow!("{}", e))?;
let node = root
.find_node(inner_path)
.ok_or_else(|| anyhow::anyhow!("File not found in ISO: {}", inner_path))?;
Ok(node.size)
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture(name: &str) -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(name)
}
fn require_fixture(name: &str) -> Option<std::path::PathBuf> {
let p = fixture(name);
if p.exists() {
Some(p)
} else {
eprintln!("Skipping: {} not found", name);
None
}
}
#[test]
fn test_is_disc_image() {
assert!(is_disc_image(Path::new("/media/movie.iso")));
assert!(is_disc_image(Path::new("/media/movie.ISO")));
assert!(is_disc_image(Path::new("/media/movie.img")));
assert!(is_disc_image(Path::new("/media/movie.IMG")));
assert!(!is_disc_image(Path::new("/media/movie.mkv")));
assert!(!is_disc_image(Path::new("/media/movie.mp4")));
}
#[test]
fn test_detect_bluray_disc() {
let iso = require_fixture("BDMV_DISC.iso");
if let Some(p) = iso {
let analysis = analyze_disc(&p).unwrap();
assert_eq!(analysis.disc_type, DiscType::Bluray);
}
}
#[test]
fn test_detect_bluray_flat_disc() {
let iso = require_fixture("BDMV_FLAT.iso");
if let Some(p) = iso {
let analysis = analyze_disc(&p).unwrap();
assert_eq!(analysis.disc_type, DiscType::BlurayFlat);
}
}
#[test]
fn test_detect_dvd_disc() {
let iso = require_fixture("DVD_DISC.iso");
if let Some(p) = iso {
let analysis = analyze_disc(&p).unwrap();
assert_eq!(analysis.disc_type, DiscType::Dvd);
}
}
#[test]
fn test_detect_avchd_disc() {
let iso = require_fixture("AVCHD_DISC.iso");
if let Some(p) = iso {
let analysis = analyze_disc(&p).unwrap();
assert_eq!(analysis.disc_type, DiscType::Avchd);
}
}
#[test]
fn test_detect_bare_media_disc() {
let iso = require_fixture("BARE_MEDIA.iso");
if let Some(p) = iso {
let analysis = analyze_disc(&p).unwrap();
assert_eq!(analysis.disc_type, DiscType::BareMedia);
}
}
#[test]
fn test_bluray_main_feature_includes_chapters() {
let iso = require_fixture("BDMV_DISC.iso");
if let Some(p) = iso {
let a = analyze_disc(&p).unwrap();
assert!(
a.main_feature.len() >= 3,
"Expected at least 3 chapter files in main feature, got {}: {:?}",
a.main_feature.len(),
a.main_feature.iter().map(|f| &f.path).collect::<Vec<_>>()
);
for w in a.main_feature.windows(2) {
assert!(
w[0].path < w[1].path,
"Files should be sorted: {} < {}",
w[0].path,
w[1].path
);
}
}
}
#[test]
fn test_bluray_excludes_tiny_menu_clips() {
let iso = require_fixture("BDMV_DISC.iso");
if let Some(p) = iso {
let a = analyze_disc(&p).unwrap();
let main_paths: Vec<&str> = a.main_feature.iter().map(|f| f.path.as_str()).collect();
assert!(
!main_paths.iter().any(|p| p.contains("00100")),
"Tiny menu clip should not be in main feature: {:?}",
main_paths
);
}
}
#[test]
fn test_bluray_main_feature_larger_than_extras() {
let iso = require_fixture("BDMV_DISC.iso");
if let Some(p) = iso {
let a = analyze_disc(&p).unwrap();
let extras_size: u64 = a.extras.iter().map(|f| f.size).sum();
assert!(
a.main_feature_size > extras_size,
"Main feature ({}) should be larger than extras ({})",
a.main_feature_size,
extras_size
);
}
}
#[test]
fn test_bluray_flat_finds_chapters() {
let iso = require_fixture("BDMV_FLAT.iso");
if let Some(p) = iso {
let a = analyze_disc(&p).unwrap();
assert!(
a.main_feature.len() >= 2,
"Expected at least 2 chapters, got {}: {:?}",
a.main_feature.len(),
a.main_feature.iter().map(|f| &f.path).collect::<Vec<_>>()
);
assert!(!a.extras.is_empty(), "Should have extras");
}
}
#[test]
fn test_dvd_main_feature_is_largest_title_set() {
let iso = require_fixture("DVD_DISC.iso");
if let Some(p) = iso {
let a = analyze_disc(&p).unwrap();
assert!(
a.main_feature.len() == 3,
"Expected 3 VOBs in main feature (VTS_01), got {}: {:?}",
a.main_feature.len(),
a.main_feature.iter().map(|f| &f.path).collect::<Vec<_>>()
);
for f in &a.main_feature {
assert!(
f.path.contains("VTS_01"),
"Main feature should be VTS_01, got: {}",
f.path
);
}
assert!(
a.extras.iter().any(|f| f.path.contains("VTS_02")),
"VTS_02 should be in extras"
);
}
}
#[test]
fn test_dvd_excludes_menu_vob() {
let iso = require_fixture("DVD_DISC.iso");
if let Some(p) = iso {
let a = analyze_disc(&p).unwrap();
let main_paths: Vec<&str> = a.main_feature.iter().map(|f| f.path.as_str()).collect();
assert!(
!main_paths.iter().any(|p| p.contains("VIDEO_TS.VOB")),
"Menu VOB should not be in main feature"
);
}
}
#[test]
fn test_dvd_vob_order_is_sequential() {
let iso = require_fixture("DVD_DISC.iso");
if let Some(p) = iso {
let a = analyze_disc(&p).unwrap();
for w in a.main_feature.windows(2) {
assert!(
w[0].path < w[1].path,
"VOBs should be sequential: {} < {}",
w[0].path,
w[1].path
);
}
}
}
#[test]
fn test_multi_title_dvd_includes_all_similar_sets() {
let iso = require_fixture("MULTI_TITLE_DVD.iso");
if let Some(p) = iso {
let a = analyze_disc(&p).unwrap();
assert!(
a.main_feature.len() == 4,
"Expected 4 VOBs (all similar-size title sets), got {}: {:?}",
a.main_feature.len(),
a.main_feature.iter().map(|f| &f.path).collect::<Vec<_>>()
);
}
}
#[test]
fn test_multi_title_dvd_emits_per_title_groups() {
let iso = require_fixture("MULTI_TITLE_DVD.iso");
if let Some(p) = iso {
let a = analyze_disc(&p).unwrap();
let groups = a
.multi_title_groups
.as_ref()
.expect("multi-title DVD should populate multi_title_groups");
assert_eq!(
groups.len(),
4,
"Expected 4 title groups (one per VTS_NN), got {}: {:?}",
groups.len(),
groups
.iter()
.map(|g| g.iter().map(|f| &f.path).collect::<Vec<_>>())
.collect::<Vec<_>>()
);
for group in groups {
assert!(!group.is_empty(), "title group must be non-empty");
for w in group.windows(2) {
assert!(
w[0].path < w[1].path,
"Files within a title group should be sorted: {} < {}",
w[0].path,
w[1].path
);
}
let first_upper = group[0].path.to_uppercase();
let title_prefix = first_upper
.split("VTS_")
.nth(1)
.and_then(|s| s.split('_').next())
.map(|s| s.to_string())
.expect("group path should contain VTS_NN_M.VOB");
for f in group {
let upper = f.path.to_uppercase();
let needle = format!("VTS_{}_", title_prefix);
assert!(
upper.contains(&needle),
"All files in a group should share VTS_{} prefix, got {}",
title_prefix,
f.path
);
}
}
assert_eq!(a.main_feature.len(), 4);
}
}
#[test]
fn test_single_feature_dvd_has_no_multi_title_groups() {
let iso = require_fixture("DVD_DISC.iso");
if let Some(p) = iso {
let a = analyze_disc(&p).unwrap();
assert!(
a.multi_title_groups.is_none(),
"Single-feature DVD should not set multi_title_groups, got {:?}",
a.multi_title_groups
);
}
}
#[test]
fn test_avchd_includes_all_clips() {
let iso = require_fixture("AVCHD_DISC.iso");
if let Some(p) = iso {
let a = analyze_disc(&p).unwrap();
assert!(
a.main_feature.len() == 3,
"Expected 3 AVCHD clips, got {}: {:?}",
a.main_feature.len(),
a.main_feature.iter().map(|f| &f.path).collect::<Vec<_>>()
);
assert!(a.extras.is_empty(), "AVCHD should have no extras");
}
}
#[test]
fn test_bare_media_largest_is_main() {
let iso = require_fixture("BARE_MEDIA.iso");
if let Some(p) = iso {
let a = analyze_disc(&p).unwrap();
assert!(!a.main_feature.is_empty(), "Should have a main feature");
assert!(
a.main_feature[0].path.contains("movie") || a.main_feature[0].size > 0,
"Largest file should be in main feature"
);
}
}
#[test]
fn test_bare_media_excludes_tiny_files() {
let iso = require_fixture("BARE_MEDIA.iso");
if let Some(p) = iso {
let a = analyze_disc(&p).unwrap();
let main_paths: Vec<&str> = a.main_feature.iter().map(|f| f.path.as_str()).collect();
assert!(
!main_paths.iter().any(|p| p.contains("trailer")),
"Tiny trailer should be in extras, not main: {:?}",
main_paths
);
}
}
#[test]
fn test_cat_files_concatenates_in_order() {
let iso = require_fixture("BDMV_DISC.iso");
if let Some(p) = iso {
let a = analyze_disc(&p).unwrap();
if a.main_feature.len() >= 2 {
let paths: Vec<String> = a.main_feature.iter().map(|f| f.path.clone()).collect();
let mut buf = Vec::new();
cat_files(&p, &paths, &mut buf).expect("cat_files failed");
assert!(buf.len() > 0, "Concatenated output should be non-empty");
let total_size: u64 = a.main_feature.iter().map(|f| f.size).sum();
assert!(
(buf.len() as u64) >= total_size * 95 / 100,
"Output size {} should be close to total size {}",
buf.len(),
total_size
);
}
}
}
#[test]
fn test_list_media_files_in_bdmv_disc() {
if let Some(p) = require_fixture("BDMV_DISC.iso") {
let exts = vec!["m2ts".to_string(), "m2t".to_string(), "mkv".to_string()];
let files = list_media_files(&p, &exts).expect("Failed to list media files");
assert!(!files.is_empty(), "Should find media files in BDMV disc");
assert!(
files.iter().any(|f| f.contains("00000")),
"Should find 00000.M2T(S): {:?}",
files
);
}
}
#[test]
fn test_cat_file_from_bdmv_disc() {
if let Some(p) = require_fixture("BDMV_DISC.iso") {
let exts = vec!["m2ts".to_string(), "m2t".to_string()];
let files = list_media_files(&p, &exts).unwrap();
let inner = &files[0];
let mut buf = Vec::new();
cat_file(&p, inner, &mut buf).expect("cat_file failed");
assert!(buf.len() > 188, "Should have at least one TS packet");
let has_sync = buf.windows(1).any(|w| w[0] == 0x47);
assert!(
has_sync,
"Should contain at least one MPEG-TS sync byte (0x47)"
);
}
}
#[test]
fn test_file_size_from_bdmv_disc() {
if let Some(p) = require_fixture("BDMV_DISC.iso") {
let exts = vec!["m2ts".to_string(), "m2t".to_string()];
let files = list_media_files(&p, &exts).unwrap();
let inner = &files[0];
let size = file_size(&p, inner).expect("file_size failed");
assert!(
size > 1_000_000,
"File should be at least 1MB, got {}",
size
);
}
}
fn bluray_root_with_dir(extra_dir: &str) -> isomage::TreeNode {
let mut root = isomage::TreeNode::new_directory("ROOT".to_string());
let mut bdmv = isomage::TreeNode::new_directory("BDMV".to_string());
let mut stream = isomage::TreeNode::new_directory("STREAM".to_string());
stream.add_child(isomage::TreeNode::new_file(
"00000.m2ts".to_string(),
10_000_000,
));
bdmv.add_child(stream);
root.add_child(bdmv);
let mut protected = isomage::TreeNode::new_directory(extra_dir.to_string());
protected.add_child(isomage::TreeNode::new_file(
"Unit_Key_RO.inf".to_string(),
4096,
));
root.add_child(protected);
root
}
#[test]
fn test_detect_encryption_aacs() {
let root = bluray_root_with_dir("AACS");
let reason = detect_encryption(&root).expect("should detect AACS");
assert!(
reason.contains("AACS"),
"Reason should mention AACS: {}",
reason
);
assert!(
reason.contains("makemkv"),
"Reason should suggest makemkv: {}",
reason
);
}
#[test]
fn test_detect_encryption_aacs_case_insensitive() {
let root = bluray_root_with_dir("aacs");
assert!(
detect_encryption(&root).is_some(),
"Lowercase aacs/ directory should still be detected"
);
}
#[test]
fn test_detect_encryption_bdsvm() {
let root = bluray_root_with_dir("BDSVM");
let reason = detect_encryption(&root).expect("should detect BD+");
assert!(
reason.contains("BD+") || reason.contains("BDSVM"),
"Reason should mention BD+ or BDSVM: {}",
reason
);
}
#[test]
fn test_detect_encryption_clean_disc() {
let mut root = isomage::TreeNode::new_directory("ROOT".to_string());
let mut bdmv = isomage::TreeNode::new_directory("BDMV".to_string());
let mut stream = isomage::TreeNode::new_directory("STREAM".to_string());
stream.add_child(isomage::TreeNode::new_file(
"00000.m2ts".to_string(),
10_000_000,
));
bdmv.add_child(stream);
root.add_child(bdmv);
assert!(detect_encryption(&root).is_none());
}
#[test]
fn test_parse_vts_title() {
assert_eq!(parse_vts_title("VTS_01_1.VOB"), Some("01".to_string()));
assert_eq!(parse_vts_title("VTS_02_3.VOB"), Some("02".to_string()));
assert_eq!(parse_vts_title("VTS_01_0.VOB"), None); assert_eq!(parse_vts_title("VIDEO_TS.VOB"), None);
assert_eq!(parse_vts_title("RANDOM.VOB"), None);
}
}