rsmediainfo 0.2.0

Rust wrapper for MediaInfo library
//! Track model: a single media stream parsed out of a media file.
//!
//! A [`Track`] holds a stream type (`General`, `Video`, `Audio`, `Text`,
//! `Other`, `Image`, or `Menu`) plus a dynamic, insertion-ordered map of
//! attributes that came out of the underlying library's XML output.

use indexmap::IndexMap;
use serde::{Deserialize, Serialize};

/// A single value stored on a [`Track`] attribute.
///
/// Attributes are typed at parse time:
///
/// - Most attributes are plain strings.
/// - When the same attribute appears more than once on a track and one of
///   the values is a parseable integer, the integer is promoted into the
///   primary slot as [`AttributeValue::Int`] and the other values are
///   moved into a sibling list under `other_<name>` as
///   [`AttributeValue::List`].
/// - Empty XML elements (e.g. `<Format/>`) are stored as
///   [`AttributeValue::Null`] so the key survives a round-trip through
///   [`Track::to_data`] / `to_json` while still reading as "missing"
///   through the typed accessors.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum AttributeValue {
    /// A textual value, the most common shape on most tracks.
    String(String),
    /// An integer value (typically used for durations, sizes, bit rates,
    /// and other quantities the library reports both as a number and as
    /// human-readable text).
    Int(i64),
    /// A list of alternative values, e.g. `other_duration`,
    /// `other_bit_rate`. Always paired with a non-list primary attribute
    /// of the same root name.
    List(Vec<String>),
    /// An explicit null marker recorded for empty XML elements.
    Null,
}

/// A track identifier in its original storage type.
///
/// Identifiers can be either strings or integers depending on what the
/// underlying library emitted. This enum lets callers branch on the
/// underlying type without losing information; see [`Track::track_id_any`]
/// for the typical access pattern.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrackId<'a> {
    /// Identifier stored as a string.
    String(&'a str),
    /// Identifier stored as an integer.
    Int(i64),
}

impl AttributeValue {
    /// Returns the value as a string slice if it is a [`String`](Self::String).
    ///
    /// Returns `None` for any other variant, including a numeric value
    /// stored as [`Int`](Self::Int).
    pub fn as_str(&self) -> Option<&str> {
        match self {
            AttributeValue::String(s) => Some(s.as_str()),
            _ => None,
        }
    }

    /// Returns the value as an `i64` if it is an integer or a string that
    /// parses cleanly as one.
    ///
    /// This is the right accessor when you need numeric data without
    /// caring about the underlying storage form. Returns `None` for
    /// [`List`](Self::List) and [`Null`](Self::Null) values, and for
    /// strings that don't parse as an integer.
    pub fn as_int(&self) -> Option<i64> {
        match self {
            AttributeValue::Int(i) => Some(*i),
            AttributeValue::String(s) => s.parse::<i64>().ok(),
            _ => None,
        }
    }

    /// Returns the value as a list reference if it is a [`List`](Self::List).
    ///
    /// Returns `None` for any other variant.
    pub fn as_list(&self) -> Option<&Vec<String>> {
        match self {
            AttributeValue::List(l) => Some(l),
            _ => None,
        }
    }
}

