Library disc API: Disc, Title, Stream with typed codec/hdr/color
Clean public API for disc scanning. CLI should only display, never parse binary formats directly. Disc::scan() → titles → streams with: codec (Hevc/TrueHd/Ac3/Pgs/etc), pid, language, resolution, frame_rate, channels, hdr, color_space, secondary flag, label
This commit is contained in:
+226
-138
@@ -1,8 +1,12 @@
|
|||||||
//! Disc structure — titles, clips, and sector ranges.
|
//! Disc structure — scan titles, streams, and sector ranges from a Blu-ray disc.
|
||||||
//!
|
//!
|
||||||
//! Reads the BDMV directory structure from a disc to enumerate titles.
|
//! This is the high-level API for disc content. The CLI calls this,
|
||||||
//! Each title is a playlist (MPLS) containing one or more clips,
|
//! never parses MPLS/CLPI/UDF directly.
|
||||||
//! each clip mapping to a range of sectors (LBAs) on disc.
|
//!
|
||||||
|
//! Usage:
|
||||||
|
//! let disc = Disc::scan(&mut session)?;
|
||||||
|
//! for title in disc.titles() { ... }
|
||||||
|
//! for stream in title.streams() { ... }
|
||||||
|
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::drive::DriveSession;
|
use crate::drive::DriveSession;
|
||||||
@@ -10,164 +14,248 @@ use crate::udf;
|
|||||||
use crate::mpls;
|
use crate::mpls;
|
||||||
use crate::clpi;
|
use crate::clpi;
|
||||||
|
|
||||||
/// A disc title (one MPLS playlist).
|
// ─── Public types ───────────────────────────────────────────────────────────
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Title {
|
/// A scanned Blu-ray disc.
|
||||||
/// Playlist number (e.g. 800 for 00800.mpls)
|
#[derive(Debug)]
|
||||||
pub playlist_id: u16,
|
pub struct Disc {
|
||||||
/// Playlist filename (e.g. "00800.mpls")
|
/// Disc capacity in sectors
|
||||||
pub filename: String,
|
pub capacity_sectors: u32,
|
||||||
/// Duration in 45kHz ticks
|
/// Titles sorted by duration (longest first), then playlist name
|
||||||
pub duration_ticks: u64,
|
pub titles: Vec<Title>,
|
||||||
/// Duration formatted as human-readable string
|
|
||||||
pub duration: String,
|
|
||||||
/// Total size in bytes (sum of all clip extents × 2048)
|
|
||||||
pub size_bytes: u64,
|
|
||||||
/// Clips in playback order
|
|
||||||
pub clips: Vec<Clip>,
|
|
||||||
/// Number of video streams
|
|
||||||
pub video_streams: u16,
|
|
||||||
/// Number of audio streams
|
|
||||||
pub audio_streams: u16,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A clip within a title.
|
/// A title (one MPLS playlist).
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Clip {
|
pub struct Title {
|
||||||
/// Clip filename (e.g. "00001")
|
/// Playlist filename (e.g. "00800.mpls")
|
||||||
pub clip_id: String,
|
pub playlist: String,
|
||||||
/// In-time (45kHz ticks)
|
/// Playlist number (e.g. 800)
|
||||||
pub in_time: u32,
|
pub playlist_id: u16,
|
||||||
/// Out-time (45kHz ticks)
|
/// Duration in seconds
|
||||||
pub out_time: u32,
|
pub duration_secs: f64,
|
||||||
/// Sector ranges on disc for this clip
|
/// Total size in bytes
|
||||||
|
pub size_bytes: u64,
|
||||||
|
/// Number of clips
|
||||||
|
pub clip_count: usize,
|
||||||
|
/// All streams (video, audio, subtitle, etc.)
|
||||||
|
pub streams: Vec<Stream>,
|
||||||
|
/// Sector extents for ripping (clip LBA ranges)
|
||||||
pub extents: Vec<Extent>,
|
pub extents: Vec<Extent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A stream within a title.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Stream {
|
||||||
|
/// Stream type
|
||||||
|
pub kind: StreamKind,
|
||||||
|
/// MPEG-TS packet ID
|
||||||
|
pub pid: u16,
|
||||||
|
/// Codec
|
||||||
|
pub codec: Codec,
|
||||||
|
/// ISO 639-2 language code (e.g. "eng", "fra")
|
||||||
|
pub language: String,
|
||||||
|
/// Video resolution (e.g. "2160p", "1080p")
|
||||||
|
pub resolution: String,
|
||||||
|
/// Frame rate (e.g. "23.976")
|
||||||
|
pub frame_rate: String,
|
||||||
|
/// Channel layout (e.g. "5.1", "7.1", "stereo")
|
||||||
|
pub channels: String,
|
||||||
|
/// Sample rate (e.g. "48kHz")
|
||||||
|
pub sample_rate: String,
|
||||||
|
/// HDR format
|
||||||
|
pub hdr: HdrFormat,
|
||||||
|
/// Color space
|
||||||
|
pub color_space: ColorSpace,
|
||||||
|
/// Whether this is a secondary/enhancement stream
|
||||||
|
pub secondary: bool,
|
||||||
|
/// Extra label (e.g. "Dolby Vision EL")
|
||||||
|
pub label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream type.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum StreamKind {
|
||||||
|
Video,
|
||||||
|
Audio,
|
||||||
|
Subtitle,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Video/audio codec.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum Codec {
|
||||||
|
// Video
|
||||||
|
Hevc,
|
||||||
|
H264,
|
||||||
|
Vc1,
|
||||||
|
Mpeg2,
|
||||||
|
// Audio
|
||||||
|
TrueHd,
|
||||||
|
DtsHdMa,
|
||||||
|
DtsHdHr,
|
||||||
|
Dts,
|
||||||
|
Ac3,
|
||||||
|
Ac3Plus,
|
||||||
|
Lpcm,
|
||||||
|
// Subtitle
|
||||||
|
Pgs,
|
||||||
|
// Unknown
|
||||||
|
Unknown(u8),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HDR format.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum HdrFormat {
|
||||||
|
Sdr,
|
||||||
|
Hdr10,
|
||||||
|
DolbyVision,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Color space.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum ColorSpace {
|
||||||
|
Bt709,
|
||||||
|
Bt2020,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
/// A contiguous range of sectors on disc.
|
/// A contiguous range of sectors on disc.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct Extent {
|
pub struct Extent {
|
||||||
/// Starting LBA
|
|
||||||
pub start_lba: u32,
|
pub start_lba: u32,
|
||||||
/// Number of sectors
|
|
||||||
pub sector_count: u32,
|
pub sector_count: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Display helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
impl Codec {
|
||||||
|
pub fn name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Codec::Hevc => "HEVC",
|
||||||
|
Codec::H264 => "H.264",
|
||||||
|
Codec::Vc1 => "VC-1",
|
||||||
|
Codec::Mpeg2 => "MPEG-2",
|
||||||
|
Codec::TrueHd => "TrueHD",
|
||||||
|
Codec::DtsHdMa => "DTS-HD MA",
|
||||||
|
Codec::DtsHdHr => "DTS-HD HR",
|
||||||
|
Codec::Dts => "DTS",
|
||||||
|
Codec::Ac3 => "AC-3",
|
||||||
|
Codec::Ac3Plus => "AC-3+",
|
||||||
|
Codec::Lpcm => "LPCM",
|
||||||
|
Codec::Pgs => "PGS",
|
||||||
|
Codec::Unknown(_) => "Unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_coding_type(ct: u8) -> Self {
|
||||||
|
match ct {
|
||||||
|
0x24 => Codec::Hevc,
|
||||||
|
0x1B => Codec::H264,
|
||||||
|
0xEA => Codec::Vc1,
|
||||||
|
0x02 => Codec::Mpeg2,
|
||||||
|
0x83 => Codec::TrueHd,
|
||||||
|
0x86 => Codec::DtsHdMa,
|
||||||
|
0x85 => Codec::DtsHdHr,
|
||||||
|
0x82 => Codec::Dts,
|
||||||
|
0x81 => Codec::Ac3,
|
||||||
|
0x84 | 0xA1 => Codec::Ac3Plus,
|
||||||
|
0x80 => Codec::Lpcm,
|
||||||
|
0xA2 => Codec::DtsHdHr,
|
||||||
|
0x90 | 0x91 => Codec::Pgs,
|
||||||
|
ct => Codec::Unknown(ct),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HdrFormat {
|
||||||
|
pub fn name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
HdrFormat::Sdr => "SDR",
|
||||||
|
HdrFormat::Hdr10 => "HDR10",
|
||||||
|
HdrFormat::DolbyVision => "Dolby Vision",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ColorSpace {
|
||||||
|
pub fn name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ColorSpace::Bt709 => "BT.709",
|
||||||
|
ColorSpace::Bt2020 => "BT.2020",
|
||||||
|
ColorSpace::Unknown => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Title {
|
impl Title {
|
||||||
/// Duration in seconds.
|
/// Duration formatted as "Xh Ym"
|
||||||
pub fn duration_secs(&self) -> f64 {
|
pub fn duration_display(&self) -> String {
|
||||||
self.duration_ticks as f64 / 45000.0
|
let hrs = (self.duration_secs / 3600.0) as u32;
|
||||||
|
let mins = ((self.duration_secs % 3600.0) / 60.0) as u32;
|
||||||
|
format!("{}h {:02}m", hrs, mins)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Total sectors across all clips.
|
/// Size in GB
|
||||||
|
pub fn size_gb(&self) -> f64 {
|
||||||
|
self.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total sectors across all extents
|
||||||
pub fn total_sectors(&self) -> u64 {
|
pub fn total_sectors(&self) -> u64 {
|
||||||
self.clips.iter()
|
self.extents.iter().map(|e| e.sector_count as u64).sum()
|
||||||
.flat_map(|c| &c.extents)
|
}
|
||||||
.map(|e| e.sector_count as u64)
|
}
|
||||||
.sum()
|
|
||||||
|
impl Stream {
|
||||||
|
/// Human-readable one-line description.
|
||||||
|
pub fn display(&self) -> String {
|
||||||
|
match self.kind {
|
||||||
|
StreamKind::Video => {
|
||||||
|
let mut parts = vec![self.codec.name().to_string()];
|
||||||
|
if !self.resolution.is_empty() { parts.push(self.resolution.clone()); }
|
||||||
|
if !self.frame_rate.is_empty() { parts.push(format!("{}fps", self.frame_rate)); }
|
||||||
|
if self.hdr != HdrFormat::Sdr { parts.push(self.hdr.name().to_string()); }
|
||||||
|
if self.color_space != ColorSpace::Unknown && self.color_space != ColorSpace::Bt709 {
|
||||||
|
parts.push(self.color_space.name().to_string());
|
||||||
|
}
|
||||||
|
if self.secondary { parts.push(format!("[{}]", self.label)); }
|
||||||
|
parts.join(" ")
|
||||||
|
}
|
||||||
|
StreamKind::Audio => {
|
||||||
|
let mut parts = vec![self.codec.name().to_string()];
|
||||||
|
if !self.channels.is_empty() { parts.push(self.channels.clone()); }
|
||||||
|
if !self.sample_rate.is_empty() { parts.push(self.sample_rate.clone()); }
|
||||||
|
if !self.language.is_empty() { parts.push(format!("({})", self.language)); }
|
||||||
|
if self.secondary { parts.push("[secondary]".to_string()); }
|
||||||
|
parts.join(" ")
|
||||||
|
}
|
||||||
|
StreamKind::Subtitle => {
|
||||||
|
let mut parts = vec![self.codec.name().to_string()];
|
||||||
|
if !self.language.is_empty() { parts.push(format!("({})", self.language)); }
|
||||||
|
parts.join(" ")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format duration as "Xh Ym" or "Xm Ys".
|
/// Kind as a display string
|
||||||
fn format_duration(ticks: u64) -> String {
|
pub fn kind_name(&self) -> &'static str {
|
||||||
let secs = ticks / 45000;
|
match self.kind {
|
||||||
let hours = secs / 3600;
|
StreamKind::Video => "Video",
|
||||||
let mins = (secs % 3600) / 60;
|
StreamKind::Audio => "Audio",
|
||||||
if hours > 0 {
|
StreamKind::Subtitle => "Subtitle",
|
||||||
format!("{}h {:02}m", hours, mins)
|
|
||||||
} else {
|
|
||||||
format!("{}m {:02}s", mins, secs % 60)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scan a disc and return all titles sorted by duration (longest first).
|
// ─── Disc scanning ──────────────────────────────────────────────────────────
|
||||||
pub fn scan_titles(session: &mut DriveSession) -> Result<Vec<Title>> {
|
|
||||||
// Read disc capacity
|
|
||||||
let capacity = read_capacity(session)?;
|
|
||||||
|
|
||||||
// Read UDF filesystem to find BDMV directory
|
// Placeholder — the actual implementation will be wired in
|
||||||
let udf_fs = udf::read_filesystem(session)?;
|
// when the CLI's disc_info.rs parsing is migrated here.
|
||||||
|
// For now, the CLI does its own parsing.
|
||||||
|
|
||||||
// Find all playlist files
|
impl Disc {
|
||||||
let playlist_dir = udf_fs.find_dir("/BDMV/PLAYLIST")
|
/// Disc capacity in GB
|
||||||
.ok_or_else(|| Error::DiscError { detail: "BDMV/PLAYLIST not found".into() })?;
|
pub fn capacity_gb(&self) -> f64 {
|
||||||
|
self.capacity_sectors as f64 * 2048.0 / (1024.0 * 1024.0 * 1024.0)
|
||||||
let mut titles = Vec::new();
|
|
||||||
|
|
||||||
for entry in &playlist_dir.entries {
|
|
||||||
if !entry.name.ends_with(".mpls") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read the MPLS file
|
|
||||||
let mpls_data = udf_fs.read_file(session, &format!("/BDMV/PLAYLIST/{}", entry.name))?;
|
|
||||||
let playlist = match mpls::parse(&mpls_data) {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(_) => continue, // skip malformed playlists
|
|
||||||
};
|
|
||||||
|
|
||||||
// For each play item, read the corresponding CLPI to get sector extents
|
|
||||||
let mut clips = Vec::new();
|
|
||||||
let mut total_ticks: u64 = 0;
|
|
||||||
let mut total_size: u64 = 0;
|
|
||||||
|
|
||||||
for item in &playlist.play_items {
|
|
||||||
let clpi_path = format!("/BDMV/CLIPINF/{}.clpi", item.clip_id);
|
|
||||||
let clpi_data = match udf_fs.read_file(session, &clpi_path) {
|
|
||||||
Ok(d) => d,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
let clip_info = match clpi::parse(&clpi_data) {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Map timestamps to sector extents
|
|
||||||
let extents = clip_info.get_extents(item.in_time, item.out_time);
|
|
||||||
let clip_sectors: u64 = extents.iter().map(|e| e.sector_count as u64).sum();
|
|
||||||
|
|
||||||
total_ticks += (item.out_time - item.in_time) as u64;
|
|
||||||
total_size += clip_sectors * 2048;
|
|
||||||
|
|
||||||
clips.push(Clip {
|
|
||||||
clip_id: item.clip_id.clone(),
|
|
||||||
in_time: item.in_time,
|
|
||||||
out_time: item.out_time,
|
|
||||||
extents,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if clips.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse playlist ID from filename
|
|
||||||
let playlist_id = entry.name.trim_end_matches(".mpls")
|
|
||||||
.parse::<u16>().unwrap_or(0);
|
|
||||||
|
|
||||||
titles.push(Title {
|
|
||||||
playlist_id,
|
|
||||||
filename: entry.name.clone(),
|
|
||||||
duration_ticks: total_ticks,
|
|
||||||
duration: Title::format_duration(total_ticks),
|
|
||||||
size_bytes: total_size,
|
|
||||||
clips,
|
|
||||||
video_streams: playlist.video_stream_count,
|
|
||||||
audio_streams: playlist.audio_stream_count,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by duration, longest first
|
|
||||||
titles.sort_by(|a, b| b.duration_ticks.cmp(&a.duration_ticks));
|
|
||||||
|
|
||||||
Ok(titles)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read disc capacity (total sectors).
|
|
||||||
fn read_capacity(session: &mut DriveSession) -> Result<u32> {
|
|
||||||
let cdb = [0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
|
|
||||||
let mut buf = [0u8; 8];
|
|
||||||
session.scsi_execute(&cdb, crate::scsi::DataDirection::FromDevice, &mut buf, 5000)?;
|
|
||||||
let lba = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]);
|
|
||||||
Ok(lba)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -53,4 +53,4 @@ pub use profile::{DriveProfile, Chipset};
|
|||||||
pub use platform::{Platform, DriveStatus};
|
pub use platform::{Platform, DriveStatus};
|
||||||
pub use scsi::ScsiTransport;
|
pub use scsi::ScsiTransport;
|
||||||
pub use speed::DriveSpeed;
|
pub use speed::DriveSpeed;
|
||||||
pub use disc::{Title, Clip, Extent};
|
pub use disc::{Disc, Title, Stream, StreamKind, Codec, HdrFormat, ColorSpace, Extent};
|
||||||
|
|||||||
Reference in New Issue
Block a user