1pub mod config;
48pub mod encode;
49pub mod error;
50pub mod retention;
51pub mod rooms;
52pub mod rooms_db;
53pub mod sink;
54pub mod storage;
55pub mod transport;
56pub mod workflows;
57
58pub use config::{
59 MediaConfig, MediaConfigError, MediaMtxConfig, MediaStorageBackend, MediaStorageConfig,
60 RecordingConfig, RoomStoreBackend,
61};
62pub use encode::{
63 FfmpegClipTailCommand, FfmpegHighlightCommand, FfmpegLiveThumbnailCommand, FfmpegPosterCommand,
64 FfmpegPreviewSpriteCommand, FfmpegRoomCompositeCommand, PREVIEW_CELL_HEIGHT,
65 PREVIEW_CELL_WIDTH, PREVIEW_FRAME_INTERVAL_SECONDS, PREVIEW_SPRITE_COLUMNS,
66 ROOM_COMPOSITE_CELL_HEIGHT, ROOM_COMPOSITE_CELL_WIDTH, build_preview_webvtt,
67 newest_recording_file, newest_recording_files, newest_recording_files_since,
68 recording_segments_covering_window, slugify,
69};
70pub use error::MediaError;
71pub use retention::{
72 RetentionDefer, RetentionReport, is_expired, recording_expires_at, spawn_retention_sweep_loop,
73 sweep_recordings_root, within_root,
74};
75pub use rooms::{
76 HeartbeatRequest, HeartbeatResponse, InMemoryRoomStore, JoinRecord, JoinRequest, JoinResponse,
77 LeaveRequest, ParticipantView, PublishTarget, ReapFuture, ReapStats, RoomError,
78 RoomLeaveResponse, RoomService, RoomSnapshot, RoomStore, RoomStoreFuture, SessionToken,
79 SubscribeTarget, renewed_expiry, room_participant_path, room_route_infos, room_router,
80 spawn_room_reaper_loop, validate_room_segment,
81};
82pub use rooms_db::DbRoomStore;
83pub use sink::{
84 MediaArtifact, MediaArtifactFile, MediaArtifactKind, MediaArtifactSink, MediaArtifactSinkExt,
85 MediaSinkFuture,
86};
87pub use storage::{MediaStorage, S3MediaStorage, StoredObject};
88pub use transport::{
89 IngestStatus, MediaMtxClient, MediaUrls, StreamQualityStats, StreamStatus, ViewerCount,
90 duration_seconds_param, ingest_statuses_from_paths_json, quality_stats_from_path_json,
91 recording_available, recording_mediamtx_path, stream_status_from_path_json,
92 viewer_count_from_path_json, viewer_counts_from_paths_json,
93};
94pub use workflows::{
95 FinalizeRecordingJobArgs, MediaWorkflowDelegate, MediaWorkflowDelegateExt,
96 MediaWorkflowRequest, MediaWorkflows, PreviewJobArgs, RoomCompositeJobArgs, ThumbnailJobArgs,
97 TranscodeJobArgs, media_job_infos,
98};
99
100pub mod prelude {
102 pub use crate::{
103 FfmpegClipTailCommand, FfmpegHighlightCommand, FfmpegLiveThumbnailCommand,
104 FfmpegPosterCommand, FfmpegPreviewSpriteCommand, FfmpegRoomCompositeCommand,
105 FinalizeRecordingJobArgs, MediaArtifact, MediaArtifactFile, MediaArtifactKind,
106 MediaArtifactSink, MediaArtifactSinkExt, MediaConfig, MediaConfigError, MediaError,
107 MediaMtxConfig, MediaPlugin, MediaStorage, MediaStorageBackend, MediaStorageConfig,
108 MediaWorkflowDelegate, MediaWorkflowDelegateExt, MediaWorkflowRequest, MediaWorkflows,
109 PREVIEW_CELL_HEIGHT, PREVIEW_CELL_WIDTH, PREVIEW_FRAME_INTERVAL_SECONDS,
110 PREVIEW_SPRITE_COLUMNS, PreviewJobArgs, ROOM_COMPOSITE_CELL_HEIGHT,
111 ROOM_COMPOSITE_CELL_WIDTH, RecordingConfig, RetentionDefer, RoomCompositeJobArgs,
112 S3MediaStorage, StoredObject, ThumbnailJobArgs, TranscodeJobArgs, build_preview_webvtt,
113 media_job_infos, newest_recording_file, newest_recording_files,
114 newest_recording_files_since, recording_segments_covering_window, slugify,
115 };
116 pub use crate::{
117 HeartbeatResponse, InMemoryRoomStore, JoinRecord, JoinResponse, ParticipantView, ReapStats,
118 RoomError, RoomService, RoomSnapshot, RoomStore, SessionToken, renewed_expiry,
119 room_participant_path, room_route_infos, room_router, spawn_room_reaper_loop,
120 validate_room_segment,
121 };
122 pub use crate::{
123 IngestStatus, MediaMtxClient, MediaUrls, StreamQualityStats, StreamStatus, ViewerCount,
124 duration_seconds_param, ingest_statuses_from_paths_json, quality_stats_from_path_json,
125 recording_available, recording_mediamtx_path, stream_status_from_path_json,
126 viewer_count_from_path_json, viewer_counts_from_paths_json,
127 };
128}
129
130use std::borrow::Cow;
131use std::collections::HashMap;
132use std::path::PathBuf;
133use std::sync::Arc;
134
135use autumn_web::app::AppBuilder;
136use autumn_web::plugin::Plugin;
137
138use crate::config::DEFAULT_ROOM_MAX_PARTICIPANTS;
139use crate::retention::RetentionDefer as RetentionDeferHook;
140use crate::sink::MediaArtifactSink as MediaArtifactSinkTrait;
141use crate::workflows::MediaWorkflowDelegate as MediaWorkflowDelegateHook;
142
143pub struct MediaPlugin {
152 config: MediaConfig,
154 enable_broadcast: bool,
156 enable_rooms: bool,
158 room_max_participants: usize,
160 queue: String,
162 api_prefix: String,
164 artifact_sink: Option<Arc<dyn MediaArtifactSinkTrait>>,
167 workflow_delegate: Option<MediaWorkflowDelegateHook>,
170 retention_days: Option<u32>,
172 recordings_root: Option<PathBuf>,
175 retention_defer: Option<RetentionDeferHook>,
177}
178
179impl MediaPlugin {
180 #[must_use]
184 pub fn new() -> Self {
185 Self {
186 config: MediaConfig::default(),
187 enable_broadcast: false,
188 enable_rooms: false,
189 room_max_participants: DEFAULT_ROOM_MAX_PARTICIPANTS,
190 queue: "media".to_owned(),
191 api_prefix: "/api/media".to_owned(),
192 artifact_sink: None,
193 workflow_delegate: None,
194 retention_days: None,
195 recordings_root: None,
196 retention_defer: None,
197 }
198 }
199
200 #[must_use]
225 pub fn from_arroyo_env() -> Self {
226 let env: HashMap<String, String> = std::env::vars().collect();
227 Self::from_arroyo_env_pairs(&env)
228 }
229
230 #[must_use]
235 pub fn from_arroyo_env_pairs(env: &HashMap<String, String>) -> Self {
236 let config = MediaConfig::from_arroyo_env_pairs(env);
237 Self::new()
238 .config(config)
239 .with_broadcast()
240 .recordings_root(arroyo_recordings_root(env))
241 }
242
243 #[must_use]
251 pub fn config(mut self, config: MediaConfig) -> Self {
252 self.room_max_participants = config.room_max_participants;
253 self.config = config;
254 self
255 }
256
257 #[must_use]
263 pub const fn with_broadcast(mut self) -> Self {
264 self.enable_broadcast = true;
265 self
266 }
267
268 #[must_use]
273 pub const fn with_rooms(mut self) -> Self {
274 self.enable_rooms = true;
275 self
276 }
277
278 #[must_use]
280 pub const fn room_max_participants(mut self, max: usize) -> Self {
281 self.room_max_participants = max;
282 self
283 }
284
285 #[must_use]
288 pub const fn room_token_ttl_seconds(mut self, seconds: u32) -> Self {
289 self.config.room_token_ttl_seconds = seconds;
290 self
291 }
292
293 #[must_use]
296 pub const fn room_session_max_seconds(mut self, seconds: u32) -> Self {
297 self.config.room_session_max_seconds = seconds;
298 self
299 }
300
301 #[must_use]
304 pub fn room_namespace(mut self, namespace: impl Into<String>) -> Self {
305 self.config.room_namespace = Some(namespace.into());
306 self
307 }
308
309 #[must_use]
311 pub fn queue(mut self, queue: impl Into<String>) -> Self {
312 self.queue = queue.into();
313 self
314 }
315
316 #[must_use]
318 pub fn api(mut self, prefix: impl Into<String>) -> Self {
319 self.api_prefix = prefix.into();
320 self
321 }
322
323 #[must_use]
326 pub fn ffmpeg_bin(mut self, bin: impl Into<String>) -> Self {
327 self.config.ffmpeg.bin = bin.into();
328 self
329 }
330
331 #[must_use]
337 pub fn artifact_sink(mut self, sink: Arc<dyn MediaArtifactSinkTrait>) -> Self {
338 self.artifact_sink = Some(sink);
339 self
340 }
341
342 #[must_use]
347 pub fn workflow_delegate(mut self, delegate: MediaWorkflowDelegateHook) -> Self {
348 self.workflow_delegate = Some(delegate);
349 self
350 }
351
352 #[must_use]
355 pub const fn retention_days(mut self, days: u32) -> Self {
356 self.retention_days = Some(days);
357 self
358 }
359
360 #[must_use]
363 pub fn recordings_root(mut self, root: impl Into<PathBuf>) -> Self {
364 self.recordings_root = Some(root.into());
365 self
366 }
367
368 #[must_use]
372 pub fn retention_defer(mut self, defer: RetentionDeferHook) -> Self {
373 self.retention_defer = Some(defer);
374 self
375 }
376}
377
378impl Default for MediaPlugin {
379 fn default() -> Self {
380 Self::new()
381 }
382}
383
384impl Plugin for MediaPlugin {
385 fn contract(&self) -> Option<autumn_web::plugin_contract::PluginContract> {
388 Some(autumn_web::plugin_contract::lockstep_contract(
389 env!("CARGO_PKG_NAME"),
390 env!("CARGO_PKG_VERSION"),
391 ))
392 }
393
394 fn name(&self) -> Cow<'static, str> {
395 Cow::Borrowed("autumn-media-plugin")
396 }
397
398 #[allow(clippy::too_many_lines)]
409 fn build(self, app: AppBuilder) -> AppBuilder {
410 let Self {
411 config,
412 enable_broadcast,
413 enable_rooms,
414 room_max_participants,
415 queue,
416 api_prefix,
417 artifact_sink,
418 workflow_delegate,
419 retention_days,
420 recordings_root,
421 retention_defer,
422 } = self;
423
424 let app = app.config_section("media");
432
433 let retention_days = retention_days.unwrap_or(config.recording.retention_days);
434
435 if let Some(message) = room_config_boot_error(enable_rooms, room_max_participants, &config)
466 {
467 tracing::error!(
468 room_max_participants,
469 ceiling = DEFAULT_ROOM_MAX_PARTICIPANTS,
470 "🍂 Autumn Media: {message}"
471 );
472 return app.on_startup(move |_state| {
473 let message = message.clone();
474 async move { Err(autumn_web::AutumnError::internal_server_error_msg(message)) }
475 });
476 }
477
478 tracing::info!(
479 broadcast = %enable_broadcast,
480 rooms = %enable_rooms,
481 room_max_participants,
482 room_store_backend = config.room_store_backend.as_str(),
483 storage_backend = config.storage.backend.as_str(),
484 queue = %queue,
485 api_prefix = %api_prefix,
486 has_artifact_sink = artifact_sink.is_some(),
487 has_workflow_delegate = workflow_delegate.is_some(),
488 retention_days,
489 "🍂 Autumn Media configured"
490 );
491
492 if !enable_broadcast && !enable_rooms {
494 tracing::warn!(
495 recordings_root_ignored = recordings_root.is_some(),
496 "🍂 Autumn Media: no primitive enabled; call `with_broadcast()` or \
497 `with_rooms()`. The plugin installs nothing, and no retention sweep starts."
498 );
499 return app;
500 }
501
502 let mut app = app;
508 if enable_rooms {
509 let backend = config.room_store_backend;
520 let shared_memory_store: Option<Arc<dyn rooms::RoomStore>> = match backend {
521 config::RoomStoreBackend::Memory => Some(Arc::new(rooms::InMemoryRoomStore::new(
522 room_max_participants,
523 ))),
524 config::RoomStoreBackend::Db => None,
525 };
526
527 let room_urls = transport::MediaUrls::from_config(&config.mediamtx);
531 let room_namespace = config.room_namespace.clone().unwrap_or_default();
532 let room_token_ttl =
533 chrono::Duration::seconds(i64::from(config.room_token_ttl_seconds));
534 let room_max_session =
535 chrono::Duration::seconds(i64::from(config.room_session_max_seconds));
536 let room_rate_limit = config.room_rate_limit_per_minute;
537
538 let init_store = shared_memory_store.clone();
539 app = app
540 .nest(&api_prefix, rooms::room_router())
541 .declare_plugin_routes(rooms::room_route_infos(&api_prefix))
542 .state_initializer(move |state| {
543 let store = init_store
544 .clone()
545 .unwrap_or_else(|| build_room_store(state, room_max_participants));
546 let room_service = rooms::RoomService::new(
547 store,
548 room_urls.clone(),
549 room_namespace.clone(),
550 room_token_ttl,
551 room_max_participants,
552 )
553 .with_session_max(room_max_session)
554 .with_rate_limit_per_minute(room_rate_limit);
555 state.insert_extension(room_service);
556 })
557 .on_startup(move |state| {
562 let store = shared_memory_store
563 .clone()
564 .unwrap_or_else(|| build_room_store(&state, room_max_participants));
565 async move {
566 rooms::spawn_room_reaper_loop(store);
567 Ok(())
568 }
569 });
570 }
571
572 if enable_broadcast {
575 let mtx_client = transport::MediaMtxClient::new(&config.mediamtx);
576 let urls = transport::MediaUrls::from_config(&config.mediamtx);
577 app = app.state_initializer(move |state| {
578 state.insert_extension(mtx_client);
579 state.insert_extension(urls);
580 });
581 }
582
583 let storage = match storage::MediaStorage::from_config(&config.storage) {
588 Ok(storage) => storage,
589 Err(error) => {
590 tracing::error!(
591 %error,
592 "🍂 Autumn Media: storage config invalid; encode workflows disabled"
593 );
594 return app;
595 }
596 };
597
598 let ffmpeg_bin = config.ffmpeg.bin;
599 let workflows = workflows::MediaWorkflows::new(ffmpeg_bin, queue.clone());
600
601 let app = app
606 .state_initializer(move |state| {
607 state.insert_extension(storage.clone());
608 state.insert_extension(workflows.clone());
609 if let Some(sink) = &artifact_sink {
610 state.insert_extension(sink::MediaArtifactSinkExt(sink.clone()));
611 }
612 if let Some(delegate) = &workflow_delegate {
613 state.insert_extension(workflows::MediaWorkflowDelegateExt(delegate.clone()));
614 }
615 })
616 .jobs(workflows::media_job_infos(&queue));
622
623 if let Some(root) = recordings_root {
628 app.on_startup(move |_state| {
629 let root = root.clone();
630 let defer = retention_defer.clone();
631 async move {
632 retention::spawn_retention_sweep_loop(root, retention_days, defer);
633 Ok(())
634 }
635 })
636 } else {
637 if retention_days > 0 {
638 tracing::debug!(
639 "🍂 Autumn Media: retention window set but no recordings_root; sweep not spawned"
640 );
641 }
642 app
643 }
644 }
645}
646
647fn room_max_participants_error(configured: usize) -> Option<String> {
654 if configured == 0 {
655 Some(format!(
656 "a room must allow at least 1 participant; got {configured}"
657 ))
658 } else if configured > DEFAULT_ROOM_MAX_PARTICIPANTS {
659 Some(format!(
660 "mesh rooms are capped at {DEFAULT_ROOM_MAX_PARTICIPANTS} participants (no SFU); got {configured}"
661 ))
662 } else {
663 None
664 }
665}
666
667fn room_namespace_error(namespace: Option<&str>) -> Option<String> {
678 let ns = namespace?;
679 if ns.is_empty() || rooms::validate_room_segment(ns).is_ok() {
680 return None;
681 }
682 Some(format!(
683 "media.room_namespace {ns:?} is not a valid room path segment: use only ASCII letters, digits, '_' or '-' (no '/', '.', or empty)"
684 ))
685}
686
687fn room_config_boot_error(
702 enable_rooms: bool,
703 room_max_participants: usize,
704 config: &MediaConfig,
705) -> Option<String> {
706 if !enable_rooms {
707 return None;
708 }
709 room_max_participants_error(room_max_participants)
710 .or_else(|| room_namespace_error(config.room_namespace.as_deref()))
711 .or_else(|| room_session_max_error(config))
712}
713
714fn room_session_max_error(config: &MediaConfig) -> Option<String> {
719 let session_max = config.room_session_max_seconds;
720 let token_ttl = config.room_token_ttl_seconds;
721 (session_max < token_ttl).then(|| {
722 format!(
723 "media.room_session_max_seconds ({session_max}) must be at least \
724 media.room_token_ttl_seconds ({token_ttl})"
725 )
726 })
727}
728
729fn build_room_store(state: &autumn_web::AppState, cap: usize) -> Arc<dyn rooms::RoomStore> {
739 if let Some(pool) = state.pool().cloned() {
740 Arc::new(rooms_db::DbRoomStore::new(pool, cap))
741 } else {
742 tracing::error!(
743 "🍂 Autumn Media: room_store_backend=\"db\" but no database pool is configured; \
744 falling back to the in-memory room store — rooms will NOT survive across \
745 processes. Configure a `[database]` primary_url to enable the shared store."
746 );
747 Arc::new(rooms::InMemoryRoomStore::new(cap))
748 }
749}
750
751const DEFAULT_ARROYO_RECORDINGS_ROOT: &str = "recordings";
755
756fn arroyo_recordings_root(env: &HashMap<String, String>) -> PathBuf {
761 env.get("ARROYO_RECORDINGS_ROOT")
762 .map(|value| value.trim())
763 .filter(|value| !value.is_empty())
764 .map_or_else(
765 || PathBuf::from(DEFAULT_ARROYO_RECORDINGS_ROOT),
766 PathBuf::from,
767 )
768}
769
770#[cfg(test)]
771mod contract_tests {
772 use super::MediaPlugin;
773 use autumn_web::plugin::Plugin;
774
775 #[test]
776 fn contract_declares_lockstep_with_own_crate() {
777 let contract = MediaPlugin::new().contract().expect("a contract");
778 assert_eq!(contract.plugin, env!("CARGO_PKG_NAME"));
779 assert_eq!(
780 contract.plugin_version.as_deref(),
781 Some(env!("CARGO_PKG_VERSION"))
782 );
783 assert_eq!(
784 contract.autumn_web.as_deref(),
785 Some(autumn_web::plugin_contract::lockstep_range(env!("CARGO_PKG_VERSION")).as_str())
786 );
787 assert!(contract.experimental_surfaces.is_empty());
788 }
789}
790
791#[cfg(test)]
794mod arroyo_shim_tests {
795 use super::{DEFAULT_ARROYO_RECORDINGS_ROOT, MediaPlugin, arroyo_recordings_root};
796 use crate::config::MediaStorageBackend;
797 use std::collections::HashMap;
798 use std::path::PathBuf;
799
800 fn env(pairs: &[(&str, &str)]) -> HashMap<String, String> {
801 pairs
802 .iter()
803 .map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
804 .collect()
805 }
806
807 #[test]
808 fn empty_env_is_wired_broadcast_local_plugin() {
809 let plugin = MediaPlugin::from_arroyo_env_pairs(&HashMap::new());
810 assert!(plugin.enable_broadcast, "broadcast must be enabled");
812 assert!(!plugin.enable_rooms, "rooms must stay disabled");
813 assert_eq!(plugin.config.storage.backend, MediaStorageBackend::Local);
815 assert_eq!(plugin.config.mediamtx.api_base, "http://127.0.0.1:9997");
816 assert_eq!(plugin.config.ffmpeg.bin, "/usr/bin/ffmpeg");
817 assert_eq!(
820 plugin.recordings_root.as_deref(),
821 Some(std::path::Path::new(DEFAULT_ARROYO_RECORDINGS_ROOT))
822 );
823 assert!(plugin.config.validate().is_ok());
825 }
826
827 #[test]
828 fn maps_full_s3_environment_and_validates() {
829 let vars = env(&[
830 ("ARROYO_VIDEO_STORAGE_BACKEND", "s3"),
831 ("BUCKET_NAME", "arroyo-bucket"),
832 ("AWS_ACCESS_KEY_ID", "AKIA"),
833 ("AWS_SECRET_ACCESS_KEY", "secret"),
834 ("ARROYO_MEDIAMTX_API_BASE", "http://mtx:9997"),
835 ("ARROYO_FFMPEG_BIN", "/opt/ffmpeg"),
836 ("ARROYO_RECORDING_RETENTION_DAYS", "21"),
837 ("ARROYO_RECORDINGS_ROOT", "/data/recordings"),
838 ]);
839 let plugin = MediaPlugin::from_arroyo_env_pairs(&vars);
840 assert!(plugin.enable_broadcast);
841 assert_eq!(plugin.config.storage.backend, MediaStorageBackend::S3);
842 assert_eq!(
843 plugin.config.storage.bucket.as_deref(),
844 Some("arroyo-bucket")
845 );
846 assert_eq!(plugin.config.storage.region.as_deref(), Some("auto"));
848 assert_eq!(
849 plugin.config.storage.endpoint_url.as_deref(),
850 Some("https://t3.storage.dev")
851 );
852 assert_eq!(plugin.config.storage.key_prefix, "highlights");
853 assert_eq!(plugin.config.mediamtx.api_base, "http://mtx:9997");
854 assert_eq!(plugin.config.ffmpeg.bin, "/opt/ffmpeg");
855 assert_eq!(plugin.config.recording.retention_days, 21);
858 assert_eq!(
859 plugin.recordings_root.as_deref(),
860 Some(std::path::Path::new("/data/recordings"))
861 );
862 assert!(plugin.config.validate().is_ok());
863 }
864
865 #[test]
866 fn recordings_root_honors_env_and_defaults() {
867 assert_eq!(
868 arroyo_recordings_root(&env(&[("ARROYO_RECORDINGS_ROOT", "/mnt/rec")])),
869 PathBuf::from("/mnt/rec")
870 );
871 assert_eq!(
873 arroyo_recordings_root(&env(&[("ARROYO_RECORDINGS_ROOT", " ")])),
874 PathBuf::from(DEFAULT_ARROYO_RECORDINGS_ROOT)
875 );
876 assert_eq!(
877 arroyo_recordings_root(&HashMap::new()),
878 PathBuf::from(DEFAULT_ARROYO_RECORDINGS_ROOT)
879 );
880 }
881
882 #[test]
883 fn returned_plugin_stays_chainable() {
884 let plugin = MediaPlugin::from_arroyo_env_pairs(&HashMap::new())
887 .queue("arroyo-media")
888 .retention_days(30);
889 assert!(plugin.enable_broadcast);
890 assert_eq!(plugin.queue, "arroyo-media");
891 assert_eq!(plugin.retention_days, Some(30));
892 }
893}
894
895#[cfg(test)]
898mod room_cap_tests {
899 use super::{DEFAULT_ROOM_MAX_PARTICIPANTS, MediaPlugin, room_max_participants_error};
900 use crate::config::MediaConfig;
901
902 #[test]
903 fn out_of_range_room_cap_is_a_specific_fail_fast_error() {
904 let over = room_max_participants_error(50).expect("50 > 6 must be rejected");
907 assert!(over.contains("50"), "names the offending value: {over}");
908 assert!(over.contains('6'), "names the ceiling: {over}");
909 assert!(over.contains("no SFU"), "explains why: {over}");
910 let zero = room_max_participants_error(0).expect("0 must be rejected");
912 assert!(zero.contains("at least 1 participant"), "0 message: {zero}");
913 assert!(zero.contains('0'), "names the value: {zero}");
914 }
915
916 #[test]
917 fn in_range_room_cap_is_accepted() {
918 assert!(room_max_participants_error(1).is_none());
919 assert!(room_max_participants_error(4).is_none());
920 assert!(room_max_participants_error(DEFAULT_ROOM_MAX_PARTICIPANTS).is_none());
921 }
922
923 #[test]
924 fn builder_and_config_paths_feed_the_same_effective_cap_that_boot_rejects() {
925 let over = MediaPlugin::new().with_rooms().room_max_participants(50);
929 assert_eq!(over.room_max_participants, 50);
930 assert!(room_max_participants_error(over.room_max_participants).is_some());
931
932 let ok = MediaPlugin::new().with_rooms().room_max_participants(4);
934 assert_eq!(ok.room_max_participants, 4);
935 assert!(room_max_participants_error(ok.room_max_participants).is_none());
936
937 let config = MediaConfig {
940 room_max_participants: 50,
941 ..MediaConfig::default()
942 };
943 let from_config = MediaPlugin::new().config(config);
944 assert_eq!(from_config.room_max_participants, 50);
945 assert!(room_max_participants_error(from_config.room_max_participants).is_some());
946 }
947}
948
949#[cfg(test)]
952mod room_namespace_tests {
953 use super::{MediaPlugin, room_namespace_error};
954
955 #[test]
956 fn absent_empty_and_valid_namespaces_are_accepted() {
957 assert!(room_namespace_error(None).is_none());
960 assert!(room_namespace_error(Some("")).is_none());
961 assert!(room_namespace_error(Some("tenant-a")).is_none());
962 assert!(room_namespace_error(Some("room_1")).is_none());
963 }
964
965 #[test]
966 fn invalid_namespace_is_a_specific_fail_fast_error() {
967 for bad in ["tenant/a", ".", "..", "a b"] {
971 let message = room_namespace_error(Some(bad))
972 .unwrap_or_else(|| panic!("{bad:?} must be rejected"));
973 assert!(
974 message.contains(bad),
975 "names the offending value: {message}"
976 );
977 assert!(
978 message.contains("letters") && message.contains('-'),
979 "mentions the allowed charset: {message}"
980 );
981 }
982 }
983
984 #[test]
985 fn room_namespace_builder_feeds_the_effective_value_that_boot_rejects() {
986 let bad = MediaPlugin::new().with_rooms().room_namespace("tenant/a");
990 assert_eq!(bad.config.room_namespace.as_deref(), Some("tenant/a"));
991 assert!(room_namespace_error(bad.config.room_namespace.as_deref()).is_some());
992
993 let ok = MediaPlugin::new().with_rooms().room_namespace("tenant-a");
995 assert_eq!(ok.config.room_namespace.as_deref(), Some("tenant-a"));
996 assert!(room_namespace_error(ok.config.room_namespace.as_deref()).is_none());
997 }
998}
999
1000#[cfg(test)]
1009mod room_config_gate_tests {
1010 use super::{MediaConfig, MediaPlugin, room_config_boot_error};
1011
1012 fn with_namespace(namespace: &str) -> MediaConfig {
1013 MediaConfig {
1014 room_namespace: Some(namespace.to_owned()),
1015 ..MediaConfig::default()
1016 }
1017 }
1018
1019 #[test]
1020 fn broadcast_only_ignores_invalid_room_config() {
1021 assert!(!MediaPlugin::new().with_broadcast().enable_rooms);
1025 assert!(room_config_boot_error(false, 0, &MediaConfig::default()).is_none());
1026 assert!(room_config_boot_error(false, 50, &with_namespace("bad/ns")).is_none());
1027 assert!(room_config_boot_error(false, 4, &with_namespace("tenant-a")).is_none());
1028 let short_session = MediaConfig {
1029 room_session_max_seconds: 1,
1030 ..MediaConfig::default()
1031 };
1032 assert!(room_config_boot_error(false, 4, &short_session).is_none());
1033 }
1034
1035 #[test]
1036 fn rooms_enabled_still_fails_fast_on_invalid_room_config() {
1037 assert!(MediaPlugin::new().with_rooms().enable_rooms);
1041
1042 let bad_cap = room_config_boot_error(true, 0, &MediaConfig::default())
1043 .expect("rooms-enabled must fail fast on room_max_participants = 0");
1044 assert!(bad_cap.contains('0'), "names the offending cap: {bad_cap}");
1045
1046 let bad_ns = room_config_boot_error(true, 4, &with_namespace("bad/ns"))
1047 .expect("rooms-enabled must fail fast on an invalid room_namespace");
1048 assert!(
1049 bad_ns.contains("bad/ns"),
1050 "names the offending namespace: {bad_ns}"
1051 );
1052
1053 assert!(room_config_boot_error(true, 4, &with_namespace("tenant-a")).is_none());
1055 }
1056
1057 #[test]
1058 fn rooms_enabled_fails_fast_on_a_session_max_less_than_the_token_ttl() {
1059 let short_session = MediaConfig {
1060 room_token_ttl_seconds: 300,
1061 room_session_max_seconds: 60,
1062 ..MediaConfig::default()
1063 };
1064 let message = room_config_boot_error(true, 4, &short_session)
1065 .expect("a session shorter than its token must abort boot");
1066 assert!(message.contains("60"), "names the session limit: {message}");
1067 assert!(message.contains("300"), "names the token TTL: {message}");
1068 }
1069}
1070
1071#[cfg(test)]
1081mod conformance_tests {
1082 use autumn_web::plugin_conformance::{
1083 CheckStatus, ConformanceConfig, check_collisions, check_duplicate_registration,
1084 check_route_attribution, check_route_prefix, check_sensitive_surfaces, run_conformance,
1085 };
1086 use autumn_web::route_listing::{RouteInfo, RouteSource};
1087
1088 const PLUGIN_NAME: &str = "autumn-media-plugin";
1089 const API_PREFIX: &str = "/api/media";
1090
1091 fn declared_room_routes() -> Vec<RouteInfo> {
1094 super::rooms::room_route_infos(API_PREFIX)
1095 .into_iter()
1096 .map(|mut route| {
1097 route.source = RouteSource::Plugin(PLUGIN_NAME.to_owned());
1098 route
1099 })
1100 .collect()
1101 }
1102
1103 #[test]
1104 fn build_declares_the_five_room_routes_when_rooms_enabled() {
1105 let routes = super::rooms::room_route_infos(API_PREFIX);
1106 assert_eq!(routes.len(), 5, "rooms declare exactly five routes");
1107 assert!(
1108 routes
1109 .iter()
1110 .all(|route| route.path.starts_with(API_PREFIX)),
1111 "every declared room route lives under the API prefix"
1112 );
1113 }
1114
1115 #[test]
1116 fn room_routes_are_attributed_to_plugin_name() {
1117 let result = check_route_attribution(PLUGIN_NAME, &declared_room_routes());
1118 assert_eq!(
1119 result.status,
1120 CheckStatus::Pass,
1121 "attribution failed: {}",
1122 result.message
1123 );
1124 }
1125
1126 #[test]
1127 fn room_routes_live_under_api_prefix() {
1128 let result = check_route_prefix(PLUGIN_NAME, API_PREFIX, &[], &declared_room_routes());
1129 assert_eq!(
1130 result.status,
1131 CheckStatus::Pass,
1132 "prefix check failed: {}",
1133 result.message
1134 );
1135 }
1136
1137 #[test]
1138 fn room_routes_have_no_collisions_in_isolation() {
1139 let (result, _) = check_collisions(&declared_room_routes());
1140 assert_eq!(
1141 result.status,
1142 CheckStatus::Pass,
1143 "unexpected collision: {}",
1144 result.message
1145 );
1146 }
1147
1148 #[test]
1149 fn room_routes_have_no_undeclared_sensitive_surfaces() {
1150 let result = check_sensitive_surfaces(PLUGIN_NAME, &declared_room_routes(), &[]);
1151 assert_eq!(
1152 result.status,
1153 CheckStatus::Pass,
1154 "sensitive-surfaces check failed: {}",
1155 result.message
1156 );
1157 }
1158
1159 #[test]
1160 fn room_single_registration_passes_duplicate_check() {
1161 let result = check_duplicate_registration(PLUGIN_NAME, &declared_room_routes());
1162 assert_eq!(
1163 result.status,
1164 CheckStatus::Pass,
1165 "single registration should pass: {}",
1166 result.message
1167 );
1168 }
1169
1170 #[test]
1171 fn room_routes_pass_full_conformance() {
1172 let config = ConformanceConfig::new(PLUGIN_NAME).prefix(API_PREFIX);
1173 let report = run_conformance(&config, &declared_room_routes());
1174 assert!(
1175 report.passed(),
1176 "MediaPlugin conformance failed:\n{}",
1177 report.to_text_report()
1178 );
1179 }
1180
1181 #[test]
1182 fn duplicate_registration_detected() {
1183 let mut routes = declared_room_routes();
1185 routes.extend(declared_room_routes());
1186 let result = check_duplicate_registration(PLUGIN_NAME, &routes);
1187 assert_eq!(
1188 result.status,
1189 CheckStatus::Fail,
1190 "expected duplicate-registration FAIL when installed twice"
1191 );
1192 }
1193
1194 #[test]
1195 fn collision_with_host_route_detected() {
1196 let mut routes = declared_room_routes();
1199 routes.push(RouteInfo {
1200 method: "GET".to_owned(),
1201 path: format!("{API_PREFIX}/rooms/{{room_id}}"),
1202 handler: "host::roster".to_owned(),
1203 source: RouteSource::User,
1204 ..Default::default()
1205 });
1206 let (result, _) = check_collisions(&routes);
1207 assert_eq!(
1208 result.status,
1209 CheckStatus::Fail,
1210 "expected collision to be detected"
1211 );
1212 }
1213}
1214
1215#[cfg(test)]
1216mod config_section_tests {
1217 use super::MediaPlugin;
1218
1219 #[test]
1225 fn build_declares_media_config_section() {
1226 let builder = autumn_web::app().plugin(MediaPlugin::new());
1227 assert!(
1228 builder.has_config_section("media"),
1229 "MediaPlugin::build must declare the [media] config section for strict_config"
1230 );
1231 }
1232}
1233
1234#[cfg(test)]
1236mod primitive_surface_tests {
1237 use std::collections::{HashMap, HashSet};
1238 use std::path::Path;
1239 use std::sync::{Arc, Mutex};
1240 use std::time::{Duration, SystemTime};
1241
1242 use autumn_web::AppState;
1243 use autumn_web::test::TestApp;
1244
1245 use super::MediaPlugin;
1246 use crate::config::{MediaConfig, MediaStorageBackend};
1247 use crate::{
1248 MediaMtxClient, MediaStorage, MediaUrls, MediaWorkflows, RetentionDefer, RoomService,
1249 media_job_infos,
1250 };
1251
1252 #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
1255 struct Installed {
1256 storage: bool,
1257 workflows: bool,
1258 mtx_client: bool,
1259 urls: bool,
1260 rooms: bool,
1261 }
1262
1263 const NOTHING: Installed = Installed {
1264 storage: false,
1265 workflows: false,
1266 mtx_client: false,
1267 urls: false,
1268 rooms: false,
1269 };
1270
1271 fn probe<T: Send + 'static>(
1273 plugin: MediaPlugin,
1274 read: impl Fn(&AppState) -> T + Send + Sync + 'static,
1275 ) -> T {
1276 let seen = Arc::new(Mutex::new(None));
1277 let sink = Arc::clone(&seen);
1278 let _client = TestApp::new()
1279 .plugin(plugin)
1280 .state_initializer(move |state| *sink.lock().unwrap() = Some(read(state)))
1281 .build();
1282 seen.lock().unwrap().take().expect("probe ran")
1283 }
1284
1285 #[tokio::test]
1286 async fn rooms_take_the_session_limit_and_rate_limit_from_config() {
1287 let config = MediaConfig {
1288 room_session_max_seconds: 3600,
1289 room_rate_limit_per_minute: 30,
1290 ..MediaConfig::default()
1291 };
1292 let (rate, session) = probe(MediaPlugin::new().config(config).with_rooms(), |state| {
1293 let rooms = state.extension::<RoomService>().expect("rooms installed");
1294 (rooms.rate_limit_per_minute(), rooms.session_max())
1295 });
1296 assert_eq!(rate, 30);
1297 assert_eq!(session, chrono::Duration::hours(1));
1298 }
1299
1300 fn installed(plugin: MediaPlugin) -> Installed {
1302 probe(plugin, |state| Installed {
1303 storage: state.extension::<MediaStorage>().is_some(),
1304 workflows: state.extension::<MediaWorkflows>().is_some(),
1305 mtx_client: state.extension::<MediaMtxClient>().is_some(),
1306 urls: state.extension::<MediaUrls>().is_some(),
1307 rooms: state.extension::<RoomService>().is_some(),
1308 })
1309 }
1310
1311 #[tokio::test]
1312 async fn no_primitive_installs_nothing() {
1313 assert_eq!(installed(MediaPlugin::new()), NOTHING);
1314 }
1315
1316 #[tokio::test]
1317 async fn broadcast_installs_transport_and_encode() {
1318 let got = installed(MediaPlugin::new().with_broadcast());
1319 assert_eq!(
1320 got,
1321 Installed {
1322 storage: true,
1323 workflows: true,
1324 mtx_client: true,
1325 urls: true,
1326 rooms: false,
1327 }
1328 );
1329 }
1330
1331 #[tokio::test]
1332 async fn rooms_install_rooms_and_encode_but_no_broadcast_transport() {
1333 let got = installed(MediaPlugin::new().with_rooms());
1334 assert_eq!(
1335 got,
1336 Installed {
1337 storage: true,
1338 workflows: true,
1339 mtx_client: false,
1340 urls: false,
1341 rooms: true,
1342 }
1343 );
1344 }
1345
1346 #[tokio::test]
1347 async fn both_primitives_install_everything() {
1348 let got = installed(MediaPlugin::new().with_broadcast().with_rooms());
1349 assert_eq!(
1350 got,
1351 Installed {
1352 storage: true,
1353 workflows: true,
1354 mtx_client: true,
1355 urls: true,
1356 rooms: true,
1357 }
1358 );
1359 }
1360
1361 #[tokio::test]
1362 async fn arroyo_shim_installs_broadcast_transport() {
1363 let got = installed(MediaPlugin::from_arroyo_env_pairs(&HashMap::new()));
1364 assert!(got.mtx_client && got.urls, "{got:?}");
1365 assert!(!got.rooms, "{got:?}");
1366 }
1367
1368 #[tokio::test]
1369 async fn broadcast_transport_survives_a_storage_error() {
1370 let mut config = MediaConfig::default();
1372 config.storage.backend = MediaStorageBackend::S3;
1373 let got = installed(MediaPlugin::new().config(config).with_broadcast());
1374 assert!(got.mtx_client && got.urls, "{got:?}");
1375 assert!(!got.storage && !got.workflows, "{got:?}");
1376 }
1377
1378 #[tokio::test]
1379 async fn transport_uses_the_configured_api_base() {
1380 let mut config = MediaConfig::default();
1381 config.mediamtx.api_base = "http://mediamtx.internal:9997".to_owned();
1382 let api_base = probe(
1383 MediaPlugin::new().config(config).with_broadcast(),
1384 |state| {
1385 state
1386 .extension::<MediaMtxClient>()
1387 .map(|client| client.api_base().to_owned())
1388 },
1389 );
1390 assert_eq!(api_base.as_deref(), Some("http://mediamtx.internal:9997"));
1391 }
1392
1393 fn media_jobs(plugin: MediaPlugin) -> HashSet<String> {
1395 let state = probe(plugin, AppState::clone);
1398 let registered = state.job_registry().snapshot();
1399 media_job_infos("media")
1400 .into_iter()
1401 .map(|info| info.name)
1402 .filter(|name| registered.contains_key(name))
1403 .collect()
1404 }
1405
1406 #[tokio::test]
1407 async fn encode_jobs_register_only_with_a_primitive() {
1408 let all: HashSet<String> = media_job_infos("media")
1409 .into_iter()
1410 .map(|info| info.name)
1411 .collect();
1412 assert!(media_jobs(MediaPlugin::new()).is_empty());
1413 assert_eq!(media_jobs(MediaPlugin::new().with_broadcast()), all);
1414 assert_eq!(media_jobs(MediaPlugin::new().with_rooms()), all);
1415 }
1416
1417 #[test]
1418 fn only_rooms_declare_routes() {
1419 let routes = |plugin| {
1420 autumn_web::app()
1421 .plugin(plugin)
1422 .plugin_route_infos()
1423 .expect("route infos")
1424 };
1425 assert!(routes(MediaPlugin::new()).is_empty());
1426 assert!(routes(MediaPlugin::new().with_broadcast()).is_empty());
1427 assert!(!routes(MediaPlugin::new().with_rooms()).is_empty());
1428 }
1429
1430 async fn sweep_runs(plugin: MediaPlugin, wait: Duration) -> bool {
1433 let root = tempfile::tempdir().expect("tempdir");
1434 let old = root.path().join("old.mp4");
1435 std::fs::write(&old, b"old").expect("write");
1436 std::fs::File::options()
1437 .write(true)
1438 .open(&old)
1439 .and_then(|file| file.set_modified(SystemTime::now() - Duration::from_hours(72)))
1440 .expect("set_modified");
1441
1442 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1443 let defer: RetentionDefer = Arc::new(move |path| {
1445 let _ = tx.send(path);
1446 Box::pin(async { true })
1447 });
1448 let plugin = plugin
1449 .recordings_root(root.path())
1450 .retention_days(1)
1451 .retention_defer(defer);
1452 let _client = TestApp::new().plugin(plugin).build();
1453 let seen = tokio::time::timeout(wait, rx.recv()).await;
1454 assert!(Path::new(&old).exists(), "the defer hook keeps the file");
1455 matches!(seen, Ok(Some(_)))
1456 }
1457
1458 #[tokio::test(flavor = "multi_thread")]
1459 async fn retention_sweep_starts_only_with_a_primitive() {
1460 assert!(sweep_runs(MediaPlugin::new().with_rooms(), Duration::from_secs(5)).await);
1461 assert!(!sweep_runs(MediaPlugin::new(), Duration::from_millis(300)).await);
1462 }
1463}