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}