use serde::Serialize;
use serde_json::Value;
use crate::common::{
CompositionConfig, CompositionQuality, LayoutConfig, LayoutPriority, LayoutType, Orientation,
Theme,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CompositionLayout {
Grid,
Spotlight,
Sidebar,
Custom(String),
}
impl CompositionLayout {
pub fn custom(template_url: impl Into<String>) -> Self {
CompositionLayout::Custom(template_url.into())
}
fn layout_type(&self) -> Option<LayoutType> {
match self {
CompositionLayout::Grid => Some(LayoutType::Grid),
CompositionLayout::Spotlight => Some(LayoutType::Spotlight),
CompositionLayout::Sidebar => Some(LayoutType::Sidebar),
CompositionLayout::Custom(_) => None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Composition {
pub layout: Option<CompositionLayout>,
pub priority: Option<LayoutPriority>,
pub grid_size: Option<u32>,
pub orientation: Option<Orientation>,
pub theme: Option<Theme>,
pub quality: Option<CompositionQuality>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct MappedComposition {
pub(crate) config: Option<CompositionConfig>,
pub(crate) template_url: Option<String>,
}
pub(crate) fn composition_to_config(
composition: Option<&Composition>,
default_priority: Option<LayoutPriority>,
) -> MappedComposition {
let Some(composition) = composition else {
return MappedComposition::default();
};
let priority = || composition.priority.or(default_priority);
let mut config = CompositionConfig::default();
let mut template_url = None;
match &composition.layout {
Some(CompositionLayout::Custom(url)) => template_url = Some(url.clone()),
Some(named) => {
config.layout = Some(LayoutConfig {
kind: named.layout_type(),
priority: priority(),
grid_size: composition.grid_size,
})
}
None => {}
}
config.orientation = composition.orientation;
config.theme = composition.theme;
config.quality = composition.quality;
if !config.is_empty() && config.layout.is_none() {
config.layout = Some(LayoutConfig {
kind: Some(LayoutType::Grid),
priority: priority(),
grid_size: None,
});
}
MappedComposition {
config: (!config.is_empty()).then_some(config),
template_url,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EgressType {
Recording,
Composite,
Hls,
Livestream,
}
#[derive(Debug, Clone)]
pub struct EgressHandle {
pub kind: EgressType,
pub room_id: String,
pub id: Option<String>,
pub session_id: Option<String>,
pub raw: Option<Value>,
}
pub(crate) fn to_egress_handle(kind: EgressType, room_id: &str, raw: Value) -> EgressHandle {
let mut handle = EgressHandle {
kind,
room_id: room_id.to_string(),
id: None,
session_id: None,
raw: None,
};
if let Some(object) = raw.as_object() {
let string = |key: &str| object.get(key).and_then(Value::as_str).map(str::to_string);
handle.id = string("recordingId")
.or_else(|| string("id"))
.or_else(|| string("_id"));
handle.session_id = string("sessionId");
if let Some(room_id) = string("roomId") {
handle.room_id = room_id;
}
}
handle.raw = Some(raw);
handle
}
#[derive(Debug, Clone)]
pub struct StopTarget {
pub room_id: String,
pub id: Option<String>,
}
impl From<&str> for StopTarget {
fn from(room_id: &str) -> Self {
Self {
room_id: room_id.to_string(),
id: None,
}
}
}
impl From<String> for StopTarget {
fn from(room_id: String) -> Self {
Self { room_id, id: None }
}
}
impl From<EgressHandle> for StopTarget {
fn from(handle: EgressHandle) -> Self {
Self {
room_id: handle.room_id,
id: handle.id,
}
}
}
impl From<&EgressHandle> for StopTarget {
fn from(handle: &EgressHandle) -> Self {
Self {
room_id: handle.room_id.clone(),
id: handle.id.clone(),
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct StopWire<'a> {
room_id: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<&'a str>,
}
impl<'a> From<&'a StopTarget> for StopWire<'a> {
fn from(target: &'a StopTarget) -> Self {
Self {
room_id: &target.room_id,
id: target.id.as_deref().filter(|id| !id.is_empty()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn config_of(composition: Composition, default: Option<LayoutPriority>) -> Value {
let mapped = composition_to_config(Some(&composition), default);
json!({
"config": mapped.config.map(|c| serde_json::to_value(c).unwrap()),
"templateUrl": mapped.template_url,
})
}
#[test]
fn no_composition_maps_to_nothing() {
assert_eq!(
composition_to_config(None, None),
MappedComposition::default()
);
}
#[test]
fn an_empty_composition_maps_to_nothing() {
let mapped = composition_to_config(Some(&Composition::default()), None);
assert_eq!(mapped, MappedComposition::default());
}
#[test]
fn a_named_layout_is_uppercased() {
let mapped = config_of(
Composition {
layout: Some(CompositionLayout::Spotlight),
..Default::default()
},
None,
);
assert_eq!(mapped["config"], json!({"layout": {"type": "SPOTLIGHT"}}));
assert_eq!(mapped["templateUrl"], json!(null));
}
#[test]
fn a_custom_layout_becomes_a_sibling_template_url_not_a_config_layout() {
let mapped = config_of(
Composition {
layout: Some(CompositionLayout::custom("https://example.com/t.html")),
..Default::default()
},
None,
);
assert_eq!(mapped["config"], json!(null));
assert_eq!(mapped["templateUrl"], json!("https://example.com/t.html"));
}
#[test]
fn a_default_priority_applies_only_when_none_is_given() {
let mapped = config_of(
Composition {
layout: Some(CompositionLayout::Grid),
..Default::default()
},
Some(LayoutPriority::Speaker),
);
assert_eq!(mapped["config"]["layout"]["priority"], json!("SPEAKER"));
let mapped = config_of(
Composition {
layout: Some(CompositionLayout::Grid),
priority: Some(LayoutPriority::Pin),
..Default::default()
},
Some(LayoutPriority::Speaker),
);
assert_eq!(mapped["config"]["layout"]["priority"], json!("PIN"));
}
#[test]
fn recordings_send_no_priority_by_default() {
let mapped = config_of(
Composition {
layout: Some(CompositionLayout::Grid),
..Default::default()
},
None,
);
assert_eq!(mapped["config"], json!({"layout": {"type": "GRID"}}));
}
#[test]
fn grid_size_rides_along_with_a_named_layout() {
let mapped = config_of(
Composition {
layout: Some(CompositionLayout::Grid),
grid_size: Some(9),
..Default::default()
},
None,
);
assert_eq!(
mapped["config"]["layout"],
json!({"type": "GRID", "gridSize": 9})
);
}
#[test]
fn a_layoutless_config_gets_grid_injected() {
let mapped = config_of(
Composition {
quality: Some(CompositionQuality::High),
theme: Some(Theme::Dark),
..Default::default()
},
None,
);
assert_eq!(
mapped["config"],
json!({"layout": {"type": "GRID"}, "theme": "DARK", "quality": "high"})
);
}
#[test]
fn grid_injection_carries_the_resolved_priority_but_not_grid_size() {
let mapped = config_of(
Composition {
orientation: Some(Orientation::Portrait),
grid_size: Some(4),
..Default::default()
},
Some(LayoutPriority::Speaker),
);
assert_eq!(
mapped["config"]["layout"],
json!({"type": "GRID", "priority": "SPEAKER"})
);
}
#[test]
fn a_custom_template_with_other_options_still_gets_grid_injected() {
let mapped = config_of(
Composition {
layout: Some(CompositionLayout::custom("https://example.com/t.html")),
quality: Some(CompositionQuality::Low),
..Default::default()
},
None,
);
assert_eq!(
mapped["config"],
json!({"layout": {"type": "GRID"}, "quality": "low"})
);
assert_eq!(mapped["templateUrl"], json!("https://example.com/t.html"));
}
#[test]
fn a_string_start_response_yields_a_room_keyed_handle() {
let handle = to_egress_handle(EgressType::Hls, "r-1", json!("HLS started"));
assert_eq!(handle.room_id, "r-1");
assert!(handle.id.is_none());
assert_eq!(handle.raw, Some(json!("HLS started")));
}
#[test]
fn an_object_start_response_yields_ids_in_priority_order() {
let handle = to_egress_handle(
EgressType::Composite,
"r-1",
json!({"recordingId": "rec-1", "id": "other", "_id": "another", "sessionId": "s-1"}),
);
assert_eq!(handle.id.as_deref(), Some("rec-1"));
assert_eq!(handle.session_id.as_deref(), Some("s-1"));
let handle = to_egress_handle(EgressType::Hls, "r-1", json!({"id": "h-1"}));
assert_eq!(handle.id.as_deref(), Some("h-1"));
let handle = to_egress_handle(EgressType::Hls, "r-1", json!({"_id": "h-2"}));
assert_eq!(handle.id.as_deref(), Some("h-2"));
}
#[test]
fn a_start_response_room_id_overrides_the_requested_one() {
let handle = to_egress_handle(
EgressType::Recording,
"requested",
json!({"roomId": "real"}),
);
assert_eq!(handle.room_id, "real");
}
#[test]
fn stop_targets_accept_handles_and_bare_room_ids() {
let handle = to_egress_handle(EgressType::Recording, "r-1", json!({"id": "e-1"}));
let target: StopTarget = (&handle).into();
assert_eq!(target.room_id, "r-1");
assert_eq!(target.id.as_deref(), Some("e-1"));
let target: StopTarget = "r-2".into();
assert_eq!(target.room_id, "r-2");
assert!(target.id.is_none());
}
#[test]
fn stop_wire_omits_an_absent_or_empty_id() {
let target = StopTarget {
room_id: "r-1".into(),
id: None,
};
assert_eq!(
serde_json::to_value(StopWire::from(&target)).unwrap(),
json!({"roomId": "r-1"})
);
let target = StopTarget {
room_id: "r-1".into(),
id: Some(String::new()),
};
assert_eq!(
serde_json::to_value(StopWire::from(&target)).unwrap(),
json!({"roomId": "r-1"})
);
let target = StopTarget {
room_id: "r-1".into(),
id: Some("e-1".into()),
};
assert_eq!(
serde_json::to_value(StopWire::from(&target)).unwrap(),
json!({"roomId": "r-1", "id": "e-1"})
);
}
}