mod format;
pub use format::*;
use std::collections::{BTreeMap, btree_map};
use serde::{Deserialize, Serialize};
use serde_with::{DisplayFromStr, DurationMilliSeconds};
use crate::catalog::Container;
#[serde_with::serde_as]
#[serde_with::skip_serializing_none]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct Text {
pub renditions: BTreeMap<String, TextConfig>,
}
impl crate::catalog::Section for Text {
const MAP: &'static str = "renditions";
}
impl Text {
pub fn insert(&mut self, name: &str, config: TextConfig) -> crate::Result<()> {
let btree_map::Entry::Vacant(entry) = self.renditions.entry(name.to_string()) else {
return Err(crate::Error::Duplicate(name.to_string()));
};
entry.insert(config);
Ok(())
}
pub fn remove(&mut self, name: &str) -> Option<TextConfig> {
self.renditions.remove(name)
}
pub fn is_empty(&self) -> bool {
self.renditions.is_empty()
}
}
#[serde_with::serde_as]
#[serde_with::skip_serializing_none]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct TextConfig {
#[serde(default)]
pub broadcast: Option<moq_net::path::RelativeOwned>,
#[serde_as(as = "DisplayFromStr")]
pub format: TextFormat,
#[serde_as(as = "DisplayFromStr")]
#[serde(default)]
pub role: TextRole,
pub lang: Option<String>,
pub label: Option<String>,
#[serde(default)]
pub container: Container,
#[serde_as(as = "Option<DurationMilliSeconds<u64>>")]
#[serde(default)]
pub jitter: Option<std::time::Duration>,
}
impl TextConfig {
pub fn new(format: TextFormat) -> Self {
Self {
broadcast: None,
format,
role: TextRole::default(),
lang: None,
label: None,
container: Container::default(),
jitter: None,
}
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn roundtrip() {
let mut encoded = r#"{
"renditions": {
"captions.en": {
"format": "vtt",
"role": "caption",
"lang": "en",
"label": "SDH",
"container": {"kind": "legacy"}
}
}
}"#
.to_string();
encoded.retain(|c| !c.is_whitespace());
let mut config = TextConfig::new(TextFormat::Vtt);
config.role = TextRole::Caption;
config.lang = Some("en".to_string());
config.label = Some("SDH".to_string());
config.container = Container::Legacy;
let mut text = Text::default();
text.insert("captions.en", config).unwrap();
let decoded: Text = serde_json::from_str(&encoded).expect("failed to decode");
assert_eq!(decoded, text, "wrong decoded output");
let output = serde_json::to_string(&text).expect("failed to encode");
assert_eq!(encoded, output, "wrong encoded output");
}
#[test]
fn optionals_omitted() {
let mut text = Text::default();
text.insert("subs", TextConfig::new(TextFormat::Vtt)).unwrap();
let output = serde_json::to_string(&text).unwrap();
assert!(!output.contains("lang"), "empty lang leaked: {output}");
assert!(!output.contains("label"), "empty label leaked: {output}");
assert!(!output.contains("broadcast"), "empty broadcast leaked: {output}");
let decoded: Text = serde_json::from_str(&output).unwrap();
assert_eq!(decoded.renditions["subs"].role, TextRole::Subtitle);
}
#[test]
fn unknown_role_preserved() {
let encoded = r#"{"renditions":{"subs":{"format":"vtt","role":"commentary","container":{"kind":"legacy"}}}}"#;
let decoded: Text = serde_json::from_str(encoded).expect("failed to decode");
assert_eq!(
decoded.renditions["subs"].role,
TextRole::Unknown("commentary".to_string())
);
let output = serde_json::to_string(&decoded).expect("failed to encode");
assert_eq!(encoded, output, "unknown role not preserved");
}
}