chdlady-disc 0.2.2

Disc geometries and physical media formats for chdlady
//! CHD track metadata parsing and formatting for disc media.
use crate::error::DiscError;
use crate::types::{
    CdSubcodeType, CdToc, CdTrack, CdTrackType, DiscType, CDROM_OLD_METADATA_TAG,
    CDROM_TRACK_METADATA2_TAG, CDROM_TRACK_METADATA_TAG, GDROM_OLD_METADATA_TAG,
    GDROM_TRACK_METADATA_TAG,
};
use chdlady_core::{MetadataEntry, CHD_MDFLAGS_CHECKSUM};

/// Parses a track metadata string into a `CdTrack`.
pub fn parse_track_metadata(s: &str) -> Result<CdTrack, DiscError> {
    let clean = s.trim().trim_matches('\0');
    let mut track_num: Option<u32> = None;
    let mut track_type: Option<CdTrackType> = None;
    let mut subcode_type = CdSubcodeType::None;
    let mut frames: Option<u32> = None;
    let mut pad_frames: u32 = 0;
    let mut pregap_frames: u32 = 0;
    let mut pgtype: Option<CdTrackType> = None;
    let mut pgsub: Option<CdSubcodeType> = None;
    let mut postgap_frames: u32 = 0;

    for token in clean.split_whitespace() {
        if let Some((key, val)) = token.split_once(':') {
            match key {
                "TRACK" => {
                    track_num = Some(val.parse::<u32>().map_err(|_| {
                        DiscError::InvalidMetadata(format!("invalid TRACK number in '{}'", s))
                    })?);
                }
                "TYPE" => {
                    track_type = Some(CdTrackType::parse_cue_or_metadata(val)?);
                }
                "SUBTYPE" => {
                    subcode_type = CdSubcodeType::parse(val)?;
                }
                "FRAMES" => {
                    frames = Some(val.parse::<u32>().map_err(|_| {
                        DiscError::InvalidMetadata(format!("invalid FRAMES count in '{}'", s))
                    })?);
                }
                "PAD" => {
                    pad_frames = val.parse::<u32>().map_err(|_| {
                        DiscError::InvalidMetadata(format!("invalid PAD count in '{}'", s))
                    })?;
                }
                "PREGAP" => {
                    pregap_frames = val.parse::<u32>().map_err(|_| {
                        DiscError::InvalidMetadata(format!("invalid PREGAP count in '{}'", s))
                    })?;
                }
                "PGTYPE" => {
                    pgtype = Some(CdTrackType::parse_cue_or_metadata(val)?);
                }
                "PGSUB" => {
                    pgsub = Some(CdSubcodeType::parse(val)?);
                }
                "POSTGAP" => {
                    postgap_frames = val.parse::<u32>().map_err(|_| {
                        DiscError::InvalidMetadata(format!("invalid POSTGAP count in '{}'", s))
                    })?;
                }
                _ => {}
            }
        }
    }

    let track_num = track_num
        .ok_or_else(|| DiscError::InvalidMetadata(format!("missing TRACK number in '{}'", s)))?;
    let track_type =
        track_type.ok_or_else(|| DiscError::InvalidMetadata(format!("missing TYPE in '{}'", s)))?;
    let frames =
        frames.ok_or_else(|| DiscError::InvalidMetadata(format!("missing FRAMES in '{}'", s)))?;

    Ok(CdTrack {
        track_num,
        track_type,
        subcode_type,
        frames,
        pad_frames,
        pregap_frames,
        pregap_type: pgtype,
        pregap_subcode: pgsub,
        postgap_frames,
        file_name: None,
        file_offset: 0,
        sub_file_name: None,
        sub_file_offset: 0,
    })
}

/// Formats a `CdTrack` into the standard CHD metadata string with trailing null byte.
pub fn format_track_metadata(track: &CdTrack, disc_type: DiscType) -> Vec<u8> {
    let type_str = track.track_type.chd_metadata_type_str();
    let sub_str = track.subcode_type.as_str();
    let pg_type_str = track
        .pregap_type
        .map(|t| t.chd_metadata_type_str())
        .unwrap_or(if track.track_type.is_audio() {
            "VAUDIO"
        } else {
            type_str
        });
    let pg_sub_str = track.pregap_subcode.map(|s| s.as_str()).unwrap_or(sub_str);

    let text = match disc_type {
        DiscType::GdRom => {
            format!(
                "TRACK:{} TYPE:{} SUBTYPE:{} FRAMES:{} PAD:{} PREGAP:{} PGTYPE:{} PGSUB:{} POSTGAP:{}",
                track.track_num,
                type_str,
                sub_str,
                track.frames,
                track.pad_frames,
                track.pregap_frames,
                pg_type_str,
                pg_sub_str,
                track.postgap_frames
            )
        }
        DiscType::Cd => {
            format!(
                "TRACK:{} TYPE:{} SUBTYPE:{} FRAMES:{} PREGAP:{} PGTYPE:{} PGSUB:{} POSTGAP:{}",
                track.track_num,
                type_str,
                sub_str,
                track.frames,
                track.pregap_frames,
                pg_type_str,
                pg_sub_str,
                track.postgap_frames
            )
        }
    };

    let mut bytes = text.into_bytes();
    bytes.push(0); // Trailing null character
    bytes
}

