Add keydb updater, update README with labels + multi-lingual + real output

This commit is contained in:
MattJackson
2026-04-07 21:21:18 -07:00
parent 722b5cf56f
commit 188d2d0a5d
4 changed files with 199 additions and 6 deletions
+12 -6
View File
@@ -4,7 +4,9 @@
# libfreemkv # libfreemkv
Rust library for 4K UHD / Blu-ray optical drives. Drive access, disc scanning, AACS decryption, and content reading in one crate. Bundled drive profiles — no external files needed. Rust library for 4K UHD / Blu-ray optical drives. Drive access, disc scanning, stream labels, AACS decryption, KEYDB updates, and content reading in one crate. Bundled drive profiles — no external files needed.
Multi-lingual by design — the library outputs structured data and numeric error codes, never English text. Build any UI or localization on top.
**[API Documentation](https://docs.rs/libfreemkv)** · **[Technical Docs](docs/)** **[API Documentation](https://docs.rs/libfreemkv)** · **[Technical Docs](docs/)**
@@ -42,9 +44,11 @@ while let Some(unit) = reader.read_unit()? {
## What It Does ## What It Does
- **Drive access** — open, identify, unlock for raw reads - **Drive access** — open, identify, unlock, eject
- **Disc scanning** — UDF 2.50 filesystem, MPLS playlists, CLPI clip info, BD-J labels - **Disc scanning** — UDF 2.50 filesystem, MPLS playlists, CLPI clip info
- **AACS decryption** — transparent key resolution and content decrypt (1.0 + 2.0) - **Stream labels** — 5 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, Deluxe)
- **AACS decryption** — transparent key resolution and content decrypt (1.0, 2.0 in progress)
- **KEYDB updates** — download, verify, save from any HTTP URL (zero deps, raw TCP)
- **Content reading** — sector reads with automatic decryption - **Content reading** — sector reads with automatic decryption
AACS decryption requires a KEYDB.cfg file. If available at `~/.config/aacs/KEYDB.cfg` or passed via `ScanOptions`, the library handles everything — handshake, key derivation, and per-sector decryption — without the application needing to know anything about encryption. AACS decryption requires a KEYDB.cfg file. If available at `~/.config/aacs/KEYDB.cfg` or passed via `ScanOptions`, the library handles everything — handshake, key derivation, and per-sector decryption — without the application needing to know anything about encryption.
@@ -61,8 +65,9 @@ Disc — scan titles, streams, AACS state
├── UDF reader — Blu-ray UDF 2.50 with metadata partitions ├── UDF reader — Blu-ray UDF 2.50 with metadata partitions
├── MPLS parser — playlists → titles + clips + streams ├── MPLS parser — playlists → titles + clips + streams
├── CLPI parser — clip info → EP map → sector extents ├── CLPI parser — clip info → EP map → sector extents
├── JAR parser — BD-J audio track labels ├── Labels 5 BD-J format parsers (detect + parse)
── AACS — key resolution + content decryption ── AACS — key resolution + content decryption
└── KEYDB — download + verify + save
``` ```
See [docs/](docs/) for detailed technical documentation on each module. See [docs/](docs/) for detailed technical documentation on each module.
@@ -80,6 +85,7 @@ All errors are structured with numeric codes. No user-facing English text — ap
| E5xxx | I/O errors | | E5xxx | I/O errors |
| E6xxx | Disc format errors | | E6xxx | Disc format errors |
| E7xxx | AACS errors | | E7xxx | AACS errors |
| E8xxx | KEYDB update errors |
## Platform Support ## Platform Support
+25
View File
@@ -32,6 +32,11 @@ pub const E_SCSI_TIMEOUT: u16 = 4001;
pub const E_IO_ERROR: u16 = 5000; pub const E_IO_ERROR: u16 = 5000;
pub const E_DISC_ERROR: u16 = 6000; pub const E_DISC_ERROR: u16 = 6000;
pub const E_AACS_ERROR: u16 = 7000; pub const E_AACS_ERROR: u16 = 7000;
pub const E_KEYDB_CONNECT: u16 = 8000;
pub const E_KEYDB_HTTP: u16 = 8001;
pub const E_KEYDB_INVALID: u16 = 8002;
pub const E_KEYDB_WRITE: u16 = 8003;
pub const E_KEYDB_PARSE: u16 = 8004;
// ── Error enum ────────────────────────────────────────────────────────────── // ── Error enum ──────────────────────────────────────────────────────────────
@@ -52,6 +57,11 @@ pub enum Error {
IoError { source: std::io::Error }, IoError { source: std::io::Error },
DiscError { detail: String }, DiscError { detail: String },
AacsError { detail: String }, AacsError { detail: String },
KeydbConnect { host: String },
KeydbHttp { status: u16 },
KeydbInvalid,
KeydbWrite { path: String },
KeydbParse,
} }
impl Error { impl Error {
@@ -72,6 +82,11 @@ impl Error {
Error::IoError { .. } => E_IO_ERROR, Error::IoError { .. } => E_IO_ERROR,
Error::DiscError { .. } => E_DISC_ERROR, Error::DiscError { .. } => E_DISC_ERROR,
Error::AacsError { .. } => E_AACS_ERROR, Error::AacsError { .. } => E_AACS_ERROR,
Error::KeydbConnect { .. } => E_KEYDB_CONNECT,
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
Error::KeydbInvalid => E_KEYDB_INVALID,
Error::KeydbWrite { .. } => E_KEYDB_WRITE,
Error::KeydbParse => E_KEYDB_PARSE,
} }
} }
} }
@@ -115,6 +130,16 @@ impl std::fmt::Display for Error {
write!(f, "E{}: {}", E_DISC_ERROR, detail), write!(f, "E{}: {}", E_DISC_ERROR, detail),
Error::AacsError { detail } => Error::AacsError { detail } =>
write!(f, "E{}: {}", E_AACS_ERROR, detail), write!(f, "E{}: {}", E_AACS_ERROR, detail),
Error::KeydbConnect { host } =>
write!(f, "E{}: {}", E_KEYDB_CONNECT, host),
Error::KeydbHttp { status } =>
write!(f, "E{}: {}", E_KEYDB_HTTP, status),
Error::KeydbInvalid =>
write!(f, "E{}", E_KEYDB_INVALID),
Error::KeydbWrite { path } =>
write!(f, "E{}: {}", E_KEYDB_WRITE, path),
Error::KeydbParse =>
write!(f, "E{}", E_KEYDB_PARSE),
} }
} }
} }
+161
View File
@@ -0,0 +1,161 @@
//! KEYDB.cfg updater — HTTP GET, unzip, verify, save.
//!
//! Zero external HTTP dependencies. Raw TCP for HTTP GET.
//! Uses `zip` and `flate2` (already in deps) for extraction.
use crate::error::{Error, Result};
use std::io::{Read, Write};
use std::net::TcpStream;
use std::path::PathBuf;
/// Standard keydb storage path.
pub fn default_path() -> Result<PathBuf> {
let home = std::env::var("HOME").map_err(|_| Error::KeydbWrite {
path: "HOME".into(),
})?;
Ok(PathBuf::from(home).join(".config").join("freemkv").join("keydb.cfg"))
}
/// Download a KEYDB from a URL, verify, save to the standard path.
pub fn update(url: &str) -> Result<UpdateResult> {
let body = http_get(url)?;
save(&body)
}
/// Verify and save raw keydb bytes (plain text, .zip, or .gz).
pub fn save(data: &[u8]) -> Result<UpdateResult> {
let text = if data.starts_with(b"PK\x03\x04") {
extract_zip(data)?
} else if data.starts_with(&[0x1f, 0x8b]) {
let mut dec = flate2::read::GzDecoder::new(data);
let mut out = String::new();
dec.read_to_string(&mut out).map_err(|_| Error::KeydbParse)?;
out
} else {
String::from_utf8(data.to_vec()).map_err(|_| Error::KeydbParse)?
};
let entries = text.lines()
.filter(|l| {
let t = l.trim();
t.starts_with("0x") || t.starts_with("| DK") || t.starts_with("| PK") || t.starts_with("| HC")
})
.count();
if entries == 0 {
return Err(Error::KeydbInvalid);
}
let path = default_path()?;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|_| Error::KeydbWrite {
path: path.display().to_string(),
})?;
}
std::fs::write(&path, &text).map_err(|_| Error::KeydbWrite {
path: path.display().to_string(),
})?;
Ok(UpdateResult { path, entries, bytes: text.len() })
}
#[derive(Debug)]
pub struct UpdateResult {
pub path: PathBuf,
pub entries: usize,
pub bytes: usize,
}
fn http_get(url: &str) -> Result<Vec<u8>> {
let (host, port, path) = parse_url(url)?;
for _ in 0..5 {
let addr = format!("{}:{}", host, port);
let mut stream = TcpStream::connect(&addr).map_err(|_| Error::KeydbConnect {
host: host.clone(),
})?;
stream.set_read_timeout(Some(std::time::Duration::from_secs(30))).ok();
let request = format!(
"GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\nAccept-Encoding: identity\r\n\r\n",
path, host
);
stream.write_all(request.as_bytes()).map_err(|_| Error::KeydbConnect {
host: host.clone(),
})?;
let mut response = Vec::new();
stream.read_to_end(&mut response).map_err(|_| Error::KeydbConnect {
host: host.clone(),
})?;
let header_end = find_header_end(&response).ok_or(Error::KeydbParse)?;
let headers = std::str::from_utf8(&response[..header_end]).unwrap_or("");
let body = &response[header_end + 4..];
if let Some(location) = extract_header(headers, "Location") {
return http_get(&location);
}
let status = parse_status(headers);
if status != 200 {
return Err(Error::KeydbHttp { status });
}
return Ok(body.to_vec());
}
Err(Error::KeydbHttp { status: 302 })
}
fn parse_url(url: &str) -> Result<(String, u16, String)> {
let url = url.strip_prefix("http://").ok_or(Error::KeydbParse)?;
let (host_port, path) = match url.find('/') {
Some(i) => (&url[..i], &url[i..]),
None => (url, "/"),
};
let (host, port) = match host_port.find(':') {
Some(i) => (&host_port[..i], host_port[i+1..].parse().unwrap_or(80)),
None => (host_port, 80u16),
};
Ok((host.to_string(), port, path.to_string()))
}
fn parse_status(headers: &str) -> u16 {
headers.lines().next()
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|s| s.parse().ok())
.unwrap_or(0)
}
fn find_header_end(data: &[u8]) -> Option<usize> {
data.windows(4).position(|w| w == b"\r\n\r\n")
}
fn extract_header<'a>(headers: &'a str, name: &str) -> Option<String> {
for line in headers.lines() {
if line.len() > name.len() + 2
&& line[..name.len()].eq_ignore_ascii_case(name)
&& line.as_bytes()[name.len()] == b':'
{
return Some(line[name.len() + 1..].trim().to_string());
}
}
None
}
fn extract_zip(data: &[u8]) -> Result<String> {
let cursor = std::io::Cursor::new(data);
let mut archive = zip::ZipArchive::new(cursor).map_err(|_| Error::KeydbParse)?;
for i in 0..archive.len() {
let mut file = archive.by_index(i).map_err(|_| Error::KeydbParse)?;
if file.name().ends_with(".cfg") || file.name().ends_with(".CFG") {
let mut text = String::new();
file.read_to_string(&mut text).map_err(|_| Error::KeydbParse)?;
return Ok(text);
}
}
Err(Error::KeydbInvalid)
}
+1
View File
@@ -79,6 +79,7 @@ pub mod clpi;
pub mod disc; pub mod disc;
pub mod aacs; pub mod aacs;
pub mod labels; pub mod labels;
pub mod keydb;
pub use error::{Error, Result}; pub use error::{Error, Result};
pub use drive::DriveSession; pub use drive::DriveSession;