use std::collections::HashMap;
use std::env;
use std::fs;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use blake2::{Blake2b512, Digest};
use tempfile::TempDir;
const BASE_URL: &str = "https://mediaarea.net/download/binary/libmediainfo0";
const MEDIAINFO_VERSION: &str = "25.10";
#[derive(Debug, Clone, Copy)]
enum Platform {
Linux,
MacOs,
Windows,
}
#[derive(Debug, Clone, Copy)]
enum Arch {
X86_64,
Arm64,
I386,
}
type DownloadResult<T> = Result<T, Box<dyn std::error::Error>>;
fn main() {
if env::var("CARGO_FEATURE_BUNDLED").is_err() {
return;
}
println!("cargo:rerun-if-env-changed=RS_MEDIAINFO_SKIP_DOWNLOAD");
println!("cargo:rerun-if-env-changed=RS_MEDIAINFO_DOWNLOAD_TIMEOUT");
println!("cargo:rerun-if-env-changed=RS_MEDIAINFO_CACHE_DIR");
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_else(|_| env::consts::OS.to_string());
let target_arch =
env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_else(|_| env::consts::ARCH.to_string());
let platform = match parse_platform(&target_os) {
Ok(platform) => platform,
Err(err) => {
println!("cargo:warning=Bundled library disabled: {err}");
return;
}
};
let arch = match parse_arch(&target_arch) {
Ok(arch) => arch,
Err(err) => {
println!("cargo:warning=Bundled library disabled: {err}");
return;
}
};
let skip_download = env::var("RS_MEDIAINFO_SKIP_DOWNLOAD")
.map(|value| is_truthy(&value))
.unwrap_or(false);
let out_dir = PathBuf::from(env::var("OUT_DIR").expect("missing OUT_DIR"));
let cache_dir = env::var("RS_MEDIAINFO_CACHE_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| out_dir.join("mediainfo-cache"));
let bundled_dir = out_dir.join("mediainfo");
let has_cache_library = has_library_files(&cache_dir, platform);
let source_dir = cache_dir.clone();
if !has_cache_library {
if skip_download {
println!(
"cargo:warning=Bundled library not found; skipping download and falling back to system search"
);
return;
}
let timeout = env::var("RS_MEDIAINFO_DOWNLOAD_TIMEOUT")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(20);
if let Err(err) = download_files(platform, arch, &cache_dir, timeout, true) {
panic!("Failed to download bundled library: {err}");
}
}
fs::create_dir_all(&bundled_dir).expect("failed to create bundled output directory");
let mut copied_any = false;
for name in expected_library_names(platform) {
let source = source_dir.join(name);
if source.is_file() {
let dest = bundled_dir.join(name);
fs::copy(&source, &dest).expect("failed to copy bundled library");
copied_any = true;
}
}
if !copied_any {
panic!(
"No MediaInfo library files found in {}",
source_dir.display()
);
}
let mut license_copied = false;
for name in expected_license_names(platform) {
let source = source_dir.join(name);
if source.is_file() {
let dest = bundled_dir.join(name);
fs::copy(&source, &dest).expect("failed to copy bundled license");
license_copied = true;
}
}
if !license_copied {
panic!(
"No MediaInfo license file found in {}",
source_dir.display()
);
}
println!(
"cargo:rustc-env=RS_MEDIAINFO_BUNDLED_DIR={}",
bundled_dir.display()
);
}
fn has_library_files(dir: &Path, platform: Platform) -> bool {
if !dir.is_dir() {
return false;
}
expected_library_names(platform)
.iter()
.any(|name| dir.join(name).is_file())
}
fn is_truthy(value: &str) -> bool {
matches!(value.trim().to_lowercase().as_str(), "1" | "true" | "yes")
}
fn parse_platform(value: &str) -> DownloadResult<Platform> {
let value = value.to_lowercase();
match value.as_str() {
"linux" => Ok(Platform::Linux),
"macos" | "darwin" => Ok(Platform::MacOs),
"windows" | "win32" => Ok(Platform::Windows),
other => Err(format!("unsupported platform: {other}").into()),
}
}
fn parse_arch(value: &str) -> DownloadResult<Arch> {
let value = value.to_lowercase();
match value.as_str() {
"x86_64" => Ok(Arch::X86_64),
"arm64" | "aarch64" => Ok(Arch::Arm64),
"i386" | "i686" | "x86" => Ok(Arch::I386),
other => Err(format!("unsupported arch: {other}").into()),
}
}
fn download_files(
platform: Platform,
arch: Arch,
target_dir: &Path,
timeout: u64,
verbose: bool,
) -> DownloadResult<HashMap<String, String>> {
validate_combination(platform, arch)?;
fs::create_dir_all(target_dir)?;
let file_name = compressed_file_name(platform, arch)?;
let url = format!("{}/{}/{}", BASE_URL, MEDIAINFO_VERSION, file_name);
let expected = expected_hash(platform, arch).ok_or("missing hash for target")?;
if verbose {
println!("Downloading MediaInfo library from {url}");
}
let temp_dir = TempDir::new()?;
let archive_path = temp_dir.path().join(&file_name);
download_to_file(&url, &archive_path, timeout)?;
let digest = blake2b_hash(&archive_path)?;
if digest != expected {
return Err(format!("hash mismatch: expected {expected}, got {digest}").into());
}
if verbose {
println!("Extracting {file_name}");
}
let files = unpack_archive(platform, &archive_path, target_dir)?;
if verbose {
println!("Extracted files: {files:?}");
}
Ok(files)
}
fn validate_combination(platform: Platform, arch: Arch) -> DownloadResult<()> {
match platform {
Platform::Linux => match arch {
Arch::X86_64 | Arch::Arm64 => Ok(()),
Arch::I386 => Err("i386 is not supported for Linux downloads".into()),
},
Platform::MacOs => match arch {
Arch::X86_64 | Arch::Arm64 => Ok(()),
Arch::I386 => Err("i386 is not supported for macOS downloads".into()),
},
Platform::Windows => match arch {
Arch::X86_64 | Arch::I386 => Ok(()),
Arch::Arm64 => Err("arm64 is not supported for Windows downloads".into()),
},
}
}
fn compressed_file_name(platform: Platform, arch: Arch) -> DownloadResult<String> {
let name = match platform {
Platform::Linux => format!(
"MediaInfo_DLL_{MEDIAINFO_VERSION}_Lambda_{}.zip",
arch.archive_suffix()
),
Platform::MacOs => format!("MediaInfo_DLL_{MEDIAINFO_VERSION}_Mac_x86_64+arm64.tar.bz2"),
Platform::Windows => {
let win_arch = match arch {
Arch::X86_64 => "x64",
Arch::I386 => "i386",
Arch::Arm64 => {
return Err("arm64 is not supported for Windows downloads".into());
}
};
format!("MediaInfo_DLL_{MEDIAINFO_VERSION}_Windows_{win_arch}_WithoutInstaller.zip")
}
};
Ok(name)
}
fn expected_hash(platform: Platform, arch: Arch) -> Option<&'static str> {
match (platform, arch) {
(Platform::Linux, Arch::X86_64) => Some(
"91e05454e84e843451bd00edb05c18037c6bf7586cbdcd4dec5178ddf309d81b639b6e9c4d5bf2cbb77f90b69061f427544521b21534dc94ca6761570cb708c7",
),
(Platform::Linux, Arch::Arm64) => Some(
"b6a79f8afa78d22999691791283d274e457be10bee1e8a9c6a9af58752f05b4615b0d32d6b214379db519c73e59b7da85b7de05ea2be12163ee9a50e8017215f",
),
(Platform::MacOs, Arch::X86_64) => Some(
"e706ce75e59e4eb9368d581a7312ce5b7bb22e701bf19beac8ba4e21845f9837be8f765f758a0de9570139ee5014a60f230b413f4fe92dfb48e0be7b80fe1aaf",
),
(Platform::MacOs, Arch::Arm64) => Some(
"e706ce75e59e4eb9368d581a7312ce5b7bb22e701bf19beac8ba4e21845f9837be8f765f758a0de9570139ee5014a60f230b413f4fe92dfb48e0be7b80fe1aaf",
),
(Platform::Windows, Arch::X86_64) => Some(
"03d503652c013fbf2cb8030747b07e5b2297b8ce4c50a893c013f193594d6cd6e3f36510191a65412da843f4083bcf6cb515b30a79f2114a4f2ab86ce7f4010f",
),
(Platform::Windows, Arch::I386) => Some(
"a3e47b303a4520fc31a46dd694241a218bbdd39daabfa9c512f81e789260f0ebc21fd6ea86ba12da008de699ccdc045a8d6b2cee13e999837e250d5ca34f094a",
),
_ => None,
}
}
fn download_to_file(url: &str, output: &Path, timeout: u64) -> DownloadResult<()> {
const MAX_ATTEMPTS: u32 = 3;
let mut last_err: Option<Box<dyn std::error::Error>> = None;
for attempt in 1..=MAX_ATTEMPTS {
match try_download(url, output, timeout) {
Ok(()) => return Ok(()),
Err(err) => {
if attempt < MAX_ATTEMPTS {
let delay = 1u64 << attempt;
println!(
"cargo:warning=Download attempt {attempt}/{MAX_ATTEMPTS} failed: {err}; retrying in {delay}s"
);
std::thread::sleep(std::time::Duration::from_secs(delay));
}
last_err = Some(err);
}
}
}
Err(last_err.unwrap_or_else(|| "unknown download error".into()))
}
fn try_download(url: &str, output: &Path, timeout: u64) -> DownloadResult<()> {
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(timeout))
.build()?;
let mut response = client.get(url).send()?.error_for_status()?;
let mut file = fs::File::create(output)?;
let mut buffer = [0u8; 8192];
loop {
let read = response.read(&mut buffer)?;
if read == 0 {
break;
}
file.write_all(&buffer[..read])?;
}
Ok(())
}
fn blake2b_hash(path: &Path) -> io::Result<String> {
let mut file = fs::File::open(path)?;
let mut hasher = Blake2b512::new();
let mut buffer = [0u8; 8192];
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
let digest = hasher.finalize();
Ok(hex_string(&digest))
}
fn unpack_archive(
platform: Platform,
archive_path: &Path,
target_dir: &Path,
) -> DownloadResult<HashMap<String, String>> {
match platform {
Platform::Linux => unpack_linux_zip(archive_path, target_dir),
Platform::Windows => unpack_windows_zip(archive_path, target_dir),
Platform::MacOs => unpack_macos_tar(archive_path, target_dir),
}
}
fn unpack_linux_zip(
archive_path: &Path,
target_dir: &Path,
) -> DownloadResult<HashMap<String, String>> {
let file = fs::File::open(archive_path)?;
let mut archive = zip::ZipArchive::new(file)?;
let mut files = HashMap::new();
let license = extract_zip_entry(&mut archive, "LICENSE", target_dir, "LICENSE")?;
let lib = extract_zip_entry(
&mut archive,
"lib/libmediainfo.so.0.0.0",
target_dir,
"libmediainfo.so.0",
)?;
files.insert("license".to_string(), license);
files.insert("lib".to_string(), lib);
Ok(files)
}
fn unpack_windows_zip(
archive_path: &Path,
target_dir: &Path,
) -> DownloadResult<HashMap<String, String>> {
let file = fs::File::open(archive_path)?;
let mut archive = zip::ZipArchive::new(file)?;
let mut files = HashMap::new();
let license = extract_zip_entry(
&mut archive,
"Developers/License.html",
target_dir,
"License.html",
)?;
let lib = extract_zip_entry(&mut archive, "MediaInfo.dll", target_dir, "MediaInfo.dll")?;
files.insert("license".to_string(), license);
files.insert("lib".to_string(), lib);
Ok(files)
}
fn unpack_macos_tar(
archive_path: &Path,
target_dir: &Path,
) -> DownloadResult<HashMap<String, String>> {
let file = fs::File::open(archive_path)?;
let decoder = bzip2::read::BzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
let mut license_path = None;
let mut lib_path = None;
for entry in archive.entries()? {
let mut entry = entry?;
let path = entry.path()?.into_owned();
if path.as_path() == Path::new("MediaInfoLib/License.html") {
let output_path = target_dir.join("License.html");
let mut output = fs::File::create(&output_path)?;
io::copy(&mut entry, &mut output)?;
license_path = Some(output_path);
}
if path.as_path() == Path::new("MediaInfoLib/libmediainfo.0.dylib") {
let output_path = target_dir.join("libmediainfo.0.dylib");
let mut output = fs::File::create(&output_path)?;
io::copy(&mut entry, &mut output)?;
lib_path = Some(output_path);
}
if license_path.is_some() && lib_path.is_some() {
break;
}
}
let license = license_path.ok_or("license not found in archive")?;
let lib = lib_path.ok_or("library not found in archive")?;
let mut files = HashMap::new();
files.insert(
"license".to_string(),
license
.strip_prefix(target_dir)
.unwrap_or(&license)
.display()
.to_string(),
);
files.insert(
"lib".to_string(),
lib.strip_prefix(target_dir)
.unwrap_or(&lib)
.display()
.to_string(),
);
Ok(files)
}
fn extract_zip_entry(
archive: &mut zip::ZipArchive<fs::File>,
name: &str,
target_dir: &Path,
output_name: &str,
) -> DownloadResult<String> {
let mut entry = archive.by_name(name)?;
let output_path = target_dir.join(output_name);
let mut output = fs::File::create(&output_path)?;
io::copy(&mut entry, &mut output)?;
Ok(output_path
.strip_prefix(target_dir)
.unwrap_or(&output_path)
.display()
.to_string())
}
fn hex_string(bytes: &[u8]) -> String {
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
output.push_str(&format!("{:02x}", byte));
}
output
}
fn expected_library_names(platform: Platform) -> &'static [&'static str] {
match platform {
Platform::Windows => &["MediaInfo.dll"],
Platform::MacOs => &["libmediainfo.0.dylib", "libmediainfo.dylib"],
Platform::Linux => &["libmediainfo.so.0"],
}
}
fn expected_license_names(platform: Platform) -> &'static [&'static str] {
match platform {
Platform::Windows | Platform::MacOs => &["License.html"],
Platform::Linux => &["LICENSE"],
}
}
impl Arch {
fn archive_suffix(&self) -> &'static str {
match self {
Arch::X86_64 => "x86_64",
Arch::Arm64 => "arm64",
Arch::I386 => "i386",
}
}
}