pub mod config;
pub mod encode;
pub mod error;
pub mod retention;
pub mod rooms;
pub mod rooms_db;
pub mod sink;
pub mod storage;
pub mod transport;
pub mod workflows;
pub use config::{
MediaConfig, MediaConfigError, MediaMtxConfig, MediaStorageBackend, MediaStorageConfig,
RecordingConfig, RoomStoreBackend,
};
pub use encode::{
FfmpegClipTailCommand, FfmpegHighlightCommand, FfmpegLiveThumbnailCommand, FfmpegPosterCommand,
FfmpegPreviewSpriteCommand, FfmpegRoomCompositeCommand, PREVIEW_CELL_HEIGHT,
PREVIEW_CELL_WIDTH, PREVIEW_FRAME_INTERVAL_SECONDS, PREVIEW_SPRITE_COLUMNS,
ROOM_COMPOSITE_CELL_HEIGHT, ROOM_COMPOSITE_CELL_WIDTH, build_preview_webvtt,
newest_recording_file, newest_recording_files, newest_recording_files_since,
recording_segments_covering_window, slugify,
};
pub use error::MediaError;
pub use retention::{
RetentionDefer, RetentionReport, is_expired, recording_expires_at, spawn_retention_sweep_loop,
sweep_recordings_root, within_root,
};
pub use rooms::{
HeartbeatRequest, HeartbeatResponse, InMemoryRoomStore, JoinRecord, JoinRequest, JoinResponse,
LeaveRequest, ParticipantView, PublishTarget, ReapFuture, ReapStats, RoomError,
RoomLeaveResponse, RoomService, RoomSnapshot, RoomStore, RoomStoreFuture, SessionToken,
SubscribeTarget, renewed_expiry, room_participant_path, room_route_infos, room_router,
spawn_room_reaper_loop, validate_room_segment,
};
pub use rooms_db::DbRoomStore;
pub use sink::{
MediaArtifact, MediaArtifactFile, MediaArtifactKind, MediaArtifactSink, MediaArtifactSinkExt,
MediaSinkFuture,
};
pub use storage::{MediaStorage, S3MediaStorage, StoredObject};
pub use transport::{
IngestStatus, MediaMtxClient, MediaUrls, StreamQualityStats, StreamStatus, ViewerCount,
duration_seconds_param, ingest_statuses_from_paths_json, quality_stats_from_path_json,
recording_available, recording_mediamtx_path, stream_status_from_path_json,
viewer_count_from_path_json, viewer_counts_from_paths_json,
};
pub use workflows::{
FinalizeRecordingJobArgs, MediaWorkflowDelegate, MediaWorkflowDelegateExt,
MediaWorkflowRequest, MediaWorkflows, PreviewJobArgs, RoomCompositeJobArgs, ThumbnailJobArgs,
TranscodeJobArgs, media_job_infos,
};
pub mod prelude {
pub use crate::{
FfmpegClipTailCommand, FfmpegHighlightCommand, FfmpegLiveThumbnailCommand,
FfmpegPosterCommand, FfmpegPreviewSpriteCommand, FfmpegRoomCompositeCommand,
FinalizeRecordingJobArgs, MediaArtifact, MediaArtifactFile, MediaArtifactKind,
MediaArtifactSink, MediaArtifactSinkExt, MediaConfig, MediaConfigError, MediaError,
MediaMtxConfig, MediaPlugin, MediaStorage, MediaStorageBackend, MediaStorageConfig,
MediaWorkflowDelegate, MediaWorkflowDelegateExt, MediaWorkflowRequest, MediaWorkflows,
PREVIEW_CELL_HEIGHT, PREVIEW_CELL_WIDTH, PREVIEW_FRAME_INTERVAL_SECONDS,
PREVIEW_SPRITE_COLUMNS, PreviewJobArgs, ROOM_COMPOSITE_CELL_HEIGHT,
ROOM_COMPOSITE_CELL_WIDTH, RecordingConfig, RetentionDefer, RoomCompositeJobArgs,
S3MediaStorage, StoredObject, ThumbnailJobArgs, TranscodeJobArgs, build_preview_webvtt,
media_job_infos, newest_recording_file, newest_recording_files,
newest_recording_files_since, recording_segments_covering_window, slugify,
};
pub use crate::{
HeartbeatResponse, InMemoryRoomStore, JoinRecord, JoinResponse, ParticipantView, ReapStats,
RoomError, RoomService, RoomSnapshot, RoomStore, SessionToken, renewed_expiry,
room_participant_path, room_route_infos, room_router, spawn_room_reaper_loop,
validate_room_segment,
};
pub use crate::{
IngestStatus, MediaMtxClient, MediaUrls, StreamQualityStats, StreamStatus, ViewerCount,
duration_seconds_param, ingest_statuses_from_paths_json, quality_stats_from_path_json,
recording_available, recording_mediamtx_path, stream_status_from_path_json,
viewer_count_from_path_json, viewer_counts_from_paths_json,
};
}
use std::borrow::Cow;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use autumn_web::app::AppBuilder;
use autumn_web::plugin::Plugin;
use crate::config::DEFAULT_ROOM_MAX_PARTICIPANTS;
use crate::retention::RetentionDefer as RetentionDeferHook;
use crate::sink::MediaArtifactSink as MediaArtifactSinkTrait;
use crate::workflows::MediaWorkflowDelegate as MediaWorkflowDelegateHook;
pub struct MediaPlugin {
config: MediaConfig,
enable_broadcast: bool,
enable_rooms: bool,
room_max_participants: usize,
queue: String,
api_prefix: String,
artifact_sink: Option<Arc<dyn MediaArtifactSinkTrait>>,
workflow_delegate: Option<MediaWorkflowDelegateHook>,
retention_days: Option<u32>,
recordings_root: Option<PathBuf>,
retention_defer: Option<RetentionDeferHook>,
}
impl MediaPlugin {
#[must_use]
pub fn new() -> Self {
Self {
config: MediaConfig::default(),
enable_broadcast: false,
enable_rooms: false,
room_max_participants: DEFAULT_ROOM_MAX_PARTICIPANTS,
queue: "media".to_owned(),
api_prefix: "/api/media".to_owned(),
artifact_sink: None,
workflow_delegate: None,
retention_days: None,
recordings_root: None,
retention_defer: None,
}
}
#[must_use]
pub fn from_arroyo_env() -> Self {
let env: HashMap<String, String> = std::env::vars().collect();
Self::from_arroyo_env_pairs(&env)
}
#[must_use]
pub fn from_arroyo_env_pairs(env: &HashMap<String, String>) -> Self {
let config = MediaConfig::from_arroyo_env_pairs(env);
Self::new()
.config(config)
.with_broadcast()
.recordings_root(arroyo_recordings_root(env))
}
#[must_use]
pub fn config(mut self, config: MediaConfig) -> Self {
self.room_max_participants = config.room_max_participants;
self.config = config;
self
}
#[must_use]
pub const fn with_broadcast(mut self) -> Self {
self.enable_broadcast = true;
self
}
#[must_use]
pub const fn with_rooms(mut self) -> Self {
self.enable_rooms = true;
self
}
#[must_use]
pub const fn room_max_participants(mut self, max: usize) -> Self {
self.room_max_participants = max;
self
}
#[must_use]
pub const fn room_token_ttl_seconds(mut self, seconds: u32) -> Self {
self.config.room_token_ttl_seconds = seconds;
self
}
#[must_use]
pub const fn room_session_max_seconds(mut self, seconds: u32) -> Self {
self.config.room_session_max_seconds = seconds;
self
}
#[must_use]
pub fn room_namespace(mut self, namespace: impl Into<String>) -> Self {
self.config.room_namespace = Some(namespace.into());
self
}
#[must_use]
pub fn queue(mut self, queue: impl Into<String>) -> Self {
self.queue = queue.into();
self
}
#[must_use]
pub fn api(mut self, prefix: impl Into<String>) -> Self {
self.api_prefix = prefix.into();
self
}
#[must_use]
pub fn ffmpeg_bin(mut self, bin: impl Into<String>) -> Self {
self.config.ffmpeg.bin = bin.into();
self
}
#[must_use]
pub fn artifact_sink(mut self, sink: Arc<dyn MediaArtifactSinkTrait>) -> Self {
self.artifact_sink = Some(sink);
self
}
#[must_use]
pub fn workflow_delegate(mut self, delegate: MediaWorkflowDelegateHook) -> Self {
self.workflow_delegate = Some(delegate);
self
}
#[must_use]
pub const fn retention_days(mut self, days: u32) -> Self {
self.retention_days = Some(days);
self
}
#[must_use]
pub fn recordings_root(mut self, root: impl Into<PathBuf>) -> Self {
self.recordings_root = Some(root.into());
self
}
#[must_use]
pub fn retention_defer(mut self, defer: RetentionDeferHook) -> Self {
self.retention_defer = Some(defer);
self
}
}
impl Default for MediaPlugin {
fn default() -> Self {
Self::new()
}
}
impl Plugin for MediaPlugin {
fn contract(&self) -> Option<autumn_web::plugin_contract::PluginContract> {
Some(autumn_web::plugin_contract::lockstep_contract(
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION"),
))
}
fn name(&self) -> Cow<'static, str> {
Cow::Borrowed("autumn-media-plugin")
}
#[allow(clippy::too_many_lines)]
fn build(self, app: AppBuilder) -> AppBuilder {
let Self {
config,
enable_broadcast,
enable_rooms,
room_max_participants,
queue,
api_prefix,
artifact_sink,
workflow_delegate,
retention_days,
recordings_root,
retention_defer,
} = self;
let app = app.config_section("media");
let retention_days = retention_days.unwrap_or(config.recording.retention_days);
if let Some(message) = room_config_boot_error(enable_rooms, room_max_participants, &config)
{
tracing::error!(
room_max_participants,
ceiling = DEFAULT_ROOM_MAX_PARTICIPANTS,
"🍂 Autumn Media: {message}"
);
return app.on_startup(move |_state| {
let message = message.clone();
async move { Err(autumn_web::AutumnError::internal_server_error_msg(message)) }
});
}
tracing::info!(
broadcast = %enable_broadcast,
rooms = %enable_rooms,
room_max_participants,
room_store_backend = config.room_store_backend.as_str(),
storage_backend = config.storage.backend.as_str(),
queue = %queue,
api_prefix = %api_prefix,
has_artifact_sink = artifact_sink.is_some(),
has_workflow_delegate = workflow_delegate.is_some(),
retention_days,
"🍂 Autumn Media configured"
);
if !enable_broadcast && !enable_rooms {
tracing::warn!(
recordings_root_ignored = recordings_root.is_some(),
"🍂 Autumn Media: no primitive enabled; call `with_broadcast()` or \
`with_rooms()`. The plugin installs nothing, and no retention sweep starts."
);
return app;
}
let mut app = app;
if enable_rooms {
let backend = config.room_store_backend;
let shared_memory_store: Option<Arc<dyn rooms::RoomStore>> = match backend {
config::RoomStoreBackend::Memory => Some(Arc::new(rooms::InMemoryRoomStore::new(
room_max_participants,
))),
config::RoomStoreBackend::Db => None,
};
let room_urls = transport::MediaUrls::from_config(&config.mediamtx);
let room_namespace = config.room_namespace.clone().unwrap_or_default();
let room_token_ttl =
chrono::Duration::seconds(i64::from(config.room_token_ttl_seconds));
let room_max_session =
chrono::Duration::seconds(i64::from(config.room_session_max_seconds));
let room_rate_limit = config.room_rate_limit_per_minute;
let init_store = shared_memory_store.clone();
app = app
.nest(&api_prefix, rooms::room_router())
.declare_plugin_routes(rooms::room_route_infos(&api_prefix))
.state_initializer(move |state| {
let store = init_store
.clone()
.unwrap_or_else(|| build_room_store(state, room_max_participants));
let room_service = rooms::RoomService::new(
store,
room_urls.clone(),
room_namespace.clone(),
room_token_ttl,
room_max_participants,
)
.with_session_max(room_max_session)
.with_rate_limit_per_minute(room_rate_limit);
state.insert_extension(room_service);
})
.on_startup(move |state| {
let store = shared_memory_store
.clone()
.unwrap_or_else(|| build_room_store(&state, room_max_participants));
async move {
rooms::spawn_room_reaper_loop(store);
Ok(())
}
});
}
if enable_broadcast {
let mtx_client = transport::MediaMtxClient::new(&config.mediamtx);
let urls = transport::MediaUrls::from_config(&config.mediamtx);
app = app.state_initializer(move |state| {
state.insert_extension(mtx_client);
state.insert_extension(urls);
});
}
let storage = match storage::MediaStorage::from_config(&config.storage) {
Ok(storage) => storage,
Err(error) => {
tracing::error!(
%error,
"🍂 Autumn Media: storage config invalid; encode workflows disabled"
);
return app;
}
};
let ffmpeg_bin = config.ffmpeg.bin;
let workflows = workflows::MediaWorkflows::new(ffmpeg_bin, queue.clone());
let app = app
.state_initializer(move |state| {
state.insert_extension(storage.clone());
state.insert_extension(workflows.clone());
if let Some(sink) = &artifact_sink {
state.insert_extension(sink::MediaArtifactSinkExt(sink.clone()));
}
if let Some(delegate) = &workflow_delegate {
state.insert_extension(workflows::MediaWorkflowDelegateExt(delegate.clone()));
}
})
.jobs(workflows::media_job_infos(&queue));
if let Some(root) = recordings_root {
app.on_startup(move |_state| {
let root = root.clone();
let defer = retention_defer.clone();
async move {
retention::spawn_retention_sweep_loop(root, retention_days, defer);
Ok(())
}
})
} else {
if retention_days > 0 {
tracing::debug!(
"🍂 Autumn Media: retention window set but no recordings_root; sweep not spawned"
);
}
app
}
}
}
fn room_max_participants_error(configured: usize) -> Option<String> {
if configured == 0 {
Some(format!(
"a room must allow at least 1 participant; got {configured}"
))
} else if configured > DEFAULT_ROOM_MAX_PARTICIPANTS {
Some(format!(
"mesh rooms are capped at {DEFAULT_ROOM_MAX_PARTICIPANTS} participants (no SFU); got {configured}"
))
} else {
None
}
}
fn room_namespace_error(namespace: Option<&str>) -> Option<String> {
let ns = namespace?;
if ns.is_empty() || rooms::validate_room_segment(ns).is_ok() {
return None;
}
Some(format!(
"media.room_namespace {ns:?} is not a valid room path segment: use only ASCII letters, digits, '_' or '-' (no '/', '.', or empty)"
))
}
fn room_config_boot_error(
enable_rooms: bool,
room_max_participants: usize,
config: &MediaConfig,
) -> Option<String> {
if !enable_rooms {
return None;
}
room_max_participants_error(room_max_participants)
.or_else(|| room_namespace_error(config.room_namespace.as_deref()))
.or_else(|| room_session_max_error(config))
}
fn room_session_max_error(config: &MediaConfig) -> Option<String> {
let session_max = config.room_session_max_seconds;
let token_ttl = config.room_token_ttl_seconds;
(session_max < token_ttl).then(|| {
format!(
"media.room_session_max_seconds ({session_max}) must be at least \
media.room_token_ttl_seconds ({token_ttl})"
)
})
}
fn build_room_store(state: &autumn_web::AppState, cap: usize) -> Arc<dyn rooms::RoomStore> {
if let Some(pool) = state.pool().cloned() {
Arc::new(rooms_db::DbRoomStore::new(pool, cap))
} else {
tracing::error!(
"🍂 Autumn Media: room_store_backend=\"db\" but no database pool is configured; \
falling back to the in-memory room store — rooms will NOT survive across \
processes. Configure a `[database]` primary_url to enable the shared store."
);
Arc::new(rooms::InMemoryRoomStore::new(cap))
}
}
const DEFAULT_ARROYO_RECORDINGS_ROOT: &str = "recordings";
fn arroyo_recordings_root(env: &HashMap<String, String>) -> PathBuf {
env.get("ARROYO_RECORDINGS_ROOT")
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map_or_else(
|| PathBuf::from(DEFAULT_ARROYO_RECORDINGS_ROOT),
PathBuf::from,
)
}
#[cfg(test)]
mod contract_tests {
use super::MediaPlugin;
use autumn_web::plugin::Plugin;
#[test]
fn contract_declares_lockstep_with_own_crate() {
let contract = MediaPlugin::new().contract().expect("a contract");
assert_eq!(contract.plugin, env!("CARGO_PKG_NAME"));
assert_eq!(
contract.plugin_version.as_deref(),
Some(env!("CARGO_PKG_VERSION"))
);
assert_eq!(
contract.autumn_web.as_deref(),
Some(autumn_web::plugin_contract::lockstep_range(env!("CARGO_PKG_VERSION")).as_str())
);
assert!(contract.experimental_surfaces.is_empty());
}
}
#[cfg(test)]
mod arroyo_shim_tests {
use super::{DEFAULT_ARROYO_RECORDINGS_ROOT, MediaPlugin, arroyo_recordings_root};
use crate::config::MediaStorageBackend;
use std::collections::HashMap;
use std::path::PathBuf;
fn env(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
.collect()
}
#[test]
fn empty_env_is_wired_broadcast_local_plugin() {
let plugin = MediaPlugin::from_arroyo_env_pairs(&HashMap::new());
assert!(plugin.enable_broadcast, "broadcast must be enabled");
assert!(!plugin.enable_rooms, "rooms must stay disabled");
assert_eq!(plugin.config.storage.backend, MediaStorageBackend::Local);
assert_eq!(plugin.config.mediamtx.api_base, "http://127.0.0.1:9997");
assert_eq!(plugin.config.ffmpeg.bin, "/usr/bin/ffmpeg");
assert_eq!(
plugin.recordings_root.as_deref(),
Some(std::path::Path::new(DEFAULT_ARROYO_RECORDINGS_ROOT))
);
assert!(plugin.config.validate().is_ok());
}
#[test]
fn maps_full_s3_environment_and_validates() {
let vars = env(&[
("ARROYO_VIDEO_STORAGE_BACKEND", "s3"),
("BUCKET_NAME", "arroyo-bucket"),
("AWS_ACCESS_KEY_ID", "AKIA"),
("AWS_SECRET_ACCESS_KEY", "secret"),
("ARROYO_MEDIAMTX_API_BASE", "http://mtx:9997"),
("ARROYO_FFMPEG_BIN", "/opt/ffmpeg"),
("ARROYO_RECORDING_RETENTION_DAYS", "21"),
("ARROYO_RECORDINGS_ROOT", "/data/recordings"),
]);
let plugin = MediaPlugin::from_arroyo_env_pairs(&vars);
assert!(plugin.enable_broadcast);
assert_eq!(plugin.config.storage.backend, MediaStorageBackend::S3);
assert_eq!(
plugin.config.storage.bucket.as_deref(),
Some("arroyo-bucket")
);
assert_eq!(plugin.config.storage.region.as_deref(), Some("auto"));
assert_eq!(
plugin.config.storage.endpoint_url.as_deref(),
Some("https://t3.storage.dev")
);
assert_eq!(plugin.config.storage.key_prefix, "highlights");
assert_eq!(plugin.config.mediamtx.api_base, "http://mtx:9997");
assert_eq!(plugin.config.ffmpeg.bin, "/opt/ffmpeg");
assert_eq!(plugin.config.recording.retention_days, 21);
assert_eq!(
plugin.recordings_root.as_deref(),
Some(std::path::Path::new("/data/recordings"))
);
assert!(plugin.config.validate().is_ok());
}
#[test]
fn recordings_root_honors_env_and_defaults() {
assert_eq!(
arroyo_recordings_root(&env(&[("ARROYO_RECORDINGS_ROOT", "/mnt/rec")])),
PathBuf::from("/mnt/rec")
);
assert_eq!(
arroyo_recordings_root(&env(&[("ARROYO_RECORDINGS_ROOT", " ")])),
PathBuf::from(DEFAULT_ARROYO_RECORDINGS_ROOT)
);
assert_eq!(
arroyo_recordings_root(&HashMap::new()),
PathBuf::from(DEFAULT_ARROYO_RECORDINGS_ROOT)
);
}
#[test]
fn returned_plugin_stays_chainable() {
let plugin = MediaPlugin::from_arroyo_env_pairs(&HashMap::new())
.queue("arroyo-media")
.retention_days(30);
assert!(plugin.enable_broadcast);
assert_eq!(plugin.queue, "arroyo-media");
assert_eq!(plugin.retention_days, Some(30));
}
}
#[cfg(test)]
mod room_cap_tests {
use super::{DEFAULT_ROOM_MAX_PARTICIPANTS, MediaPlugin, room_max_participants_error};
use crate::config::MediaConfig;
#[test]
fn out_of_range_room_cap_is_a_specific_fail_fast_error() {
let over = room_max_participants_error(50).expect("50 > 6 must be rejected");
assert!(over.contains("50"), "names the offending value: {over}");
assert!(over.contains('6'), "names the ceiling: {over}");
assert!(over.contains("no SFU"), "explains why: {over}");
let zero = room_max_participants_error(0).expect("0 must be rejected");
assert!(zero.contains("at least 1 participant"), "0 message: {zero}");
assert!(zero.contains('0'), "names the value: {zero}");
}
#[test]
fn in_range_room_cap_is_accepted() {
assert!(room_max_participants_error(1).is_none());
assert!(room_max_participants_error(4).is_none());
assert!(room_max_participants_error(DEFAULT_ROOM_MAX_PARTICIPANTS).is_none());
}
#[test]
fn builder_and_config_paths_feed_the_same_effective_cap_that_boot_rejects() {
let over = MediaPlugin::new().with_rooms().room_max_participants(50);
assert_eq!(over.room_max_participants, 50);
assert!(room_max_participants_error(over.room_max_participants).is_some());
let ok = MediaPlugin::new().with_rooms().room_max_participants(4);
assert_eq!(ok.room_max_participants, 4);
assert!(room_max_participants_error(ok.room_max_participants).is_none());
let config = MediaConfig {
room_max_participants: 50,
..MediaConfig::default()
};
let from_config = MediaPlugin::new().config(config);
assert_eq!(from_config.room_max_participants, 50);
assert!(room_max_participants_error(from_config.room_max_participants).is_some());
}
}
#[cfg(test)]
mod room_namespace_tests {
use super::{MediaPlugin, room_namespace_error};
#[test]
fn absent_empty_and_valid_namespaces_are_accepted() {
assert!(room_namespace_error(None).is_none());
assert!(room_namespace_error(Some("")).is_none());
assert!(room_namespace_error(Some("tenant-a")).is_none());
assert!(room_namespace_error(Some("room_1")).is_none());
}
#[test]
fn invalid_namespace_is_a_specific_fail_fast_error() {
for bad in ["tenant/a", ".", "..", "a b"] {
let message = room_namespace_error(Some(bad))
.unwrap_or_else(|| panic!("{bad:?} must be rejected"));
assert!(
message.contains(bad),
"names the offending value: {message}"
);
assert!(
message.contains("letters") && message.contains('-'),
"mentions the allowed charset: {message}"
);
}
}
#[test]
fn room_namespace_builder_feeds_the_effective_value_that_boot_rejects() {
let bad = MediaPlugin::new().with_rooms().room_namespace("tenant/a");
assert_eq!(bad.config.room_namespace.as_deref(), Some("tenant/a"));
assert!(room_namespace_error(bad.config.room_namespace.as_deref()).is_some());
let ok = MediaPlugin::new().with_rooms().room_namespace("tenant-a");
assert_eq!(ok.config.room_namespace.as_deref(), Some("tenant-a"));
assert!(room_namespace_error(ok.config.room_namespace.as_deref()).is_none());
}
}
#[cfg(test)]
mod room_config_gate_tests {
use super::{MediaConfig, MediaPlugin, room_config_boot_error};
fn with_namespace(namespace: &str) -> MediaConfig {
MediaConfig {
room_namespace: Some(namespace.to_owned()),
..MediaConfig::default()
}
}
#[test]
fn broadcast_only_ignores_invalid_room_config() {
assert!(!MediaPlugin::new().with_broadcast().enable_rooms);
assert!(room_config_boot_error(false, 0, &MediaConfig::default()).is_none());
assert!(room_config_boot_error(false, 50, &with_namespace("bad/ns")).is_none());
assert!(room_config_boot_error(false, 4, &with_namespace("tenant-a")).is_none());
let short_session = MediaConfig {
room_session_max_seconds: 1,
..MediaConfig::default()
};
assert!(room_config_boot_error(false, 4, &short_session).is_none());
}
#[test]
fn rooms_enabled_still_fails_fast_on_invalid_room_config() {
assert!(MediaPlugin::new().with_rooms().enable_rooms);
let bad_cap = room_config_boot_error(true, 0, &MediaConfig::default())
.expect("rooms-enabled must fail fast on room_max_participants = 0");
assert!(bad_cap.contains('0'), "names the offending cap: {bad_cap}");
let bad_ns = room_config_boot_error(true, 4, &with_namespace("bad/ns"))
.expect("rooms-enabled must fail fast on an invalid room_namespace");
assert!(
bad_ns.contains("bad/ns"),
"names the offending namespace: {bad_ns}"
);
assert!(room_config_boot_error(true, 4, &with_namespace("tenant-a")).is_none());
}
#[test]
fn rooms_enabled_fails_fast_on_a_session_max_less_than_the_token_ttl() {
let short_session = MediaConfig {
room_token_ttl_seconds: 300,
room_session_max_seconds: 60,
..MediaConfig::default()
};
let message = room_config_boot_error(true, 4, &short_session)
.expect("a session shorter than its token must abort boot");
assert!(message.contains("60"), "names the session limit: {message}");
assert!(message.contains("300"), "names the token TTL: {message}");
}
}
#[cfg(test)]
mod conformance_tests {
use autumn_web::plugin_conformance::{
CheckStatus, ConformanceConfig, check_collisions, check_duplicate_registration,
check_route_attribution, check_route_prefix, check_sensitive_surfaces, run_conformance,
};
use autumn_web::route_listing::{RouteInfo, RouteSource};
const PLUGIN_NAME: &str = "autumn-media-plugin";
const API_PREFIX: &str = "/api/media";
fn declared_room_routes() -> Vec<RouteInfo> {
super::rooms::room_route_infos(API_PREFIX)
.into_iter()
.map(|mut route| {
route.source = RouteSource::Plugin(PLUGIN_NAME.to_owned());
route
})
.collect()
}
#[test]
fn build_declares_the_five_room_routes_when_rooms_enabled() {
let routes = super::rooms::room_route_infos(API_PREFIX);
assert_eq!(routes.len(), 5, "rooms declare exactly five routes");
assert!(
routes
.iter()
.all(|route| route.path.starts_with(API_PREFIX)),
"every declared room route lives under the API prefix"
);
}
#[test]
fn room_routes_are_attributed_to_plugin_name() {
let result = check_route_attribution(PLUGIN_NAME, &declared_room_routes());
assert_eq!(
result.status,
CheckStatus::Pass,
"attribution failed: {}",
result.message
);
}
#[test]
fn room_routes_live_under_api_prefix() {
let result = check_route_prefix(PLUGIN_NAME, API_PREFIX, &[], &declared_room_routes());
assert_eq!(
result.status,
CheckStatus::Pass,
"prefix check failed: {}",
result.message
);
}
#[test]
fn room_routes_have_no_collisions_in_isolation() {
let (result, _) = check_collisions(&declared_room_routes());
assert_eq!(
result.status,
CheckStatus::Pass,
"unexpected collision: {}",
result.message
);
}
#[test]
fn room_routes_have_no_undeclared_sensitive_surfaces() {
let result = check_sensitive_surfaces(PLUGIN_NAME, &declared_room_routes(), &[]);
assert_eq!(
result.status,
CheckStatus::Pass,
"sensitive-surfaces check failed: {}",
result.message
);
}
#[test]
fn room_single_registration_passes_duplicate_check() {
let result = check_duplicate_registration(PLUGIN_NAME, &declared_room_routes());
assert_eq!(
result.status,
CheckStatus::Pass,
"single registration should pass: {}",
result.message
);
}
#[test]
fn room_routes_pass_full_conformance() {
let config = ConformanceConfig::new(PLUGIN_NAME).prefix(API_PREFIX);
let report = run_conformance(&config, &declared_room_routes());
assert!(
report.passed(),
"MediaPlugin conformance failed:\n{}",
report.to_text_report()
);
}
#[test]
fn duplicate_registration_detected() {
let mut routes = declared_room_routes();
routes.extend(declared_room_routes());
let result = check_duplicate_registration(PLUGIN_NAME, &routes);
assert_eq!(
result.status,
CheckStatus::Fail,
"expected duplicate-registration FAIL when installed twice"
);
}
#[test]
fn collision_with_host_route_detected() {
let mut routes = declared_room_routes();
routes.push(RouteInfo {
method: "GET".to_owned(),
path: format!("{API_PREFIX}/rooms/{{room_id}}"),
handler: "host::roster".to_owned(),
source: RouteSource::User,
..Default::default()
});
let (result, _) = check_collisions(&routes);
assert_eq!(
result.status,
CheckStatus::Fail,
"expected collision to be detected"
);
}
}
#[cfg(test)]
mod config_section_tests {
use super::MediaPlugin;
#[test]
fn build_declares_media_config_section() {
let builder = autumn_web::app().plugin(MediaPlugin::new());
assert!(
builder.has_config_section("media"),
"MediaPlugin::build must declare the [media] config section for strict_config"
);
}
}
#[cfg(test)]
mod primitive_surface_tests {
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime};
use autumn_web::AppState;
use autumn_web::test::TestApp;
use super::MediaPlugin;
use crate::config::{MediaConfig, MediaStorageBackend};
use crate::{
MediaMtxClient, MediaStorage, MediaUrls, MediaWorkflows, RetentionDefer, RoomService,
media_job_infos,
};
#[allow(clippy::struct_excessive_bools)] #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct Installed {
storage: bool,
workflows: bool,
mtx_client: bool,
urls: bool,
rooms: bool,
}
const NOTHING: Installed = Installed {
storage: false,
workflows: false,
mtx_client: false,
urls: false,
rooms: false,
};
fn probe<T: Send + 'static>(
plugin: MediaPlugin,
read: impl Fn(&AppState) -> T + Send + Sync + 'static,
) -> T {
let seen = Arc::new(Mutex::new(None));
let sink = Arc::clone(&seen);
let _client = TestApp::new()
.plugin(plugin)
.state_initializer(move |state| *sink.lock().unwrap() = Some(read(state)))
.build();
seen.lock().unwrap().take().expect("probe ran")
}
#[tokio::test]
async fn rooms_take_the_session_limit_and_rate_limit_from_config() {
let config = MediaConfig {
room_session_max_seconds: 3600,
room_rate_limit_per_minute: 30,
..MediaConfig::default()
};
let (rate, session) = probe(MediaPlugin::new().config(config).with_rooms(), |state| {
let rooms = state.extension::<RoomService>().expect("rooms installed");
(rooms.rate_limit_per_minute(), rooms.session_max())
});
assert_eq!(rate, 30);
assert_eq!(session, chrono::Duration::hours(1));
}
fn installed(plugin: MediaPlugin) -> Installed {
probe(plugin, |state| Installed {
storage: state.extension::<MediaStorage>().is_some(),
workflows: state.extension::<MediaWorkflows>().is_some(),
mtx_client: state.extension::<MediaMtxClient>().is_some(),
urls: state.extension::<MediaUrls>().is_some(),
rooms: state.extension::<RoomService>().is_some(),
})
}
#[tokio::test]
async fn no_primitive_installs_nothing() {
assert_eq!(installed(MediaPlugin::new()), NOTHING);
}
#[tokio::test]
async fn broadcast_installs_transport_and_encode() {
let got = installed(MediaPlugin::new().with_broadcast());
assert_eq!(
got,
Installed {
storage: true,
workflows: true,
mtx_client: true,
urls: true,
rooms: false,
}
);
}
#[tokio::test]
async fn rooms_install_rooms_and_encode_but_no_broadcast_transport() {
let got = installed(MediaPlugin::new().with_rooms());
assert_eq!(
got,
Installed {
storage: true,
workflows: true,
mtx_client: false,
urls: false,
rooms: true,
}
);
}
#[tokio::test]
async fn both_primitives_install_everything() {
let got = installed(MediaPlugin::new().with_broadcast().with_rooms());
assert_eq!(
got,
Installed {
storage: true,
workflows: true,
mtx_client: true,
urls: true,
rooms: true,
}
);
}
#[tokio::test]
async fn arroyo_shim_installs_broadcast_transport() {
let got = installed(MediaPlugin::from_arroyo_env_pairs(&HashMap::new()));
assert!(got.mtx_client && got.urls, "{got:?}");
assert!(!got.rooms, "{got:?}");
}
#[tokio::test]
async fn broadcast_transport_survives_a_storage_error() {
let mut config = MediaConfig::default();
config.storage.backend = MediaStorageBackend::S3;
let got = installed(MediaPlugin::new().config(config).with_broadcast());
assert!(got.mtx_client && got.urls, "{got:?}");
assert!(!got.storage && !got.workflows, "{got:?}");
}
#[tokio::test]
async fn transport_uses_the_configured_api_base() {
let mut config = MediaConfig::default();
config.mediamtx.api_base = "http://mediamtx.internal:9997".to_owned();
let api_base = probe(
MediaPlugin::new().config(config).with_broadcast(),
|state| {
state
.extension::<MediaMtxClient>()
.map(|client| client.api_base().to_owned())
},
);
assert_eq!(api_base.as_deref(), Some("http://mediamtx.internal:9997"));
}
fn media_jobs(plugin: MediaPlugin) -> HashSet<String> {
let state = probe(plugin, AppState::clone);
let registered = state.job_registry().snapshot();
media_job_infos("media")
.into_iter()
.map(|info| info.name)
.filter(|name| registered.contains_key(name))
.collect()
}
#[tokio::test]
async fn encode_jobs_register_only_with_a_primitive() {
let all: HashSet<String> = media_job_infos("media")
.into_iter()
.map(|info| info.name)
.collect();
assert!(media_jobs(MediaPlugin::new()).is_empty());
assert_eq!(media_jobs(MediaPlugin::new().with_broadcast()), all);
assert_eq!(media_jobs(MediaPlugin::new().with_rooms()), all);
}
#[test]
fn only_rooms_declare_routes() {
let routes = |plugin| {
autumn_web::app()
.plugin(plugin)
.plugin_route_infos()
.expect("route infos")
};
assert!(routes(MediaPlugin::new()).is_empty());
assert!(routes(MediaPlugin::new().with_broadcast()).is_empty());
assert!(!routes(MediaPlugin::new().with_rooms()).is_empty());
}
async fn sweep_runs(plugin: MediaPlugin, wait: Duration) -> bool {
let root = tempfile::tempdir().expect("tempdir");
let old = root.path().join("old.mp4");
std::fs::write(&old, b"old").expect("write");
std::fs::File::options()
.write(true)
.open(&old)
.and_then(|file| file.set_modified(SystemTime::now() - Duration::from_hours(72)))
.expect("set_modified");
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let defer: RetentionDefer = Arc::new(move |path| {
let _ = tx.send(path);
Box::pin(async { true })
});
let plugin = plugin
.recordings_root(root.path())
.retention_days(1)
.retention_defer(defer);
let _client = TestApp::new().plugin(plugin).build();
let seen = tokio::time::timeout(wait, rx.recv()).await;
assert!(Path::new(&old).exists(), "the defer hook keeps the file");
matches!(seen, Ok(Some(_)))
}
#[tokio::test(flavor = "multi_thread")]
async fn retention_sweep_starts_only_with_a_primitive() {
assert!(sweep_runs(MediaPlugin::new().with_rooms(), Duration::from_secs(5)).await);
assert!(!sweep_runs(MediaPlugin::new(), Duration::from_millis(300)).await);
}
}