/// A media track with a type and a dynamic set of attributes.
///
/// Each [`Track`] corresponds to one stream the underlying library reported
/// when parsing a media file. Attributes are stored in an
/// insertion-ordered map so the order they appeared in the source XML
/// survives through [`Track::to_data`] and JSON serialization.
///
/// # Attribute name normalization
///
/// Attribute names are normalized to lowercase, with surrounding
/// whitespace and underscores stripped. Interior underscores are
/// preserved, so `Stream_Size` becomes `stream_size`, `_Format_` becomes
/// `format`, and `  Duration  ` becomes `duration`. The single special
/// case is the bare key `id`, which is rewritten to `track_id` so callers
/// can address it consistently across track types.
///
/// # Repeated attributes
///
/// When the same attribute name appears more than once on a track:
///
/// 1. The first value is stored under the canonical key.
/// 2. Subsequent values are appended to a sibling list under
///    `other_<name>`.
/// 3. After the track is fully parsed, if any of the values can be parsed
///    as an integer, the integer is promoted into the primary slot and
///    the original primary string is appended to the end of the
///    `other_<name>` list. The `other_<name>` list itself is preserved
///    in encounter order.
///
/// This means a `<Duration>` element that appears as `3000`, then
/// `"3 s 0 ms"`, then `"00:00:03.000"` ends up exposed as
/// `duration = 3000` (integer) and
/// `other_duration = ["3 s 0 ms", "00:00:03.000"]`.
///
/// # Attribute access
///
/// Use the typed `get*` accessors for the common shapes:
///
/// ```rust
/// # use rsmediainfo::Track;
/// # let mut track = Track::new("General".to_string());
/// # track.set_string("format".to_string(), "MKV".to_string());
/// # track.set_int("duration".to_string(), 3000);
/// # track.set_list("other_duration".to_string(), vec!["3 s 0 ms".to_string()]);
/// if let Some(format) = track.get_string("format") {
///     println!("Format: {format}");
/// }
///
/// if let Some(duration) = track.get_int("duration") {
///     println!("Duration: {duration} ms");
/// }
///
/// if let Some(other_durations) = track.get_list("other_duration") {
///     for d in other_durations {
///         println!("  {d}");
///     }
/// }
/// ```
///
/// For track identifiers that may be either strings or integers, use
/// [`Track::track_id_any`], [`Track::track_id_int`], or
/// [`Track::track_id_value`] to keep the underlying type intact.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Track {
    /// The track type as reported by the source XML, e.g. `"General"`,
    /// `"Video"`, `"Audio"`, `"Text"`, `"Other"`, `"Image"`, `"Menu"`.
    /// Compared verbatim by the track-shortcut accessors on `MediaInfo`.
    pub track_type: String,

    /// All track attributes stored as key-value pairs. The track
    /// identifier (when present) lives here under the `track_id` key just
    /// like any other attribute, so there is exactly one source of truth
    /// for the value and for its underlying type (string or integer).
    ///
    /// Storage is insertion-ordered so the original order from the
    /// parsed source survives through [`Track::to_data`] / `to_json`.
    #[serde(flatten)]
    attributes: IndexMap<String, AttributeValue>,
}

impl Track {
    /// Creates a new track of the given type with no attributes set.
    pub fn new(track_type: String) -> Self {
        Track {
            track_type,
            attributes: IndexMap::new(),
        }
    }

    /// Returns the track type as a string slice.
    pub fn track_type(&self) -> &str {
        &self.track_type
    }

    /// Returns the track identifier as a string slice when it is stored
    /// as a string.
    ///
    /// Returns `None` when the identifier is missing or stored as an
    /// integer; use [`Track::track_id_int`] or [`Track::track_id_any`]
    /// for the integer case.
    pub fn track_id(&self) -> Option<&str> {
        match self.track_id_any() {
            Some(TrackId::String(value)) => Some(value),
            _ => None,
        }
    }

    /// Returns the track identifier as an `i64`, parsing a string-typed
    /// identifier on the fly when needed.
    ///
    /// Returns `None` only when the identifier is missing or is a string
    /// that does not parse as an integer.
    pub fn track_id_int(&self) -> Option<i64> {
        match self.track_id_any() {
            Some(TrackId::Int(value)) => Some(value),
            Some(TrackId::String(value)) => value.parse::<i64>().ok(),
            None => None,
        }
    }

    /// Returns the raw stored value of the `track_id` attribute, if any.
    ///
    /// This is the most direct accessor and the right choice when you
    /// need to inspect or pattern-match the underlying [`AttributeValue`].
    pub fn track_id_value(&self) -> Option<&AttributeValue> {
        self.get("track_id")
    }

