1use serde::Serialize;
5use serde_json::Value;
6
7use crate::common::{
8 CompositionConfig, CompositionQuality, LayoutConfig, LayoutPriority, LayoutType, Orientation,
9 Theme,
10};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum CompositionLayout {
15 Grid,
17 Spotlight,
19 Sidebar,
21 Custom(String),
23}
24
25impl CompositionLayout {
26 pub fn custom(template_url: impl Into<String>) -> Self {
28 CompositionLayout::Custom(template_url.into())
29 }
30
31 fn layout_type(&self) -> Option<LayoutType> {
33 match self {
34 CompositionLayout::Grid => Some(LayoutType::Grid),
35 CompositionLayout::Spotlight => Some(LayoutType::Spotlight),
36 CompositionLayout::Sidebar => Some(LayoutType::Sidebar),
37 CompositionLayout::Custom(_) => None,
38 }
39 }
40}
41
42#[derive(Debug, Clone, Default)]
49pub struct Composition {
50 pub layout: Option<CompositionLayout>,
52 pub priority: Option<LayoutPriority>,
55 pub grid_size: Option<u32>,
57 pub orientation: Option<Orientation>,
59 pub theme: Option<Theme>,
61 pub quality: Option<CompositionQuality>,
63}
64
65#[derive(Debug, Clone, Default, PartialEq, Eq)]
67pub(crate) struct MappedComposition {
68 pub(crate) config: Option<CompositionConfig>,
69 pub(crate) template_url: Option<String>,
70}
71
72pub(crate) fn composition_to_config(
77 composition: Option<&Composition>,
78 default_priority: Option<LayoutPriority>,
79) -> MappedComposition {
80 let Some(composition) = composition else {
81 return MappedComposition::default();
82 };
83
84 let priority = || composition.priority.or(default_priority);
85 let mut config = CompositionConfig::default();
86 let mut template_url = None;
87
88 match &composition.layout {
89 Some(CompositionLayout::Custom(url)) => template_url = Some(url.clone()),
90 Some(named) => {
91 config.layout = Some(LayoutConfig {
92 kind: named.layout_type(),
93 priority: priority(),
94 grid_size: composition.grid_size,
95 })
96 }
97 None => {}
98 }
99
100 config.orientation = composition.orientation;
101 config.theme = composition.theme;
102 config.quality = composition.quality;
103
104 if !config.is_empty() && config.layout.is_none() {
110 config.layout = Some(LayoutConfig {
111 kind: Some(LayoutType::Grid),
112 priority: priority(),
113 grid_size: None,
114 });
115 }
116
117 MappedComposition {
118 config: (!config.is_empty()).then_some(config),
119 template_url,
120 }
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum EgressType {
128 Recording,
130 Composite,
132 Hls,
134 Livestream,
136}
137
138#[derive(Debug, Clone)]
146pub struct EgressHandle {
147 pub kind: EgressType,
149 pub room_id: String,
151 pub id: Option<String>,
153 pub session_id: Option<String>,
155 pub raw: Option<Value>,
157}
158
159pub(crate) fn to_egress_handle(kind: EgressType, room_id: &str, raw: Value) -> EgressHandle {
162 let mut handle = EgressHandle {
163 kind,
164 room_id: room_id.to_string(),
165 id: None,
166 session_id: None,
167 raw: None,
168 };
169
170 if let Some(object) = raw.as_object() {
171 let string = |key: &str| object.get(key).and_then(Value::as_str).map(str::to_string);
172 handle.id = string("recordingId")
173 .or_else(|| string("id"))
174 .or_else(|| string("_id"));
175 handle.session_id = string("sessionId");
176 if let Some(room_id) = string("roomId") {
177 handle.room_id = room_id;
178 }
179 }
180
181 handle.raw = Some(raw);
182 handle
183}
184
185#[derive(Debug, Clone)]
195pub struct StopTarget {
196 pub room_id: String,
198 pub id: Option<String>,
200}
201
202impl From<&str> for StopTarget {
203 fn from(room_id: &str) -> Self {
204 Self {
205 room_id: room_id.to_string(),
206 id: None,
207 }
208 }
209}
210
211impl From<String> for StopTarget {
212 fn from(room_id: String) -> Self {
213 Self { room_id, id: None }
214 }
215}
216
217impl From<EgressHandle> for StopTarget {
218 fn from(handle: EgressHandle) -> Self {
219 Self {
220 room_id: handle.room_id,
221 id: handle.id,
222 }
223 }
224}
225
226impl From<&EgressHandle> for StopTarget {
227 fn from(handle: &EgressHandle) -> Self {
228 Self {
229 room_id: handle.room_id.clone(),
230 id: handle.id.clone(),
231 }
232 }
233}
234
235#[derive(Debug, Serialize)]
237#[serde(rename_all = "camelCase")]
238pub(crate) struct StopWire<'a> {
239 room_id: &'a str,
240 #[serde(skip_serializing_if = "Option::is_none")]
241 id: Option<&'a str>,
242}
243
244impl<'a> From<&'a StopTarget> for StopWire<'a> {
245 fn from(target: &'a StopTarget) -> Self {
246 Self {
247 room_id: &target.room_id,
248 id: target.id.as_deref().filter(|id| !id.is_empty()),
249 }
250 }
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256 use serde_json::json;
257
258 fn config_of(composition: Composition, default: Option<LayoutPriority>) -> Value {
259 let mapped = composition_to_config(Some(&composition), default);
260 json!({
261 "config": mapped.config.map(|c| serde_json::to_value(c).unwrap()),
262 "templateUrl": mapped.template_url,
263 })
264 }
265
266 #[test]
267 fn no_composition_maps_to_nothing() {
268 assert_eq!(
269 composition_to_config(None, None),
270 MappedComposition::default()
271 );
272 }
273
274 #[test]
275 fn an_empty_composition_maps_to_nothing() {
276 let mapped = composition_to_config(Some(&Composition::default()), None);
277 assert_eq!(mapped, MappedComposition::default());
278 }
279
280 #[test]
281 fn a_named_layout_is_uppercased() {
282 let mapped = config_of(
283 Composition {
284 layout: Some(CompositionLayout::Spotlight),
285 ..Default::default()
286 },
287 None,
288 );
289 assert_eq!(mapped["config"], json!({"layout": {"type": "SPOTLIGHT"}}));
290 assert_eq!(mapped["templateUrl"], json!(null));
291 }
292
293 #[test]
294 fn a_custom_layout_becomes_a_sibling_template_url_not_a_config_layout() {
295 let mapped = config_of(
296 Composition {
297 layout: Some(CompositionLayout::custom("https://example.com/t.html")),
298 ..Default::default()
299 },
300 None,
301 );
302 assert_eq!(mapped["config"], json!(null));
303 assert_eq!(mapped["templateUrl"], json!("https://example.com/t.html"));
304 }
305
306 #[test]
307 fn a_default_priority_applies_only_when_none_is_given() {
308 let mapped = config_of(
309 Composition {
310 layout: Some(CompositionLayout::Grid),
311 ..Default::default()
312 },
313 Some(LayoutPriority::Speaker),
314 );
315 assert_eq!(mapped["config"]["layout"]["priority"], json!("SPEAKER"));
316
317 let mapped = config_of(
318 Composition {
319 layout: Some(CompositionLayout::Grid),
320 priority: Some(LayoutPriority::Pin),
321 ..Default::default()
322 },
323 Some(LayoutPriority::Speaker),
324 );
325 assert_eq!(mapped["config"]["layout"]["priority"], json!("PIN"));
326 }
327
328 #[test]
329 fn recordings_send_no_priority_by_default() {
330 let mapped = config_of(
331 Composition {
332 layout: Some(CompositionLayout::Grid),
333 ..Default::default()
334 },
335 None,
336 );
337 assert_eq!(mapped["config"], json!({"layout": {"type": "GRID"}}));
338 }
339
340 #[test]
341 fn grid_size_rides_along_with_a_named_layout() {
342 let mapped = config_of(
343 Composition {
344 layout: Some(CompositionLayout::Grid),
345 grid_size: Some(9),
346 ..Default::default()
347 },
348 None,
349 );
350 assert_eq!(
351 mapped["config"]["layout"],
352 json!({"type": "GRID", "gridSize": 9})
353 );
354 }
355
356 #[test]
358 fn a_layoutless_config_gets_grid_injected() {
359 let mapped = config_of(
360 Composition {
361 quality: Some(CompositionQuality::High),
362 theme: Some(Theme::Dark),
363 ..Default::default()
364 },
365 None,
366 );
367 assert_eq!(
368 mapped["config"],
369 json!({"layout": {"type": "GRID"}, "theme": "DARK", "quality": "high"})
370 );
371 }
372
373 #[test]
374 fn grid_injection_carries_the_resolved_priority_but_not_grid_size() {
375 let mapped = config_of(
376 Composition {
377 orientation: Some(Orientation::Portrait),
378 grid_size: Some(4),
379 ..Default::default()
380 },
381 Some(LayoutPriority::Speaker),
382 );
383 assert_eq!(
384 mapped["config"]["layout"],
385 json!({"type": "GRID", "priority": "SPEAKER"})
386 );
387 }
388
389 #[test]
390 fn a_custom_template_with_other_options_still_gets_grid_injected() {
391 let mapped = config_of(
392 Composition {
393 layout: Some(CompositionLayout::custom("https://example.com/t.html")),
394 quality: Some(CompositionQuality::Low),
395 ..Default::default()
396 },
397 None,
398 );
399 assert_eq!(
400 mapped["config"],
401 json!({"layout": {"type": "GRID"}, "quality": "low"})
402 );
403 assert_eq!(mapped["templateUrl"], json!("https://example.com/t.html"));
404 }
405
406 #[test]
409 fn a_string_start_response_yields_a_room_keyed_handle() {
410 let handle = to_egress_handle(EgressType::Hls, "r-1", json!("HLS started"));
411 assert_eq!(handle.room_id, "r-1");
412 assert!(handle.id.is_none());
413 assert_eq!(handle.raw, Some(json!("HLS started")));
414 }
415
416 #[test]
417 fn an_object_start_response_yields_ids_in_priority_order() {
418 let handle = to_egress_handle(
419 EgressType::Composite,
420 "r-1",
421 json!({"recordingId": "rec-1", "id": "other", "_id": "another", "sessionId": "s-1"}),
422 );
423 assert_eq!(handle.id.as_deref(), Some("rec-1"));
424 assert_eq!(handle.session_id.as_deref(), Some("s-1"));
425
426 let handle = to_egress_handle(EgressType::Hls, "r-1", json!({"id": "h-1"}));
427 assert_eq!(handle.id.as_deref(), Some("h-1"));
428
429 let handle = to_egress_handle(EgressType::Hls, "r-1", json!({"_id": "h-2"}));
430 assert_eq!(handle.id.as_deref(), Some("h-2"));
431 }
432
433 #[test]
434 fn a_start_response_room_id_overrides_the_requested_one() {
435 let handle = to_egress_handle(
436 EgressType::Recording,
437 "requested",
438 json!({"roomId": "real"}),
439 );
440 assert_eq!(handle.room_id, "real");
441 }
442
443 #[test]
444 fn stop_targets_accept_handles_and_bare_room_ids() {
445 let handle = to_egress_handle(EgressType::Recording, "r-1", json!({"id": "e-1"}));
446 let target: StopTarget = (&handle).into();
447 assert_eq!(target.room_id, "r-1");
448 assert_eq!(target.id.as_deref(), Some("e-1"));
449
450 let target: StopTarget = "r-2".into();
451 assert_eq!(target.room_id, "r-2");
452 assert!(target.id.is_none());
453 }
454
455 #[test]
456 fn stop_wire_omits_an_absent_or_empty_id() {
457 let target = StopTarget {
458 room_id: "r-1".into(),
459 id: None,
460 };
461 assert_eq!(
462 serde_json::to_value(StopWire::from(&target)).unwrap(),
463 json!({"roomId": "r-1"})
464 );
465
466 let target = StopTarget {
467 room_id: "r-1".into(),
468 id: Some(String::new()),
469 };
470 assert_eq!(
471 serde_json::to_value(StopWire::from(&target)).unwrap(),
472 json!({"roomId": "r-1"})
473 );
474
475 let target = StopTarget {
476 room_id: "r-1".into(),
477 id: Some("e-1".into()),
478 };
479 assert_eq!(
480 serde_json::to_value(StopWire::from(&target)).unwrap(),
481 json!({"roomId": "r-1", "id": "e-1"})
482 );
483 }
484}