Skip to main content

oxideav_core/
metadata.rs

1//! Structured container metadata.
2//!
3//! Today most demuxers expose chapters and attachments as flat
4//! [`Demuxer::metadata`](crate::Demuxer::metadata) entries — strings
5//! like `chapter:0:start_ms` / `attachment:2:filename`. Those keep
6//! working, but consumers that want to iterate chapters or pull an
7//! attachment's payload should use the structured
8//! [`Demuxer::chapters`](crate::Demuxer::chapters) and
9//! [`Demuxer::attachments`](crate::Demuxer::attachments) accessors
10//! instead. Both default to an empty slice on the trait, so demuxers
11//! that don't carry such data — and demuxers that haven't been ported
12//! to the structured API yet — keep compiling unchanged.
13//!
14//! The two structs are deliberately container-agnostic. They cover the
15//! intersection of MKV (`Chapters` / `Attachments` master elements),
16//! MP4 (chapter track + `iTunSMPB`-style chapter atoms; `meta`/`covr`
17//! adjacent for attachments), Ogg (`CHAPTERnn=…` Vorbis comments),
18//! and DVD/Blu-ray IFO chapter tables.
19
20use crate::time::Timestamp;
21
22/// One chapter / cue point inside a container.
23///
24/// Containers that only carry a start time (Vorbis-comment chapters,
25/// DVD IFO PGCs without explicit end times) set `end == start`. The
26/// `id` field is whatever the container uses internally — MKV's
27/// `ChapterUID`, MP4 chapter track sample index, or a synthesised
28/// counter for formats without a stable ID.
29#[derive(Clone, Debug, PartialEq)]
30pub struct Chapter {
31    /// Container-native chapter identifier. Stable across demuxer
32    /// re-opens of the same file but **not** comparable across
33    /// different containers.
34    pub id: u64,
35    /// Chapter start time. The [`Timestamp`]'s time base is whatever
36    /// the demuxer reports; consumers should
37    /// [`rescale`](Timestamp::rescale) to a common base before
38    /// comparing chapters from different sources.
39    pub start: Timestamp,
40    /// Chapter end time. Equal to `start` when the container does not
41    /// store an explicit end (the next chapter's start is the
42    /// implicit end in that case).
43    pub end: Timestamp,
44    /// Display title in the chapter's primary language, if present.
45    pub title: Option<String>,
46    /// BCP-47 / ISO 639 language tag for the title (`"en"`, `"jpn"`,
47    /// …) when the container labels it. `None` means "unspecified" —
48    /// not "neutral".
49    pub language: Option<String>,
50}
51
52/// One file-shaped payload attached to a container.
53///
54/// Distinct from [`AttachedPicture`](crate::AttachedPicture): an
55/// `Attachment` is an arbitrary byte blob with a filename — fonts
56/// (MKV `application/x-truetype-font`), thumbnail strips, subtitle
57/// fragments, README text — whereas `AttachedPicture` is the
58/// ID3v2/FLAC/MP4 cover-art pathway that carries a typed
59/// [`PictureType`](crate::PictureType). MKV `Attachments` map
60/// cleanly onto this struct; MP4 `meta` boxes and Ogg
61/// `METADATA_BLOCK_PICTURE` are emitted as
62/// [`attached_pictures`](crate::Demuxer::attached_pictures) instead.
63#[derive(Clone, Debug, PartialEq)]
64pub struct Attachment {
65    /// Original filename as stored in the container (no path
66    /// stripping, no normalisation). Containers that don't track a
67    /// name still populate this — synthesise something stable like
68    /// `attachment_<id>.bin` so callers always have a routing handle.
69    pub name: String,
70    /// IANA media type (`"image/png"`, `"application/x-truetype-font"`,
71    /// …) when the container declares one. `None` means "unspecified"
72    /// — callers are free to sniff the bytes.
73    pub mime: Option<String>,
74    /// Free-form description supplied by the tagger.
75    pub description: Option<String>,
76    /// Raw attachment bytes exactly as stored in the container.
77    pub data: Vec<u8>,
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use crate::time::TimeBase;
84
85    #[test]
86    fn chapter_clone_eq_round_trip() {
87        let base = TimeBase::new(1, 1000);
88        let c = Chapter {
89            id: 42,
90            start: Timestamp::new(0, base),
91            end: Timestamp::new(5_000, base),
92            title: Some("Intro".into()),
93            language: Some("en".into()),
94        };
95        let c2 = c.clone();
96        assert_eq!(c, c2);
97        assert_eq!(c.id, 42);
98        assert_eq!(c.start.value, 0);
99        assert_eq!(c.end.value, 5_000);
100        assert_eq!(c.title.as_deref(), Some("Intro"));
101        assert_eq!(c.language.as_deref(), Some("en"));
102    }
103
104    #[test]
105    fn chapter_optional_fields_default_to_none() {
106        let base = TimeBase::new(1, 1);
107        let c = Chapter {
108            id: 1,
109            start: Timestamp::new(0, base),
110            end: Timestamp::new(0, base),
111            title: None,
112            language: None,
113        };
114        assert!(c.title.is_none());
115        assert!(c.language.is_none());
116        // Containers without an explicit end time set end == start.
117        assert_eq!(c.start, c.end);
118    }
119
120    #[test]
121    fn attachment_clone_eq_round_trip() {
122        let a = Attachment {
123            name: "cover.png".into(),
124            mime: Some("image/png".into()),
125            description: Some("Album front".into()),
126            data: vec![0x89, b'P', b'N', b'G'],
127        };
128        let a2 = a.clone();
129        assert_eq!(a, a2);
130        assert_eq!(a.name, "cover.png");
131        assert_eq!(a.mime.as_deref(), Some("image/png"));
132        assert_eq!(a.description.as_deref(), Some("Album front"));
133        assert_eq!(a.data, vec![0x89, b'P', b'N', b'G']);
134    }
135
136    #[test]
137    fn attachment_optional_fields_default_to_none() {
138        let a = Attachment {
139            name: "blob.bin".into(),
140            mime: None,
141            description: None,
142            data: Vec::new(),
143        };
144        assert!(a.mime.is_none());
145        assert!(a.description.is_none());
146        assert!(a.data.is_empty());
147    }
148}