    /// Returns the track identifier wrapped in a [`TrackId`] enum that
    /// preserves the underlying string-vs-integer distinction.
    ///
    /// This is the recommended accessor when you do not know in advance
    /// which storage shape the underlying library used.
    pub fn track_id_any(&self) -> Option<TrackId<'_>> {
        match self.attributes.get("track_id") {
            Some(AttributeValue::String(value)) => Some(TrackId::String(value.as_str())),
            Some(AttributeValue::Int(value)) => Some(TrackId::Int(*value)),
            Some(AttributeValue::List(values)) => {
                values.first().map(|value| TrackId::String(value.as_str()))
            }
            Some(AttributeValue::Null) | None => None,
        }
    }

    /// Returns a reference to the raw [`AttributeValue`] for `key`, or
    /// `None` if the attribute is missing or set to
    /// [`AttributeValue::Null`].
    ///
    /// The null aliasing means callers can treat empty XML elements as
    /// missing without having to special-case them, while
    /// [`Track::to_data`] still records the key explicitly so the
    /// information is preserved across round-trips.
    pub fn get(&self, key: &str) -> Option<&AttributeValue> {
        match self.attributes.get(key) {
            Some(AttributeValue::Null) => None,
            other => other,
        }
    }

    /// Returns the attribute as an `i64` if it is integer-shaped.
    ///
    /// Mirrors [`AttributeValue::as_int`]: it accepts a stored
    /// [`AttributeValue::Int`] or a [`AttributeValue::String`] that
    /// parses cleanly as an integer, and returns `None` otherwise.
    pub fn get_int(&self, key: &str) -> Option<i64> {
        self.get(key).and_then(|v| v.as_int())
    }

    /// Returns the attribute as a string slice if it is stored as one.
    ///
    /// Returns `None` for missing keys, null values, integer values, and
    /// list values.
    pub fn get_string(&self, key: &str) -> Option<&str> {
        self.get(key).and_then(|v| v.as_str())
    }

    /// Returns the attribute as a list reference if it is stored as one.
    ///
    /// This is the right accessor for the `other_<name>` sibling lists
    /// that hold alternative formats of repeated attributes.
    pub fn get_list(&self, key: &str) -> Option<&Vec<String>> {
        self.get(key).and_then(|v| v.as_list())
    }

    /// Inserts (or replaces) an attribute with the supplied value.
    pub fn set(&mut self, key: String, value: AttributeValue) {
        self.attributes.insert(key, value);
    }

    /// Inserts (or replaces) a string attribute.
    pub fn set_string(&mut self, key: String, value: String) {
        self.attributes.insert(key, AttributeValue::String(value));
    }

    /// Inserts (or replaces) an integer attribute.
    pub fn set_int(&mut self, key: String, value: i64) {
        self.attributes.insert(key, AttributeValue::Int(value));
    }

    /// Inserts (or replaces) a list attribute.
    pub fn set_list(&mut self, key: String, value: Vec<String>) {
        self.attributes.insert(key, AttributeValue::List(value));
    }

    /// Inserts (or replaces) an attribute with [`AttributeValue::Null`].
    pub fn set_null(&mut self, key: String) {
        self.attributes.insert(key, AttributeValue::Null);
    }

    /// Returns a reference to the underlying attribute map.
    ///
    /// The map is insertion-ordered so iteration walks attributes in the
    /// order they were first encountered during parsing.
    pub fn attributes(&self) -> &IndexMap<String, AttributeValue> {
        &self.attributes
    }

    /// Returns a mutable reference to the underlying attribute map.
    pub fn attributes_mut(&mut self) -> &mut IndexMap<String, AttributeValue> {
        &mut self.attributes
    }

    /// Renders the track as a flat JSON object.
    ///
    /// The returned [`serde_json::Map`] always contains a `track_type`
    /// entry first, followed by every attribute in storage order. Each
    /// value is rendered using its most natural JSON shape:
    ///
    /// - [`AttributeValue::String`] → `Value::String`
    /// - [`AttributeValue::Int`] → `Value::Number`
    /// - [`AttributeValue::List`] → `Value::Array` of strings
    /// - [`AttributeValue::Null`] → `Value::Null`
    pub fn to_data(&self) -> serde_json::Map<String, serde_json::Value> {
        let mut data = serde_json::Map::new();

        data.insert(
            "track_type".to_string(),
            serde_json::Value::String(self.track_type.clone()),
        );

        for (key, value) in &self.attributes {
            let json_value = match value {
                AttributeValue::String(s) => serde_json::Value::String(s.clone()),
                AttributeValue::Int(i) => serde_json::Value::Number((*i).into()),
                AttributeValue::List(l) => serde_json::Value::Array(
                    l.iter()
                        .map(|s| serde_json::Value::String(s.clone()))
                        .collect(),
                ),
                AttributeValue::Null => serde_json::Value::Null,
            };
            data.insert(key.clone(), json_value);
        }

        data
    }
}

impl std::fmt::Display for Track {
    /// Renders the track in a stable, debuggable single-line form:
    /// `<Track track_id='…', track_type='…'>`. When the track has no
    /// identifier the `track_id` slot reads `'None'`.
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let track_id_display = match self.attributes.get("track_id") {
            Some(AttributeValue::String(s)) => s.clone(),
            Some(AttributeValue::Int(i)) => i.to_string(),
            Some(AttributeValue::List(list)) => {
                list.first().cloned().unwrap_or_else(|| "None".to_string())
            }
            Some(AttributeValue::Null) | None => "None".to_string(),
        };
        write!(
            f,
            "<Track track_id='{}', track_type='{}'>",
            track_id_display, self.track_type
        )
    }
}