Skip to main content

autumn_media_plugin/
lib.rs

1//! # autumn-media-plugin
2//!
3//! Live-streaming media plugin for `autumn-web` applications. It packages the
4//! two primitives an interactive streaming product needs:
5//!
6//! - **Broadcast** — one creator ingests (RTMP / WHIP / browser WebRTC) and a
7//!   fan-out audience watches over low-latency WebRTC/WHEP with HLS fallback,
8//!   backed by `MediaMTX`; recordings become VODs and clip/highlight encodes.
9//! - **Room** — a small (mesh, no SFU) multi-participant call capped at
10//!   [`config::DEFAULT_ROOM_MAX_PARTICIPANTS`] participants.
11//!
12//! Both primitives are off by default. Enable each one on the builder:
13//!
14//! | Builder call | Installs |
15//! |---|---|
16//! | [`with_broadcast`](MediaPlugin::with_broadcast) | [`MediaMtxClient`] and [`MediaUrls`] extensions |
17//! | [`with_rooms`](MediaPlugin::with_rooms) | Room routes, [`RoomService`] and the room reaper |
18//! | Either one | [`MediaStorage`], [`MediaWorkflows`] and the encode jobs |
19//!
20//! The retention sweep also starts if you set
21//! [`recordings_root`](MediaPlugin::recordings_root). If the storage config is
22//! not valid, the plugin installs no storage, workflows, jobs or sweep.
23//!
24//! If you enable neither primitive, the plugin installs no routes, extensions or
25//! jobs. It logs a warning.
26//!
27//! Because Autumn resolves config *after* [`Plugin::build`] runs, the plugin
28//! cannot read `[media]` from inside `build`; the application loads a
29//! [`MediaConfig`](config::MediaConfig) up front and passes it in via
30//! [`MediaPlugin::config`] (mirroring the `autumn-storage-s3`
31//! `from_config(&cfg) -> …with_blob_store(store)` pattern).
32//!
33//! ```rust,ignore
34//! use autumn_media_plugin::{prelude::*, MediaPlugin};
35//!
36//! let media = MediaConfig::from_autumn_toml("autumn.toml")?;
37//! autumn_web::app()
38//!     .plugin(MediaPlugin::new().config(media).with_broadcast())
39//!     .run()
40//!     .await;
41//! ```
42//!
43//! # Naming convention
44//!
45//! First-party plugin: `autumn-<name>-plugin`.
46
47pub 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
100/// Common downstream imports for configuring and mounting the media plugin.
101pub 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
143/// The live-streaming media plugin.
144///
145/// Configure the two primitives with [`with_broadcast`](Self::with_broadcast)
146/// and [`with_rooms`](Self::with_rooms), supply a
147/// [`MediaConfig`](config::MediaConfig) with [`config`](Self::config), then
148/// install with `app.plugin(...)`.
149///
150/// See the crate docs for what each primitive installs.
151pub struct MediaPlugin {
152    /// Resolved `[media]` configuration.
153    config: MediaConfig,
154    /// Whether the broadcast primitive is enabled.
155    enable_broadcast: bool,
156    /// Whether the rooms primitive is enabled.
157    enable_rooms: bool,
158    /// Hard cap on mesh-room participants.
159    room_max_participants: usize,
160    /// Job queue name the built-in media encode jobs are registered on.
161    queue: String,
162    /// URL prefix for the plugin's API routes.
163    api_prefix: String,
164    /// App-supplied artifact completion callback (parallels the
165    /// `OutboundWebhookHandler` store).
166    artifact_sink: Option<Arc<dyn MediaArtifactSinkTrait>>,
167    /// Optional runtime delegate that overrides the built-in `#[job]` engine
168    /// (e.g. an external Harvest adapter).
169    workflow_delegate: Option<MediaWorkflowDelegateHook>,
170    /// Retention window override (days). `None` uses `config.recording.retention_days`.
171    retention_days: Option<u32>,
172    /// Filesystem root the retention sweep operates on. `None` disables the
173    /// sweep (there is nothing to sweep without a recordings root).
174    recordings_root: Option<PathBuf>,
175    /// App-overridable retention-defer predicate.
176    retention_defer: Option<RetentionDeferHook>,
177}
178
179impl MediaPlugin {
180    /// Create a media plugin with default configuration and both primitives
181    /// disabled (opt in with [`with_broadcast`](Self::with_broadcast) /
182    /// [`with_rooms`](Self::with_rooms)).
183    #[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    /// Build a fully-wired broadcast [`MediaPlugin`] from an Arroyo operator's
201    /// existing `ARROYO_*` environment — the ratified migration shim (issue
202    /// #1974, slice 5). One call maps the operator's env onto a plugin ready to
203    /// install, so adopting `autumn-media-plugin` changes no ops config:
204    ///
205    /// - the `[media]` config surface — `MediaMTX` origins, `FFmpeg` bin, the
206    ///   local/S3 storage selection (including Arroyo's Tigris `auto` region /
207    ///   `t3.storage.dev` endpoint / `highlights` key-prefix defaults), and the
208    ///   retention window — via
209    ///   [`MediaConfig::from_arroyo_env`](config::MediaConfig::from_arroyo_env);
210    /// - the **broadcast** primitive enabled (Arroyo is one-to-many and uses no
211    ///   rooms), so the plugin is watch-path-ready without a further builder
212    ///   call;
213    /// - the retention sweep's recordings root from `ARROYO_RECORDINGS_ROOT`
214    ///   (default `recordings`, matching Arroyo's own
215    ///   `configured_recordings_root()` fallback), so the hourly retention loop
216    ///   is wired exactly as before.
217    ///
218    /// The returned value is a normal builder: chain
219    /// [`artifact_sink`](Self::artifact_sink) (the app writes its own tables on
220    /// completion), [`workflow_delegate`](Self::workflow_delegate),
221    /// [`retention_defer`](Self::retention_defer), etc. This reads the process
222    /// environment; the pure mapping lives in
223    /// [`from_arroyo_env_pairs`](Self::from_arroyo_env_pairs) for testability.
224    #[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    /// Pure core of [`from_arroyo_env`](Self::from_arroyo_env): map the supplied
231    /// `ARROYO_*` environment map onto a wired [`MediaPlugin`] without touching
232    /// process-global environment (mirroring
233    /// [`MediaConfig::from_arroyo_env_pairs`](config::MediaConfig::from_arroyo_env_pairs)).
234    #[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    /// Supply the resolved `[media]` configuration.
244    ///
245    /// The plugin cannot read `[media]` itself because Autumn loads config
246    /// after [`Plugin::build`] runs; resolve it up front with
247    /// [`MediaConfig::from_autumn_toml`](config::MediaConfig::from_autumn_toml)
248    /// or [`MediaConfig::from_arroyo_env`](config::MediaConfig::from_arroyo_env)
249    /// and pass it here. Adopts the config's `room_max_participants`.
250    #[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    /// Enable the broadcast primitive (ingest → fan-out playback → VOD).
258    ///
259    /// Installs [`MediaMtxClient`] and [`MediaUrls`] extensions, built from
260    /// `config.mediamtx`. The plugin installs them even if the storage config
261    /// is not valid.
262    #[must_use]
263    pub const fn with_broadcast(mut self) -> Self {
264        self.enable_broadcast = true;
265        self
266    }
267
268    /// Enable the rooms primitive (small mesh calls).
269    ///
270    /// Mounts the room routes, installs [`RoomService`] and starts the room
271    /// reaper.
272    #[must_use]
273    pub const fn with_rooms(mut self) -> Self {
274        self.enable_rooms = true;
275        self
276    }
277
278    /// Override the hard cap on mesh-room participants.
279    #[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    /// Override the mesh-room session-token lifetime, in seconds (shortcut for
286    /// `config.room_token_ttl_seconds`).
287    #[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    /// Override the maximum room session length, in seconds (shortcut for
294    /// `config.room_session_max_seconds`). It must be at least the token TTL.
295    #[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    /// Override the mesh-room `MediaMTX` path namespace (shortcut for
302    /// `config.room_namespace`).
303    #[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    /// Override the job/Harvest queue name used for media encode work.
310    #[must_use]
311    pub fn queue(mut self, queue: impl Into<String>) -> Self {
312        self.queue = queue.into();
313        self
314    }
315
316    /// Override the URL prefix for the plugin's API routes.
317    #[must_use]
318    pub fn api(mut self, prefix: impl Into<String>) -> Self {
319        self.api_prefix = prefix.into();
320        self
321    }
322
323    /// Override the `FFmpeg` binary path (shortcut for
324    /// `config.ffmpeg.bin`).
325    #[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    /// Install the app-supplied [`MediaArtifactSink`](sink::MediaArtifactSink)
332    /// the built-in workflow jobs invoke on completion.
333    ///
334    /// Without a sink, a completed workflow persists its output but logs that
335    /// no sink recorded it (parallels leaving `OutboundWebhookHandler` unset).
336    #[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    /// Install a runtime workflow delegate that overrides the built-in `#[job]`
343    /// engine (e.g. an external Harvest adapter maintained outside this
344    /// workspace). When set, every [`MediaWorkflows`] `queue_*` call routes
345    /// through the delegate instead of enqueuing the built-in job.
346    #[must_use]
347    pub fn workflow_delegate(mut self, delegate: MediaWorkflowDelegateHook) -> Self {
348        self.workflow_delegate = Some(delegate);
349        self
350    }
351
352    /// Override the recording-retention window, in days (`0` disables the
353    /// sweep). Defaults to `config.recording.retention_days`.
354    #[must_use]
355    pub const fn retention_days(mut self, days: u32) -> Self {
356        self.retention_days = Some(days);
357        self
358    }
359
360    /// Set the filesystem root the retention sweep deletes expired recordings
361    /// from. Required to spawn the sweep — without it, no retention loop runs.
362    #[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    /// Install the app-overridable retention-defer predicate: return `true` for
369    /// a path to hold it back this sweep (e.g. a still-encoding workflow
370    /// references it).
371    #[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    /// This plugin ships in lockstep with `autumn-web` — see
386    /// [`lockstep_contract`](autumn_web::plugin_contract::lockstep_contract).
387    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    /// Applies the plugin to the [`AppBuilder`].
399    ///
400    /// Declares the `[media]` config section (via
401    /// [`AppBuilder::config_section`]). Then `server.strict_config = true` does
402    /// not reject `[media]`. Then installs the parts of each enabled primitive
403    /// (see the crate docs).
404    // `build` is a long, linear plugin-assembly routine (config validation,
405    // room wiring + reaper, broadcast transport, storage/workflow install,
406    // retention loop); it reads best as one top-to-bottom sequence rather than
407    // fragmented across helpers.
408    #[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        // Declare the plugin-owned `[media]` top-level config table so a host app
425        // running `server.strict_config = true` boots cleanly instead of failing
426        // with `unknown key "media"`. `MediaConfig` is still read from raw TOML
427        // (see `MediaConfig::from_autumn_toml`); this only tells core's strict
428        // unknown-key check that `[media]` is a known, opaque section the plugin
429        // owns and validates itself. Applied first so every return path below
430        // (including the early room-misconfig abort) carries the declaration.
431        let app = app.config_section("media");
432
433        let retention_days = retention_days.unwrap_or(config.recording.retention_days);
434
435        // The mesh is O(N²), so `DEFAULT_ROOM_MAX_PARTICIPANTS` (6) is an
436        // ABSOLUTE ceiling with no SFU: the `[media]` room config and the
437        // `room_max_participants` builder may set a per-room seat count only
438        // within `1..=6`. An out-of-range value (0 or >6) is a fatal
439        // misconfiguration — fail fast and LOUD at boot, never a silent clamp.
440        // `Plugin::build` returns an `AppBuilder` (not a `Result`), so the
441        // specific error is surfaced from an `on_startup` hook, which aborts
442        // boot with `process::exit(1)` exactly as a failed init would (see
443        // autumn-web's `run_startup_hooks`). `room_max_participants` is the
444        // effective seat count sourced from either `config(..)` (which copies
445        // `room_max_participants` into it) or the `room_max_participants(..)`
446        // builder, so this single check covers both the TOML and builder paths;
447        // `InMemoryRoomStore::create_room` re-checks the fixed 6 ceiling as a
448        // defense-in-depth backstop, and `MediaConfig::validate` stays the
449        // opt-in strict check for consumers who validate config up front.
450        //
451        // A configured `room_namespace` is prepended to every room path, so an
452        // invalid one (a slash, a dot segment, whitespace) would likewise mount
453        // the router fine yet make every `POST /rooms` fail `InvalidSegment` —
454        // another config that can never serve a request. It fails fast here too,
455        // via the same `on_startup` abort but its own specific message.
456        // The storage backend keeps its own degrade path below (unchanged), so
457        // this fails fast only on the room cap, the room namespace and the
458        // session limit.
459        //
460        // Gated on `enable_rooms`: a broadcast-only plugin (`with_broadcast()`
461        // without `with_rooms()`) mounts no room router / `RoomService`, so a
462        // stray/irrelevant room setting — or a room env override — must never
463        // abort boot and take the broadcast path down with it. The rooms path
464        // keeps the existing fail-fast (see `room_config_boot_error`).
465        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        // Both primitives are off by default. With neither, install nothing.
493        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        // Rooms are storage-independent, so mount the room signaling router and
503        // install the `RoomService` extension up front — they must stay
504        // available even if the storage backend below fails to resolve. The
505        // `nest` + `declare_plugin_routes` pair keeps the room routes both
506        // served and audit-visible under `api_prefix`.
507        let mut app = app;
508        if enable_rooms {
509            // Select the room-state backend (config, default `memory`).
510            //
511            // The `memory` store is per-process, so the `RoomService` and the
512            // reaper MUST share ONE instance — built once here and cloned into
513            // both hooks. The `db` store is stateless w.r.t. process memory (all
514            // room state lives in the shared database), so per-hook instances
515            // over the same pool are equivalent; it is built lazily from
516            // `state.pool()` inside each hook via `build_room_store`, which
517            // degrades to a warned in-memory store if `db` was selected without
518            // a configured database.
519            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            // URL/namespace/TTL for the `RoomService`, cloned into the
528            // initializer so the service can be built there (the `db` store
529            // needs `&AppState`).
530            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                // Spawn from `on_startup` so the reaper shares the running app's
558                // tokio runtime (matching the retention sweep below). For the
559                // `db` backend it builds its own store instance over the same
560                // pool the service uses — sweeping the same shared rows.
561                .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        // The broadcast transport does not need storage, so install it before
573        // the storage check below.
574        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        // Resolve the storage backend up front so a misconfiguration surfaces
584        // as one error line here rather than inside a job. If storage fails, the
585        // plugin keeps the room routes and broadcast transport. It installs no
586        // storage, workflows, encode jobs or retention sweep.
587        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        // Extensions are installed via `state_initializer` (not `on_startup`)
602        // so they exist BEFORE job workers start — mirroring autumn-web's
603        // outbound-webhook plugin, whose manager must be present before the
604        // first job runs.
605        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            // Register the built-in encode jobs with the queue overridden to
617            // `queue` (the JobInfo's `queue` field, not the `#[job]` literal, is
618            // what the enqueue chokepoint routes on). autumn-web's worker
619            // auto-registers any declared-but-unconfigured queue at lowest
620            // priority, so the media queue drains without extra config.
621            .jobs(workflows::media_job_infos(&queue));
622
623        // Recording-retention sweep: spawn only when a recordings root is
624        // configured and retention is enabled. Spawned from `on_startup` so it
625        // shares the running app's runtime, matching how the thumbnail/retention
626        // loops are spawned in an Autumn app.
627        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
647/// Validate an effective mesh-room seat count against the ABSOLUTE ceiling.
648///
649/// Mesh WebRTC is O(N²), so [`DEFAULT_ROOM_MAX_PARTICIPANTS`] (6) is a hard cap
650/// with no SFU, and a room must seat at least one participant. Returns the
651/// specific, fail-fast error message — naming the offending value and the cap —
652/// when `configured` is outside `1..=6`, or `None` when it is in range.
653fn 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
667/// Validate a configured mesh-room `MediaMTX` path namespace at boot.
668///
669/// The namespace is prepended to every room / participant `MediaMTX` path, so a
670/// value [`rooms::validate_room_segment`] would reject (a slash, a `.` / `..`
671/// dot segment, whitespace, or other non-`[A-Za-z0-9_-]` label) mounts the
672/// router fine but makes every `POST /rooms` fail with `InvalidSegment` — a
673/// config that can never serve a request must instead refuse to boot. Returns
674/// the specific, fail-fast error message — naming the offending value and the
675/// allowed charset — when the namespace is present-and-invalid, or `None` when
676/// it is absent, empty, or a valid segment.
677fn 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
687/// Resolve the fail-fast boot error for room configuration, gated on rooms
688/// actually being enabled.
689///
690/// Room config (the `room_max_participants` seat count and the
691/// `room_namespace` path segment) is only load-bearing when a room router /
692/// [`rooms::RoomService`] is mounted — i.e. when `enable_rooms` is true. A
693/// broadcast-only plugin (`with_broadcast()` without `with_rooms()`) serves no
694/// room requests, so an out-of-range `room_max_participants` or an invalid
695/// `room_namespace` (e.g. a stray value or a `media.room_namespace` env
696/// override) is inert and must **not** abort boot — otherwise an irrelevant
697/// room setting becomes a full broadcast outage. When `enable_rooms` is false
698/// this returns `None` unconditionally; when it is true the existing
699/// [`room_max_participants_error`] / [`room_namespace_error`] fail-fast checks
700/// apply exactly as before.
701fn 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
714/// Reject a session limit shorter than the token TTL.
715///
716/// A join mints a token that lives `room_token_ttl_seconds`. A shorter session
717/// limit ends the session before that token expires.
718fn 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
729/// Build a shared, database-backed [`rooms_db::DbRoomStore`] from the running
730/// app's connection pool for the `db` room-store backend.
731///
732/// Only called on the `db` code path (the `memory` backend shares one pre-built
733/// [`rooms::InMemoryRoomStore`] across the service and reaper). If `db` was
734/// selected but no database pool is configured, it logs an actionable error and
735/// **degrades to a per-process in-memory store** so the app still boots and
736/// rooms still work — they just will not survive across processes until a
737/// database is configured.
738fn 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
751/// Recordings root an Arroyo deployment uses when `ARROYO_RECORDINGS_ROOT` is
752/// unset — mirrors Arroyo's own `configured_recordings_root()` fallback, so the
753/// migration shim wires the retention sweep the same way with no ops change.
754const DEFAULT_ARROYO_RECORDINGS_ROOT: &str = "recordings";
755
756/// Resolve the Arroyo recordings root from an env map: a non-blank
757/// `ARROYO_RECORDINGS_ROOT`, else [`DEFAULT_ARROYO_RECORDINGS_ROOT`]. Blank /
758/// whitespace-only values are treated as unset (parity with
759/// [`MediaConfig::from_arroyo_env_pairs`](config::MediaConfig::from_arroyo_env_pairs)).
760fn 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// ── Arroyo migration shim (slice 5) ─────────────────────────────────────────
792
793#[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        // Broadcast is enabled (Arroyo is one-to-many); rooms are not.
811        assert!(plugin.enable_broadcast, "broadcast must be enabled");
812        assert!(!plugin.enable_rooms, "rooms must stay disabled");
813        // Config maps through with neutral defaults for an empty env.
814        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        // Retention sweep is wired to Arroyo's default recordings root, so the
818        // hourly loop runs exactly as it did pre-migration.
819        assert_eq!(
820            plugin.recordings_root.as_deref(),
821            Some(std::path::Path::new(DEFAULT_ARROYO_RECORDINGS_ROOT))
822        );
823        // The produced config is valid to hand to MediaStorage::from_config.
824        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        // Arroyo's Tigris S3 defaults flow through the config-level shim.
847        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        // Retention window flows from ARROYO_RECORDING_RETENTION_DAYS into the
856        // config (the plugin resolves the effective days from it at build time).
857        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        // Blank / whitespace-only is treated as unset → Arroyo's default.
872        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        // The shim returns a normal builder, so an app can layer its own
885        // overrides (queue name, retention window) on top of the mapped env.
886        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// ── Absolute room-cap enforcement (Fix 1) ───────────────────────────────────
896
897#[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        // >6 fails loud, naming the offending value and the ceiling — never a
905        // clamp.
906        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        // 0 fails loud with its own message.
911        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        // The `room_max_participants(50)` builder stores what it was given;
926        // `build()` is what rejects it fail-fast (no clamp), so an out-of-range
927        // builder value produces the specific error at boot.
928        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        // `room_max_participants(4)` stays in range → a real 4-seat room.
933        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        // The `[media] room_max_participants = 50` config path flows into the
938        // same effective field via `config(..)`, so it is rejected identically.
939        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// ── Room-namespace fail-fast (Fix P2-a) ─────────────────────────────────────
950
951#[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        // Absent and empty mean "no namespace" → no boot error; a valid segment
958        // passes through untouched.
959        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        // A slash, either dot segment, or embedded whitespace fails loud, naming
968        // the offending value and the allowed charset — never a silent mount
969        // that 500s every request.
970        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        // The `room_namespace("tenant/a")` builder stores what it was given;
987        // `build()` is what rejects it fail-fast, so an invalid builder value
988        // produces the specific error at boot.
989        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        // A valid namespace flows through untouched → a real namespaced room.
994        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// ── Room-config fail-fast gated on rooms being enabled (Codex P2) ────────────
1001//
1002// The boot-time room-config abort must be conditional on `enable_rooms`: a
1003// broadcast-only plugin mounts no room router / `RoomService`, so a stray or
1004// invalid room setting (an out-of-range `room_max_participants`, a bad
1005// `room_namespace` env override) is inert and must never abort boot and take
1006// the broadcast path down with it. Rooms-enabled keeps the existing fail-fast.
1007
1008#[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        // `with_broadcast()` without `with_rooms()` → rooms disabled. An invalid
1022        // room cap (0) or namespace ("bad/ns") is irrelevant and must NOT abort
1023        // boot, so no failing startup hook is installed and broadcast stays up.
1024        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        // `with_rooms()` → rooms enabled. The same invalid settings that a
1038        // broadcast-only plugin ignores must still abort boot on the rooms path,
1039        // preserving the pre-existing fail-fast (naming the offending value).
1040        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        // A valid room config on the rooms path boots cleanly (no abort).
1054        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// ── Conformance reference tests ─────────────────────────────────────────────
1072//
1073// The room signaling routes `MediaPlugin::build` declares under `/api/media`
1074// (via `rooms::room_route_infos`) are run through autumn-web's plugin
1075// conformance harness — the same checks `autumn-admin-plugin`'s
1076// `conformance_tests` uses — so the plugin's naming / prefix / attribution
1077// conventions stay clean and a future slice that touches the room routes must
1078// consciously keep them conformant.
1079
1080#[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    /// The real room routes `build` declares, attributed to the plugin exactly
1092    /// as `declare_plugin_routes` attributes them at runtime.
1093    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        // Installing the plugin twice would double its routes → FAIL.
1184        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        // Sanity check the harness is wired: a host route colliding with a
1197        // plugin route is flagged.
1198        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    // #1974 item 7: `MediaPlugin::build` must declare its `[media]` top-level
1220    // config section on the builder so a host app with
1221    // `server.strict_config = true` boots without core rejecting `[media]` as an
1222    // unknown key. Registering the real plugin and asserting the section is
1223    // declared proves the wiring end-to-end (not just a stand-in).
1224    #[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// Each primitive installs only its own surface (#1974).
1235#[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    /// The extensions a built app holds.
1253    #[allow(clippy::struct_excessive_bools)] // one flag per extension
1254    #[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    /// Build `plugin` in a test app and return what `read` sees in its state.
1272    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    /// The extensions `plugin` installs.
1301    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        // S3 without a bucket fails `MediaStorage::from_config`.
1371        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    /// The media job names `plugin` registers.
1394    fn media_jobs(plugin: MediaPlugin) -> HashSet<String> {
1395        // Jobs register after the probe runs. The registry clone shares state,
1396        // so read it after `build`.
1397        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    /// Build `plugin` over a root with one expired file. Return `true` if the
1431    /// retention sweep looks at the file within `wait`.
1432    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        // Keep the file; only report that the sweep saw it.
1444        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}