/// Converts a slice of CHD `MetadataEntry` structs into a `CdToc`.
pub fn parse_toc_from_metadata(entries: &[MetadataEntry]) -> Result<CdToc, DiscError> {
    let mut tracks = Vec::new();
    let mut disc_type = DiscType::Cd;

    for entry in entries {
        if entry.metatag == GDROM_TRACK_METADATA_TAG || entry.metatag == GDROM_OLD_METADATA_TAG {
            disc_type = DiscType::GdRom;
            let text = String::from_utf8_lossy(&entry.value);
            tracks.push(parse_track_metadata(&text)?);
        } else if entry.metatag == CDROM_TRACK_METADATA2_TAG
            || entry.metatag == CDROM_TRACK_METADATA_TAG
            || entry.metatag == CDROM_OLD_METADATA_TAG
        {
            let text = String::from_utf8_lossy(&entry.value);
            tracks.push(parse_track_metadata(&text)?);
        }
    }

    if tracks.is_empty() {
        return Err(DiscError::InvalidMetadata(
            "no optical disc track metadata found in CHD container".to_string(),
        ));
    }

    tracks.sort_by_key(|t| t.track_num);
    Ok(CdToc { disc_type, tracks })
}

/// Converts a `CdToc` into a sequence of checksummed `MetadataEntry` items.
pub fn create_metadata_from_toc(toc: &CdToc) -> Vec<MetadataEntry> {
    let tag = match toc.disc_type {
        DiscType::Cd => CDROM_TRACK_METADATA2_TAG,
        DiscType::GdRom => GDROM_TRACK_METADATA_TAG,
    };

    toc.tracks
        .iter()
        .map(|track| {
            let value = format_track_metadata(track, toc.disc_type);
            MetadataEntry {
                metatag: tag,
                flags: CHD_MDFLAGS_CHECKSUM,
                offset: 0,
                next: 0,
                value,
            }
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_metadata_parse_cd() {
        let meta = "TRACK:1 TYPE:MODE1_RAW SUBTYPE:NONE FRAMES:4 PREGAP:0 PGTYPE:MODE1 PGSUB:NONE POSTGAP:0\0";
        let track = parse_track_metadata(meta).expect("parse metadata");
        assert_eq!(track.track_num, 1);
        assert_eq!(track.track_type, CdTrackType::Mode1Raw);
        assert_eq!(track.frames, 4);
        assert_eq!(track.pad_frames, 0);
        assert_eq!(track.pregap_frames, 0);

        let formatted = format_track_metadata(&track, DiscType::Cd);
        let s = String::from_utf8_lossy(&formatted);
        assert_eq!(s, meta);
    }

    #[test]
    fn test_metadata_parse_gdrom() {
        let meta = "TRACK:2 TYPE:AUDIO SUBTYPE:NONE FRAMES:44996 PAD:44992 PREGAP:0 PGTYPE:MODE1 PGSUB:NONE POSTGAP:0\0";
        let track = parse_track_metadata(meta).expect("parse metadata");
        assert_eq!(track.track_num, 2);
        assert_eq!(track.track_type, CdTrackType::Audio);
        assert_eq!(track.frames, 44996);
        assert_eq!(track.pad_frames, 44992);

        let formatted = format_track_metadata(&track, DiscType::GdRom);
        let s = String::from_utf8_lossy(&formatted);
        assert_eq!(s, meta);
    }

    #[test]
    fn test_metadata_parse_v_prefixed_types() {
        let meta = "TRACK:2 TYPE:MODE1_RAW SUBTYPE:NONE FRAMES:8634 PREGAP:231 PGTYPE:VMODE1_RAW PGSUB:NONE POSTGAP:0\0";
        let track = parse_track_metadata(meta).expect("parse metadata with VMODE1_RAW");
        assert_eq!(track.track_num, 2);
        assert_eq!(track.track_type, CdTrackType::Mode1Raw);
        assert_eq!(track.frames, 8634);
        assert_eq!(track.pregap_frames, 231);
        assert_eq!(track.pregap_type, Some(CdTrackType::Mode1Raw));
    }
}