use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::cli::Cli;
use crate::config::Config;
use crate::gpu::GpuInfo;
use crate::iso;
use crate::probe;
use crate::transcode;
use super::worker::output_stem_for_item;
use super::{ScanItem, WorkItem};
#[cfg(test)]
use super::MIN_TRANSCODE_DURATION_SECS;
pub struct PartitionResult {
pub to_transcode: Vec<WorkItem>,
pub skipped: u32,
pub resumed: u32,
pub errors: u32,
}
pub fn partition(items: &[ScanItem], cli: &Cli, cfg: &Config, gpu: &GpuInfo) -> PartitionResult {
let mut to_transcode = Vec::with_capacity(items.len());
let mut skipped = 0u32;
let mut resumed = 0u32;
let mut errors = 0u32;
let mut writable_cache: HashMap<PathBuf, bool> = HashMap::new();
let probe_timeout = Duration::from_secs(cli.probe_timeout);
let overwrite = cli.overwrite();
for item in items {
let outcome = classify_item(
item,
cli,
cfg,
gpu,
overwrite,
probe_timeout,
&mut writable_cache,
);
match outcome {
Outcome::Transcode(w) => to_transcode.push(*w),
Outcome::Skip(reason) => {
eprintln!(" {}", reason);
skipped += 1;
}
Outcome::SkippedSilently => {
skipped += 1;
}
Outcome::Resumed(msg) => {
if let Some(m) = msg {
eprintln!(" {}", m);
}
resumed += 1;
}
Outcome::ProbeError(msg) => {
eprintln!(" {}", msg);
errors += 1;
}
}
}
PartitionResult {
to_transcode,
skipped,
resumed,
errors,
}
}
enum Outcome {
Transcode(Box<WorkItem>),
Skip(String),
SkippedSilently,
Resumed(Option<String>),
ProbeError(String),
}
fn classify_item(
item: &ScanItem,
cli: &Cli,
cfg: &Config,
gpu: &GpuInfo,
overwrite: bool,
probe_timeout: Duration,
writable_cache: &mut HashMap<PathBuf, bool>,
) -> Outcome {
let probe_result = match (&item.iso_path, &item.inner_path) {
(Some(ip), Some(inner)) => probe::probe_iso_file_with_timeout(ip, inner, probe_timeout),
_ => probe::probe_file_with_timeout(&item.file, probe_timeout),
};
let info = match probe_result {
Ok(i) => i,
Err(e) => {
return Outcome::ProbeError(format!(
"skip: {:?}: {}",
item.file.file_name().unwrap_or_default(),
e
));
}
};
if is_too_short(info.duration_secs, cli.min_duration) {
return Outcome::Skip(format!(
"skip: {}: duration too short ({:.2}s)",
item.file.display(),
info.duration_secs
));
}
if probe::meets_target(&info, &cfg.target) {
return Outcome::SkippedSilently;
}
if probe::is_10bit(&info.pix_fmt) && !gpu.supports_10bit_hevc {
let name = item.file.file_name().unwrap_or_default().to_string_lossy();
return Outcome::Skip(format!(
"skip: {}: source is 10-bit; this GPU's NVENC doesn't support 10-bit HEVC.\n\
\tUse a Turing (RTX 20xx) or newer card, or convert to 8-bit first.",
name
));
}
let source_size = source_size_for(item);
let extra_probes = probe_extra_inner_files(item);
let multi_file_count = item.inner_paths.as_ref().map(|p| p.len()).unwrap_or(0);
let (bitrate_kbps, duration_secs) =
aggregate_iso_probes(&info, &extra_probes, multi_file_count);
if let Some(outcome) = try_resume(item, cli, cfg, overwrite, duration_secs) {
return outcome;
}
if !cli.dry_run {
if let Some(skip) = preflight_writable(item, cli, cfg, overwrite, writable_cache) {
return Outcome::Skip(skip);
}
}
let color = transcode::ColorMetadata::from_media_info(&info);
Outcome::Transcode(Box::new(WorkItem {
path: item.file.clone(),
bitrate_kbps,
duration_secs,
pix_fmt: info.pix_fmt,
source_size,
color,
iso_path: item.iso_path.clone(),
inner_path: item.inner_path.clone(),
inner_paths: item.inner_paths.clone(),
title_suffix: item.title_suffix.clone(),
}))
}
pub fn is_too_short(duration_secs: f64, min_duration_secs: f64) -> bool {
duration_secs > 0.0 && duration_secs < min_duration_secs
}
fn source_size_for(item: &ScanItem) -> u64 {
if let Some(ref ip) = item.iso_path {
if let Some(ref paths) = item.inner_paths {
return paths
.iter()
.filter_map(|p| iso::file_size(ip, p).ok())
.sum();
}
if let Some(ref inner) = item.inner_path {
return iso::file_size(ip, inner).unwrap_or(0);
}
return 0;
}
std::fs::metadata(&item.file).map(|m| m.len()).unwrap_or(0)
}
fn probe_extra_inner_files(item: &ScanItem) -> Vec<Option<probe::MediaInfo>> {
let (Some(ip), Some(paths)) = (&item.iso_path, &item.inner_paths) else {
return Vec::new();
};
if paths.len() <= 1 {
return Vec::new();
}
paths
.iter()
.skip(1)
.map(|inner| match probe::probe_iso_file(ip, inner) {
Ok(info) => Some(info),
Err(e) => {
log::debug!(
" per-file probe failed for {}:{}: {}",
ip.display(),
inner,
e
);
None
}
})
.collect()
}
pub fn aggregate_iso_probes(
representative: &probe::MediaInfo,
extra_probes: &[Option<probe::MediaInfo>],
total_file_count: usize,
) -> (u32, f64) {
if total_file_count <= 1 {
return (representative.bitrate_kbps, representative.duration_secs);
}
let mut max_bitrate = representative.bitrate_kbps;
let mut sum_duration = representative.duration_secs;
let mut all_succeeded = true;
for p in extra_probes {
match p {
Some(info) => {
if info.bitrate_kbps > max_bitrate {
max_bitrate = info.bitrate_kbps;
}
sum_duration += info.duration_secs;
}
None => all_succeeded = false,
}
}
let duration_secs = if all_succeeded {
sum_duration
} else {
representative.duration_secs * total_file_count as f64
};
(max_bitrate, duration_secs)
}
fn try_resume(
item: &ScanItem,
cli: &Cli,
cfg: &Config,
overwrite: bool,
duration_secs: f64,
) -> Option<Outcome> {
let out_dir = cli.output_dir.as_deref().or(cfg.output_dir.as_deref());
let source_for_output = output_stem_for_item(&item.file, item.title_suffix.as_deref());
let out_path =
transcode::output_path(&source_for_output, out_dir, &cfg.target.container).ok()?;
if !transcode::output_already_valid(&out_path, &item.file, duration_secs) {
return None;
}
if !transcode::marker_valid_for_source(&out_path, &item.file) {
eprintln!(
" Ignoring incomplete previous output for {:?} (no/stale {} marker) — re-encoding",
item.file.file_name().unwrap_or_default(),
transcode::MARKER_SUFFIX
);
let _ = std::fs::remove_file(&out_path);
transcode::remove_marker(&out_path);
return None;
}
let is_disc = item.iso_path.is_some();
if overwrite && !is_disc {
match transcode::replace_original(&item.file, &out_path, duration_secs) {
Ok(_saved) => Some(Outcome::Resumed(Some(format!(
"Replaced {:?} with existing transcoded copy",
item.file.file_name().unwrap_or_default()
)))),
Err(e) => {
eprintln!(
" Failed to replace {:?}: {}",
item.file.file_name().unwrap_or_default(),
e
);
None
}
}
} else {
Some(Outcome::Resumed(None))
}
}
fn preflight_writable(
item: &ScanItem,
cli: &Cli,
cfg: &Config,
overwrite: bool,
cache: &mut HashMap<PathBuf, bool>,
) -> Option<String> {
let out_dir = cli.output_dir.as_deref().or(cfg.output_dir.as_deref());
let dest_dir: PathBuf = if overwrite && item.iso_path.is_none() {
item.file
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."))
} else if let Some(d) = out_dir {
if !d.exists() {
if let Err(e) = std::fs::create_dir_all(d) {
let name = item.file.file_name().unwrap_or_default().to_string_lossy();
return Some(format!(
"skip: {}: cannot create output directory {:?}: {}",
name, d, e
));
}
}
d.to_path_buf()
} else {
item.file
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."))
};
if dir_is_writable_cached(cache, &dest_dir) {
return None;
}
let name = item.file.file_name().unwrap_or_default().to_string_lossy();
if overwrite && item.iso_path.is_none() {
Some(format!(
"skip: {}: source directory is not writable; \
use --no-overwrite to write transcodes elsewhere",
name
))
} else {
Some(format!(
"skip: {}: output directory {:?} is not writable",
name, dest_dir
))
}
}
fn dir_is_writable_cached(cache: &mut HashMap<PathBuf, bool>, dir: &Path) -> bool {
if let Some(&v) = cache.get(dir) {
return v;
}
let v = dir_is_writable(dir);
cache.insert(dir.to_path_buf(), v);
v
}
pub fn dir_is_writable(dir: &Path) -> bool {
use std::fs::OpenOptions;
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let probe = dir.join(format!(
".hvac_writable_check_{}_{}",
std::process::id(),
nanos
));
if OpenOptions::new()
.write(true)
.create_new(true)
.open(&probe)
.is_err()
{
return false;
}
match std::fs::remove_file(&probe) {
Ok(()) => true,
Err(e) => {
log::warn!(
"dir_is_writable: probe at {:?} created but not removed: {} \
— treating directory as non-writable",
probe,
e
);
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn mk_info(bitrate_kbps: u32, duration_secs: f64) -> probe::MediaInfo {
probe::MediaInfo {
codec: "mpeg2video".to_string(),
width: 720,
height: 480,
bitrate_kbps,
duration_secs,
pix_fmt: "yuv420p".to_string(),
has_audio: true,
has_subtitles: false,
..probe::MediaInfo::default()
}
}
#[test]
fn is_too_short_below_threshold() {
assert!(is_too_short(0.04, 1.0));
assert!(is_too_short(0.999, 1.0));
}
#[test]
fn is_too_short_at_threshold_returns_false() {
assert!(!is_too_short(1.0, 1.0));
assert!(!is_too_short(1.5, 1.0));
}
#[test]
fn is_too_short_zero_falls_through() {
assert!(!is_too_short(0.0, 1.0));
assert!(!is_too_short(-1.0, 1.0));
}
#[test]
fn is_too_short_with_min_zero_disables_skip() {
assert!(!is_too_short(0.04, 0.0));
assert!(!is_too_short(1.0, 0.0));
}
#[test]
fn min_transcode_duration_default_is_one_second() {
assert_eq!(MIN_TRANSCODE_DURATION_SECS, 1.0);
}
#[test]
fn aggregate_iso_probes_single_file_returns_representative() {
let rep = mk_info(4000, 1500.0);
let (b, d) = aggregate_iso_probes(&rep, &[], 1);
assert_eq!(b, 4000);
assert!((d - 1500.0).abs() < 1e-9);
}
#[test]
fn aggregate_iso_probes_takes_max_bitrate() {
let rep = mk_info(2000, 500.0);
let extras = vec![
Some(mk_info(8000, 500.0)),
Some(mk_info(4000, 500.0)),
Some(mk_info(6000, 500.0)),
];
let (b, _d) = aggregate_iso_probes(&rep, &extras, 4);
assert_eq!(b, 8000, "should pick the highest bitrate");
}
#[test]
fn aggregate_iso_probes_sums_durations_when_all_probes_succeed() {
let rep = mk_info(2000, 500.0);
let extras = vec![Some(mk_info(2000, 600.0)), Some(mk_info(2000, 700.0))];
let (_b, d) = aggregate_iso_probes(&rep, &extras, 3);
assert!((d - 1800.0).abs() < 1e-9);
}
#[test]
fn aggregate_iso_probes_falls_back_when_any_probe_failed() {
let rep = mk_info(2000, 500.0);
let extras = vec![Some(mk_info(2000, 600.0)), None];
let (_b, d) = aggregate_iso_probes(&rep, &extras, 3);
assert!((d - 1500.0).abs() < 1e-9);
}
#[test]
fn dir_is_writable_for_temp_dir() {
let tmp = tempdir().unwrap();
assert!(dir_is_writable(tmp.path()));
let leftover: Vec<_> = fs::read_dir(tmp.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with(".hvac_writable_check_")
})
.collect();
assert!(leftover.is_empty(), "probe file should be removed");
}
#[test]
fn dir_is_writable_cache_returns_same_result_twice() {
let tmp = tempdir().unwrap();
let mut cache = HashMap::new();
assert!(dir_is_writable_cached(&mut cache, tmp.path()));
assert_eq!(cache.len(), 1);
assert!(dir_is_writable_cached(&mut cache, tmp.path()));
assert_eq!(cache.len(), 1);
}
#[cfg(unix)]
#[test]
fn dir_is_writable_false_for_readonly() {
use std::os::unix::fs::PermissionsExt;
let is_root = unsafe { libc::geteuid() } == 0;
if is_root {
eprintln!("skipping read-only check under root (DAC bypass)");
return;
}
let tmp = tempdir().unwrap();
let ro = tmp.path().join("ro");
fs::create_dir(&ro).unwrap();
fs::set_permissions(&ro, fs::Permissions::from_mode(0o555)).unwrap();
let writable = dir_is_writable(&ro);
let _ = fs::set_permissions(&ro, fs::Permissions::from_mode(0o755));
assert!(!writable);
}
#[test]
fn dir_is_writable_does_not_clobber_user_file() {
let tmp = tempdir().unwrap();
let user_file = tmp
.path()
.join(format!(".hvac_writable_check_{}", std::process::id()));
fs::write(&user_file, b"user content").unwrap();
for _ in 0..3 {
assert!(dir_is_writable(tmp.path()));
}
assert_eq!(fs::read(&user_file).unwrap(), b"user content");
}
}