diff --git a/backend/app/src/api/endpoints/download_api.rs b/backend/app/src/api/endpoints/download_api.rs index 9c04215e7..9e5e4a35b 100644 --- a/backend/app/src/api/endpoints/download_api.rs +++ b/backend/app/src/api/endpoints/download_api.rs @@ -1403,7 +1403,7 @@ pub(in crate::api) async fn ensure_download_worker_running( }; let progress_path = recording_partial_path(&execution_download.file_path); let container_format = - app_config.config.load().recording.as_ref().map_or_else( + app_config.config.load().recording().map_or_else( shared::model::RecordingContainerFormat::default, |recording| recording.container_format, ); @@ -1833,7 +1833,7 @@ pub(in crate::api) fn start_download_scheduler( pub(in crate::api) fn spawn_download_services(app_state: &AppState, cancel_token: &CancellationToken) { let config = app_state.app_config.config.load(); - let Some(download_cfg) = config.recording.as_ref().cloned() else { + let Some(download_cfg) = config.recording().cloned() else { return; }; @@ -1874,11 +1874,11 @@ pub async fn queue_download_file( let app_config = &*app_state.app_config; let config = app_config.config.load(); - if let Some(download_cfg) = config.recording.as_ref() { + if let Some(download_cfg) = config.recording() { if download_cfg.directory.is_empty() { return ( axum::http::StatusCode::BAD_REQUEST, - axum::Json(json!({"error": "Server config missing video.download.directory configuration"})), + axum::Json(json!({"error": "Server config missing video.recording.directory configuration"})), ) .into_response(); } @@ -1971,11 +1971,11 @@ pub async fn queue_recording_file( .into_response(); } - if let Some(download_cfg) = config.recording.as_ref() { + if let Some(download_cfg) = config.recording() { if download_cfg.directory.is_empty() { return ( axum::http::StatusCode::BAD_REQUEST, - axum::Json(json!({"error": "Server config missing video.download.directory configuration"})), + axum::Json(json!({"error": "Server config missing video.recording.directory configuration"})), ) .into_response(); } @@ -2056,7 +2056,7 @@ pub async fn resume_download( ) -> impl axum::response::IntoResponse + Send { match app_state.downloads.resume_active(&req.uuid).await { Ok(true) => { - let download_cfg = app_state.app_config.config.load().recording.as_ref().cloned(); + let download_cfg = app_state.app_config.config.load().recording().cloned(); if let Some(download_cfg) = download_cfg { let app_state = Arc::clone(&app_state); tokio::spawn(async move { @@ -2099,7 +2099,7 @@ pub async fn cancel_download( Ok(Some(was_paused)) => { if was_paused { let config = app_state.app_config.config.load(); - if let Some(download_cfg) = config.recording.as_ref() { + if let Some(download_cfg) = config.recording() { if let Err(err) = ensure_download_worker_running( &app_state.app_config, download_cfg, @@ -2165,7 +2165,7 @@ pub async fn retry_download( // Start the queue if not running let app_config = &app_state.app_config; let config = app_config.config.load(); - if let Some(download_cfg) = config.recording.as_ref() { + if let Some(download_cfg) = config.recording() { if app_state.downloads.active.read().await.is_none() { let _ = ensure_download_worker_running( app_config, diff --git a/backend/app/src/api/endpoints/recording_api.rs b/backend/app/src/api/endpoints/recording_api.rs index 581fba04f..6b74d798e 100644 --- a/backend/app/src/api/endpoints/recording_api.rs +++ b/backend/app/src/api/endpoints/recording_api.rs @@ -240,7 +240,7 @@ async fn create_http_recording_task( return error_response(StatusCode::BAD_REQUEST, "recording_invalid_source"); }; let config = app_state.app_config.config.load(); - let Some(recording_config) = config.recording.as_ref() else { + let Some(recording_config) = config.recording() else { return error_response(StatusCode::NOT_IMPLEMENTED, "recording_disabled"); }; let filename = format!("{}.{}", resolved.title, extension.trim_start_matches('.')); @@ -569,7 +569,7 @@ pub async fn get_recording_quota( let tasks = all_recording_tasks(&app_state).await; let totals = recording_quota::compute_totals(&tasks); let config = app_state.app_config.config.load(); - let limits = quota_limits_from_config(config.recording.as_ref().and_then(|recording| recording.quota.as_ref())); + let limits = quota_limits_from_config(config.recording().and_then(|recording| recording.quota.as_ref())); let quota = recording_quota::regular_user_dto(subject_id, &totals, &limits, &tasks); Json(RecordingQuotaResponse { private_used_bytes: quota.private.measured_bytes.saturating_add(quota.private.reserved_bytes), @@ -608,7 +608,7 @@ pub async fn get_recording_health( } let health = crate::api::model::recording::recording_supervisor::supervisor_health(); let config = app_state.app_config.config.load(); - let recording = config.recording.as_ref(); + let recording = config.recording(); Json(RecordingHealthResponse { enabled: recording.is_none_or(|cfg| cfg.enabled), server_time: chrono::Utc::now().timestamp(), @@ -1201,7 +1201,11 @@ mod tests { let recording_dto = shared::model::RecordingConfigDto { enabled: true, ..Default::default() }; let recording_runtime = crate::model::RecordingConfig::from(&recording_dto); crate::api::model::create_test_app_state(crate::model::Config { - recording: Some(recording_runtime), + video: Some(crate::model::VideoConfig { + extensions: Vec::new(), + web_search: None, + recording: Some(recording_runtime), + }), ..crate::model::Config::default() }) } diff --git a/backend/app/src/api/endpoints/recording_media_api.rs b/backend/app/src/api/endpoints/recording_media_api.rs index 9a886f5a1..3fee40777 100644 --- a/backend/app/src/api/endpoints/recording_media_api.rs +++ b/backend/app/src/api/endpoints/recording_media_api.rs @@ -234,7 +234,7 @@ async fn resolve_for_open(app_state: &AppState, claims: &Claims, uuid: &str) -> .await .map_err(|e| Box::new(access_error_to_response(&e)))?; let config = app_state.app_config.config.load(); - let recording_root = config.recording.as_ref().map(|recording| recording.directory.clone()).ok_or_else(|| { + let recording_root = config.recording().map(|recording| recording.directory.clone()).ok_or_else(|| { Box::new( (StatusCode::INTERNAL_SERVER_ERROR, axum::Json(serde_json::json!({"error": "recording_not_configured"}))) .into_response(), diff --git a/backend/app/src/api/endpoints/v1_api.rs b/backend/app/src/api/endpoints/v1_api.rs index c07cd2610..b08eada51 100644 --- a/backend/app/src/api/endpoints/v1_api.rs +++ b/backend/app/src/api/endpoints/v1_api.rs @@ -229,7 +229,14 @@ mod tests { fn config_with_recording_enabled(enabled: bool) -> Config { let recording_dto = shared::model::RecordingConfigDto { enabled, ..Default::default() }; let recording_runtime = RecordingConfig::from(&recording_dto); - Config { recording: Some(recording_runtime), ..Config::default() } + Config { + video: Some(tuliprox_core::model::VideoConfig { + extensions: Vec::new(), + web_search: None, + recording: Some(recording_runtime), + }), + ..Config::default() + } } fn config_with_recording_and_web_auth(recording_enabled: bool) -> Config { diff --git a/backend/app/src/api/main_api.rs b/backend/app/src/api/main_api.rs index 17a10ffef..95ce9638a 100644 --- a/backend/app/src/api/main_api.rs +++ b/backend/app/src/api/main_api.rs @@ -761,7 +761,7 @@ pub async fn start_server(app_config: Arc, targets: Arc, changes: &UpdateChanges) { spawn_download_services(app_state, &app_state.cancel_tokens.load().downloads); spawn_recording_rule_scheduler(&app_state.recording_ctx(), &app_state.cancel_tokens.load().downloads); let config = app_state.app_config.config.load(); - if let Some(download_cfg) = config.recording.as_ref().cloned() { + if let Some(download_cfg) = config.recording().cloned() { let app_state = Arc::clone(app_state); tokio::spawn(async move { for _ in 0..50 { @@ -647,8 +647,11 @@ impl AppState { let geoip_enabled_old = old_config.is_geoip_enabled(); let changed_storage_dir = old_config.storage_dir != config.storage_dir; let changed_qos_aggregation = qos_aggregation_changed(&old_config, config); - let changed_video_download = - change_detect!(recording_changed, old_config.recording.as_ref(), config.recording.as_ref()); + let changed_recording = change_detect!( + recording_changed, + old_config.video.as_ref().and_then(|video| video.recording.as_ref()), + config.video.as_ref().and_then(|video| video.recording.as_ref()) + ); let mut changes = UpdateChanges { flags: UpdateChangesFlagsSet::new(), targets: None }; changes.set_flag_if( @@ -660,7 +663,7 @@ impl AppState { changes.set_flag_if(geoip_enabled != geoip_enabled_old, UpdateChangesFlags::Geoip); changes.set_flag_if(changed_storage_dir, UpdateChangesFlags::Metadata); changes.set_flag_if(changed_qos_aggregation || changed_storage_dir, UpdateChangesFlags::QosAggregation); - changes.set_flag_if(changed_video_download, UpdateChangesFlags::Downloads); + changes.set_flag_if(changed_recording, UpdateChangesFlags::Downloads); changes } diff --git a/backend/config-loader/src/runtime_config_report.rs b/backend/config-loader/src/runtime_config_report.rs index aa7ba4899..6e87800b0 100644 --- a/backend/config-loader/src/runtime_config_report.rs +++ b/backend/config-loader/src/runtime_config_report.rs @@ -13,7 +13,7 @@ use shared::{ model::{ ApiProxyConfigDto, AppConfigDto, ConfigDto, ConfigPaths, HdHomeRunConfigDto, HdHomeRunDeviceConfigDto, LibraryConfigDto, LibraryMetadataConfigDto, LibraryMetadataReadConfigDto, LibraryPlaylistConfigDto, - LogConfigDto, RecordingConfigDto, RuntimeConfigReportFormat, ThumbnailConfigDto, + LogConfigDto, RuntimeConfigReportFormat, ThumbnailConfigDto, VideoConfigDto, }, }; use tuliprox_core::model::{AppConfig, Config, HdHomeRunConfig, HdHomeRunFlags, LibraryConfig}; @@ -50,7 +50,7 @@ async fn render_runtime_config_report( let report = build_runtime_config_report(app_config).await?; let mut value = serde_json::to_value(&report) .map_err(|err| TuliproxError::Config(format!("Failed to convert runtime config report to value: {err}")))?; - redact_value(None, None, &mut value); + redact_value(None, None, None, &mut value); serialize_report_value(&value, format) } @@ -79,8 +79,7 @@ fn runtime_config_to_dto(config: &Config) -> ConfigDto { custom_stream_response_timeout_secs: config.custom_stream_response_timeout_secs, custom_stream_response_enabled: config.custom_stream_response_enabled, custom_stream_response_error_status: config.custom_stream_response_error_status, - recording: config.recording.as_ref().map(RecordingConfigDto::from), - video: None, + video: config.video.as_ref().map(VideoConfigDto::from), metadata_update: config.metadata_update.as_ref().map(shared::model::MetadataUpdateConfigDto::from), schedules: config .schedules @@ -183,20 +182,20 @@ fn serialize_report_value(value: &Value, format: RuntimeConfigReportFormat) -> R } } -fn redact_value(current_key: Option<&str>, parent_key: Option<&str>, value: &mut Value) { +fn redact_value(current_key: Option<&str>, parent_key: Option<&str>, grandparent_key: Option<&str>, value: &mut Value) { match value { Value::Object(map) => { for (key, child) in map { - redact_value(Some(key.as_str()), current_key, child); + redact_value(Some(key.as_str()), current_key, parent_key, child); } } Value::Array(items) => { for item in items { - redact_value(None, current_key, item); + redact_value(None, current_key, parent_key, item); } } Value::String(text) => { - if should_redact_field(current_key, parent_key) { + if should_redact_field(current_key, parent_key, grandparent_key) { *text = "***".to_string(); } else if should_redact_as_url(current_key) { *text = redact_url_like_value(text); @@ -206,7 +205,7 @@ fn redact_value(current_key: Option<&str>, parent_key: Option<&str>, value: &mut } } -fn should_redact_field(current_key: Option<&str>, parent_key: Option<&str>) -> bool { +fn should_redact_field(current_key: Option<&str>, parent_key: Option<&str>, grandparent_key: Option<&str>) -> bool { let Some(key) = current_key.map(str::to_ascii_lowercase) else { return false; }; @@ -228,6 +227,12 @@ fn should_redact_field(current_key: Option<&str>, parent_key: Option<&str>) -> b return true; } + if parent_key.is_some_and(|parent| parent.eq_ignore_ascii_case("headers")) + && grandparent_key.is_some_and(|parent| parent.eq_ignore_ascii_case("recording")) + { + return true; + } + if parent_key.is_some_and(|parent| parent.eq_ignore_ascii_case("headers")) { return matches!( key.as_str(), @@ -336,7 +341,7 @@ sources: "url": "http://example.com/get.php?username=alice&password=secret" }); - redact_value(None, None, &mut value); + redact_value(None, None, None, &mut value); assert_eq!(value["password"], "***"); assert_eq!(value["secret"], "***"); @@ -344,6 +349,17 @@ sources: assert_eq!(value["url"], "http://***/get.php?username=***&password=***"); } + #[test] + fn runtime_config_report_redacts_every_recording_header() { + let mut value = serde_json::json!({ + "video": { "recording": { "headers": { "X-Upstream-Key": "custom-secret" } } } + }); + + redact_value(None, None, None, &mut value); + + assert_eq!(value["video"]["recording"]["headers"]["X-Upstream-Key"], "***"); + } + #[tokio::test] async fn runtime_config_report_formats_yaml() { let app_config = create_test_app_config().await; @@ -366,7 +382,7 @@ sources: } #[tokio::test] - async fn runtime_config_report_emits_canonical_recording_from_runtime_config() { + async fn runtime_config_report_emits_recording_from_runtime_video_config() { let make_recording = |dir: &str| { tuliprox_core::model::RecordingConfig::from(&shared::model::RecordingConfigDto { enabled: true, @@ -374,16 +390,23 @@ sources: ..shared::model::RecordingConfigDto::default() }) }; - let mut config = tuliprox_core::model::Config::default(); - config.recording = Some(make_recording("/canonical/recordings")); + let config = tuliprox_core::model::Config { + video: Some(tuliprox_core::model::VideoConfig { + extensions: vec![".ts".to_string()], + web_search: None, + recording: Some(make_recording("/canonical/recordings")), + }), + ..tuliprox_core::model::Config::default() + }; let dto = super::runtime_config_to_dto(&config); - let recording = dto.recording.as_ref().expect("report must carry canonical recording"); + let video = dto.video.as_ref().expect("report must carry video"); + let recording = video.recording.as_ref().expect("report must carry recording"); assert_eq!( recording.directory.as_deref(), Some("/canonical/recordings"), - "report must read from Config.recording, not the legacy compat shadow" + "report must read from Config.video.recording" ); } } diff --git a/backend/core/src/model/config/base.rs b/backend/core/src/model/config/base.rs index 28ea866d2..a0fdb727d 100644 --- a/backend/core/src/model/config/base.rs +++ b/backend/core/src/model/config/base.rs @@ -2,7 +2,7 @@ use crate::{ model::{ macros, ConfigApi, HdHomeRunConfig, HdHomeRunFlags, IpCheckConfig, LibraryConfig, LogConfig, MessagingConfig, MetadataUpdateConfig, ProxyConfig, RecordingConfig, ReverseProxyConfig, ReverseProxyDisabledHeaderConfig, - ScheduleConfig, WebUiConfig, + ScheduleConfig, VideoConfig, WebUiConfig, }, utils, utils::get_default_path_for_home, @@ -12,7 +12,7 @@ use path_clean::PathClean; use shared::{ defaults::{ default_grace_period_millis, default_grace_period_timeout_secs, DEFAULT_BACKUP_DIR, DEFAULT_CACHE_DIR, - DEFAULT_DOWNLOAD_DIR, DEFAULT_STORAGE_DIR, DEFAULT_STORAGE_TEMP_DIR, DEFAULT_USER_CONFIG_DIR, + DEFAULT_RECORDING_DIR, DEFAULT_STORAGE_DIR, DEFAULT_STORAGE_TEMP_DIR, DEFAULT_USER_CONFIG_DIR, }, error::TuliproxError, model::{ConfigDto, GeoIpUnavailablePolicy, HdHomeRunDeviceOverview}, @@ -29,7 +29,7 @@ fn create_directories(cfg: &Config, temp_path: &Path) { Some(cfg.storage_dir.clone()), cfg.backup_dir.clone(), cfg.user_config_dir.clone(), - cfg.recording.as_ref().map(|recording| recording.directory.clone()), + cfg.video.as_ref().and_then(|video| video.recording.as_ref()).map(|recording| recording.directory.clone()), cfg.reverse_proxy .as_ref() .and_then(|r| r.cache.as_ref().and_then(|c| if c.enabled { Some(c.directory.clone()) } else { None })), @@ -89,7 +89,7 @@ pub struct Config { pub custom_stream_response_timeout_secs: u32, pub custom_stream_response_enabled: bool, pub custom_stream_response_error_status: u16, - pub recording: Option, + pub video: Option, pub metadata_update: Option, pub schedules: Option>, pub log: Option, @@ -112,6 +112,10 @@ pub struct Config { } impl Config { + pub fn recording(&self) -> Option<&RecordingConfig> { + self.video.as_ref().and_then(|video| video.recording.as_ref()) + } + fn lexical_normalize(path: &Path) -> PathBuf { path.components().collect::() } fn normalize_storage_path( @@ -173,10 +177,10 @@ impl Config { messaging.prepare(config_path); } - if let Some(recording) = self.recording.as_mut() { + if let Some(recording) = self.video.as_mut().and_then(|video| video.recording.as_mut()) { let directory = PathBuf::from(&recording.directory); if recording.directory.trim().is_empty() { - recording.directory = get_default_path_for_home(Path::new(home_path), DEFAULT_DOWNLOAD_DIR) + recording.directory = get_default_path_for_home(Path::new(home_path), DEFAULT_RECORDING_DIR) .clean() .to_string_lossy() .to_string(); @@ -314,7 +318,7 @@ impl From<&ConfigDto> for Config { custom_stream_response_timeout_secs: dto.custom_stream_response_timeout_secs, custom_stream_response_enabled: dto.custom_stream_response_enabled, custom_stream_response_error_status: dto.custom_stream_response_error_status, - recording: dto.recording.as_ref().map(Into::into), + video: dto.video.as_ref().map(Into::into), metadata_update: dto.metadata_update.as_ref().map(Into::into), schedules: dto.schedules.as_ref().map(|s| s.iter().map(Into::into).collect()), log: dto.log.as_ref().map(Into::into), @@ -340,7 +344,7 @@ impl From<&ConfigDto> for Config { #[cfg(test)] mod tests { use super::Config; - use shared::model::{ConfigDto, GeoIpUnavailablePolicy, RecordingConfigDto}; + use shared::model::{ConfigDto, GeoIpUnavailablePolicy, RecordingConfigDto, VideoConfigDto}; use tempfile::tempdir; #[test] @@ -453,70 +457,26 @@ mod tests { assert_eq!(config.get_geoip_unavailable_policy(), GeoIpUnavailablePolicy::Allow); } - // --- Runtime `Config` reader tests --- - // - // The runtime `Config` mirrors the shared `ConfigDto`: a canonical - // top-level `recording: Option` populated from the - // DTO's `recording` field, replacing the legacy - // `video.download.recording` path as the DVR reader site. - // Download-pipeline sites keep using `config.video.download.*`. - #[test] - fn recording_runtime_config_from_dto_canonical_top_level_field() { - // Canonical `recording:` on the DTO must populate the runtime - // `Config.recording` field directly. This is the migration - // target every DVR reader site relies on. + fn recording_runtime_config_from_video_dto() { let dto = ConfigDto { - recording: Some(RecordingConfigDto { - enabled: true, - directory: Some("/srv/recordings".to_string()), - ..RecordingConfigDto::default() + video: Some(VideoConfigDto { + extensions: vec![".mp4".to_string()], + web_search: Some("https://example.test/search".to_string()), + recording: Some(RecordingConfigDto { + enabled: true, + directory: Some("/srv/recordings".to_string()), + ..RecordingConfigDto::default() + }), }), ..ConfigDto::default() }; let config = Config::from(&dto); - let recording = config.recording.as_ref().expect("runtime config must expose canonical recording"); + let video = config.video.as_ref().expect("runtime config must expose video"); + let recording = video.recording.as_ref().expect("runtime config must expose recording"); assert!(recording.enabled, "recording.enabled must round-trip from DTO"); assert_eq!(recording.directory, "/srv/recordings"); - } - - #[test] - fn recording_runtime_config_legacy_video_download_after_prepare_yields_enabled_canonical() { - // End-to-end: a YAML config that only sets `video.download` (no - // canonical `recording:`) flows through the shared deserializer - // (which lifts legacy into canonical) and `prepare()` (which - // normalizes). After both, `Config.recording` must carry an - // enabled block because `video.download` is present. - let yaml = r#" -api: - host: 127.0.0.1 - port: 8901 - web_root: ./web -video: - extensions: [".mp4"] - download: - directory: /data/downloads -"#; - let mut dto: ConfigDto = serde_saphyr::from_str(yaml).expect("legacy config deserializes"); - dto.prepare(false).expect("prepare should succeed"); - let config = Config::from(&dto); - let recording = config.recording.as_ref().expect("runtime config must expose the lifted canonical recording"); - assert!(recording.enabled, "video.download present implies DVR is enabled by default"); - assert_eq!(recording.directory, "/data/downloads/recordings"); - } - - #[test] - fn recording_runtime_config_default_has_disabled_canonical_block_after_prepare() { - // Default DTO after `prepare()` has a canonical `recording` in - // disabled form (no `video.download` block). The runtime - // `Config::from(&dto)` must surface it as `Some(RecordingConfig)` - // so the supervisor's `recording_enabled` predicate can read - // the canonical value directly. - let mut dto = ConfigDto::default(); - dto.prepare(false).expect("prepare on default"); - let config = Config::from(&dto); - let recording = - config.recording.as_ref().expect("default DTO after prepare must carry disabled canonical block"); - assert!(!recording.enabled, "no download block implies recording is disabled"); + assert_eq!(video.extensions, [".mp4"]); + assert_eq!(video.web_search.as_deref(), Some("https://example.test/search")); } } diff --git a/backend/core/src/model/config/mod.rs b/backend/core/src/model/config/mod.rs index b1d2faa7e..061deed65 100644 --- a/backend/core/src/model/config/mod.rs +++ b/backend/core/src/model/config/mod.rs @@ -22,6 +22,7 @@ mod panel_api; mod proxy; mod qos_aggregation; mod rate_limit; +mod recording; mod rename; mod reverse_proxy; mod schedule; @@ -32,7 +33,6 @@ mod stream_history; mod target; mod trakt; mod trakt_api; -mod video_download; mod web_auth; mod web_ui; @@ -58,6 +58,7 @@ pub use panel_api::*; pub use proxy::*; pub use qos_aggregation::*; pub use rate_limit::*; +pub use recording::*; pub use rename::*; pub use reverse_proxy::*; pub use schedule::*; @@ -68,6 +69,5 @@ pub use stream_history::*; pub use target::*; pub use trakt::*; pub use trakt_api::*; -pub use video_download::*; pub use web_auth::*; pub use web_ui::*; diff --git a/backend/core/src/model/config/video_download.rs b/backend/core/src/model/config/recording.rs similarity index 94% rename from backend/core/src/model/config/video_download.rs rename to backend/core/src/model/config/recording.rs index 3912af2d2..fd3286a67 100644 --- a/backend/core/src/model/config/video_download.rs +++ b/backend/core/src/model/config/recording.rs @@ -5,7 +5,7 @@ use shared::model::{ default_recording_notification_backoff_initial_secs, default_recording_notification_backoff_max_secs, default_recording_notification_max_attempts, default_recording_notification_outbox_buffer, RecordingConfigDto, RecordingContainerFormat, RecordingDiskConfigDto, RecordingNotificationConfigDto, RecordingQuotaConfigDto, - RecordingRetentionConfigDto, + RecordingRetentionConfigDto, VideoConfigDto, }; use std::{collections::HashMap, sync::Arc}; @@ -13,7 +13,6 @@ use std::{collections::HashMap, sync::Arc}; #[derive(Debug, Clone)] pub struct RecordingConfig { pub headers: HashMap, - pub extensions: Vec, pub organize_into_directories: bool, pub episode_pattern: Option>, pub priority: i8, @@ -40,6 +39,34 @@ pub struct RecordingConfig { pub fallback_bytes_per_minute: u64, } +#[derive(Debug, Clone)] +pub struct VideoConfig { + pub extensions: Vec, + pub web_search: Option, + pub recording: Option, +} + +macros::from_impl!(VideoConfig); +impl From<&VideoConfigDto> for VideoConfig { + fn from(dto: &VideoConfigDto) -> Self { + Self { + extensions: dto.extensions.clone(), + web_search: dto.web_search.clone(), + recording: dto.recording.as_ref().map(Into::into), + } + } +} + +impl From<&VideoConfig> for VideoConfigDto { + fn from(instance: &VideoConfig) -> Self { + Self { + extensions: instance.extensions.clone(), + web_search: instance.web_search.clone(), + recording: instance.recording.as_ref().map(RecordingConfigDto::from), + } + } +} + #[derive(Debug, Clone)] pub struct RecordingRetentionConfig { pub keep_last_per_channel: Option, @@ -128,7 +155,6 @@ impl From<&RecordingConfigDto> for RecordingConfig { .unwrap_or_else(|| "UTC".parse::().expect("UTC must parse")); Self { headers: dto.headers.clone(), - extensions: dto.extensions.clone(), organize_into_directories: dto.organize_into_directories, episode_pattern: dto.episode_pattern.as_ref().and_then(|pattern| { shared::model::REGEX_CACHE @@ -166,7 +192,6 @@ impl From<&RecordingConfig> for RecordingConfigDto { fn from(instance: &RecordingConfig) -> Self { Self { headers: instance.headers.clone(), - extensions: instance.extensions.clone(), organize_into_directories: instance.organize_into_directories, episode_pattern: instance.episode_pattern.as_ref().map(std::string::ToString::to_string), priority: instance.priority, diff --git a/backend/dvr/src/http_transfer.rs b/backend/dvr/src/http_transfer.rs index 88743e2e7..2b2e7c416 100644 --- a/backend/dvr/src/http_transfer.rs +++ b/backend/dvr/src/http_transfer.rs @@ -100,7 +100,7 @@ pub struct ResumeValidator { /// Byte offset we asked for with `Range: bytes=START-`. pub expected_offset: u64, /// Optional ETag captured at partial-open time. Reject a resume - /// whose response carries a different ETag: the resource was + /// whose response carries a different `ETag`: the resource was /// replaced mid-recording. pub expected_etag: Option, /// Optional `Last-Modified` captured at partial-open time. Same @@ -123,7 +123,7 @@ pub enum ResumeValidationError { StartMismatch { expected: u64, got: u64 }, /// `Content-Range` total does not match what we recorded. TotalMismatch { expected: u64, got: u64 }, - /// ETag changed between partial open and resume. The resource + /// `ETag` changed between partial open and resume. The resource /// was replaced — do not append. ETagMismatch { expected: String, got: String }, /// `Last-Modified` changed between partial open and resume. diff --git a/backend/dvr/src/recording/recording_service.rs b/backend/dvr/src/recording/recording_service.rs index 5a696a6de..7b29ea06c 100644 --- a/backend/dvr/src/recording/recording_service.rs +++ b/backend/dvr/src/recording/recording_service.rs @@ -110,7 +110,7 @@ pub enum ServiceError { /// The recording cannot fit on disk; reservation would exceed /// available space. DiskFull, - /// The server has no download engine: the `video.download` block + /// The server has no download engine: the `video.recording` block /// is missing from the configuration. Distinct from /// `InvalidSource` because the caller's identifiers may be /// perfectly valid — nothing on the server can execute them. @@ -287,7 +287,7 @@ impl RecordingService { return Err(ServiceError::Disabled); } let config = self.app_config.config.load(); - let Some(recording_cfg) = config.recording.as_ref() else { + let Some(recording_cfg) = config.recording() else { return Err(ServiceError::Disabled); }; recording_edit::validate_padding( @@ -399,7 +399,7 @@ impl RecordingService { // helper falls back to the shared-model defaults, so a // configured-without-recording deployment still validates // edits. - let recording_cfg = config.recording.as_ref(); + let recording_cfg = config.recording(); let bounds = padding_bounds(recording_cfg); let fallback_bytes_per_minute = recording_cfg.map_or(8 * 1024 * 1024, |cfg| cfg.fallback_bytes_per_minute); let quota_limits = quota_limits_from_config(recording_cfg.and_then(|cfg| cfg.quota.as_ref())); @@ -600,7 +600,6 @@ impl RecordingService { /// occurrences, then delete the rule) that cannot be made atomic, so /// it keeps these to undo the queue side if the rule store fails — /// see [`Self::restore_cancelled_rule_recordings`]. - pub async fn pause_recording(&self, claims: &shared::model::Claims, uuid: &str) -> Result<(), ServiceError> { let owner_id = Self::subject_id(claims)?; let active = self.downloads.active.read().await.clone(); @@ -636,7 +635,11 @@ impl RecordingService { Err(ServiceError::UnknownRecording) } - pub async fn remove_recording_task(&self, claims: &shared::model::Claims, uuid: &str) -> Result { + pub async fn remove_recording_task( + &self, + claims: &shared::model::Claims, + uuid: &str, + ) -> Result { let owner_id = Self::subject_id(claims)?; // Just remove, but we should check permissions. // We'll fetch from queue or finished to get the meta. @@ -789,7 +792,7 @@ impl RecordingService { // Reject malformed input up front so the analyzer never sees // garbage. The endpoint enforces the same bounds; this is the // service-layer defense in depth. - let bounds = padding_bounds(self.app_config.config.load().recording.as_ref()); + let bounds = padding_bounds(self.app_config.config.load().recording()); recording_edit::validate_padding(request.pre_roll_secs, request.post_roll_secs, bounds) .map_err(|error: EditError| map_edit_validation_error(&error))?; if request.padded_start >= request.padded_end { @@ -1229,8 +1232,8 @@ pub struct ConflictPreviewRequest { fn effective_capacity_from_config( config: &tuliprox_core::model::Config, ) -> crate::recording_conflict::EffectiveCapacity { - let background_slots = config.recording.as_ref().map_or(0, |cfg| u32::from(cfg.max_background_per_provider)); - let reserved = config.recording.as_ref().map_or(0, |cfg| u32::from(cfg.reserve_slots_for_users)); + let background_slots = config.recording().map_or(0, |cfg| u32::from(cfg.max_background_per_provider)); + let reserved = config.recording().map_or(0, |cfg| u32::from(cfg.reserve_slots_for_users)); crate::recording_conflict::EffectiveCapacity { background_slots, reserved_interactive_slots: reserved } } @@ -1660,7 +1663,6 @@ mod tests { }; let rec_cfg = RecordingConfig { headers: HashMap::new(), - extensions: Vec::new(), organize_into_directories: false, episode_pattern: None, priority: 0, @@ -1687,7 +1689,11 @@ mod tests { fallback_bytes_per_minute: 60, }; let config = tuliprox_core::model::Config { - recording: Some(rec_cfg.clone()), + video: Some(tuliprox_core::model::VideoConfig { + extensions: Vec::new(), + web_search: None, + recording: Some(rec_cfg.clone()), + }), ..tuliprox_core::model::Config::default() }; let app_config = Arc::new(AppConfig { @@ -1781,7 +1787,7 @@ mod tests { #[tokio::test] async fn create_recording_without_download_config_reports_disabled() { - // A missing `video.download` block means the server has no + // A missing `video.recording` block means the server has no // download engine at all — the caller's source identifiers are // not wrong. Reporting `InvalidSource` here sent clients // hunting for a misconfiguration that does not exist. @@ -2017,7 +2023,7 @@ mod tests { #[tokio::test] async fn create_recording_rejects_absent_recording_config() { - let config = tuliprox_core::model::Config { recording: None, ..tuliprox_core::model::Config::default() }; + let config = tuliprox_core::model::Config::default(); let app_config = Arc::new(AppConfig { config: Arc::new(arc_swap::ArcSwap::from_pointee(config)), sources: Arc::new(arc_swap::ArcSwap::from_pointee(tuliprox_core::model::SourcesConfig::default())), diff --git a/backend/dvr/src/recording/recording_supervisor/mod.rs b/backend/dvr/src/recording/recording_supervisor/mod.rs index ca46da3c8..49b02b7c1 100644 --- a/backend/dvr/src/recording/recording_supervisor/mod.rs +++ b/backend/dvr/src/recording/recording_supervisor/mod.rs @@ -56,12 +56,9 @@ pub use startup::run_startup_reconciliation; /// The effective recording configuration, cloned out of the `ArcSwap` /// guard so no guard is held across an await. /// -/// The canonical home of the DVR config is `Config.recording` (lifted -/// from `ConfigDto.recording`). The legacy `config.video.download.recording` -/// carrier is still populated for download-pipeline sites, but every -/// DVR reader routes through here and reads the canonical slot. +/// The DVR block lives at `Config.video.recording`. pub fn recording_config(app_config: &AppConfig) -> Option { - app_config.config.load().recording.clone() + app_config.config.load().recording().cloned() } /// `true` when the DVR is switched on. @@ -73,11 +70,8 @@ pub fn recording_config(app_config: &AppConfig) -> Option { /// (routes refusing but the scheduler still materializing) is worse than /// either state. /// -/// Reads `Config.recording.enabled` directly: the shared deserializer -/// materializes the canonical block in disabled form when no DVR is -/// configured, so "absent canonical block" cannot happen post-prepare. pub fn recording_enabled(app_config: &AppConfig) -> bool { - app_config.config.load().recording.as_ref().is_some_and(|recording| recording.enabled) + app_config.config.load().recording().is_some_and(|recording| recording.enabled) } pub fn now_ts() -> i64 { chrono::Utc::now().timestamp() } @@ -138,7 +132,14 @@ mod tests { fn config_with_download(recording: Option) -> tuliprox_core::model::Config { let canonical_dto = recording.unwrap_or_default(); let canonical = tuliprox_core::model::RecordingConfig::from(&canonical_dto); - tuliprox_core::model::Config { recording: Some(canonical), ..Default::default() } + tuliprox_core::model::Config { + video: Some(tuliprox_core::model::VideoConfig { + extensions: Vec::new(), + web_search: None, + recording: Some(canonical), + }), + ..Default::default() + } } /// An `AppConfig` carrying just the config under test. The predicates below @@ -192,8 +193,7 @@ mod tests { #[tokio::test] async fn hot_reload_swaps_canonical_recording_through_the_arcswap_accessor() { - // `recording_config()` reads `Config.recording` (canonical - // top-level slot), and a hot reload that swaps the + // `recording_config()` reads `Config.video.recording`, and a hot reload that swaps the // `ArcSwap` makes the new recording visible to every // supervisor. let make_recording = |dir: &str| { @@ -204,46 +204,56 @@ mod tests { }) }; let initial = tuliprox_core::model::Config { - recording: Some(make_recording("/initial/recordings")), + video: Some(tuliprox_core::model::VideoConfig { + extensions: Vec::new(), + web_search: None, + recording: Some(make_recording("/initial/recordings")), + }), ..tuliprox_core::model::Config::default() }; let app_config = test_app_config(initial.clone()); // Before reload: canonical recording visible. - let before = recording_config(&app_config).expect("canonical recording visible"); + let before = recording_config(&app_config).expect("video recording visible"); assert_eq!(before.directory, "/initial/recordings"); assert!(recording_enabled(&app_config)); - // Hot reload: swap to a new Config with a different canonical - // recording directory. + // Hot reload: swap to a new Config with a different recording directory. let mut next = initial; - next.recording.as_mut().expect("initial recording set").directory = "/reloaded/recordings".to_string(); + next.video.as_mut().and_then(|video| video.recording.as_mut()).expect("initial recording set").directory = + "/reloaded/recordings".to_string(); app_config.config.store(std::sync::Arc::new(next)); - let after = recording_config(&app_config).expect("canonical recording still visible after reload"); - assert_eq!(after.directory, "/reloaded/recordings", "recording_config must read the swapped Config.recording"); + let after = recording_config(&app_config).expect("recording still visible after reload"); + assert_eq!(after.directory, "/reloaded/recordings", "recording_config must read the swapped Config.video"); } #[tokio::test] async fn recording_enabled_predicate_uses_canonical_recording_block() { - // `recording_enabled()` must consult `Config.recording.enabled` - // directly. The legacy `video.download.recording` carrier is - // ignored. + // `recording_enabled()` must consult `Config.video.recording.enabled`. let enabled_cfg = tuliprox_core::model::Config { - recording: Some(tuliprox_core::model::RecordingConfig::from(&shared::model::RecordingConfigDto { - enabled: true, - ..Default::default() - })), + video: Some(tuliprox_core::model::VideoConfig { + extensions: Vec::new(), + web_search: None, + recording: Some(tuliprox_core::model::RecordingConfig::from(&shared::model::RecordingConfigDto { + enabled: true, + ..Default::default() + })), + }), ..tuliprox_core::model::Config::default() }; let app_enabled = test_app_config(enabled_cfg); assert!(recording_enabled(&app_enabled), "enabled canonical block must enable DVR"); let disabled_cfg = tuliprox_core::model::Config { - recording: Some(tuliprox_core::model::RecordingConfig::from(&shared::model::RecordingConfigDto { - enabled: false, - ..Default::default() - })), + video: Some(tuliprox_core::model::VideoConfig { + extensions: Vec::new(), + web_search: None, + recording: Some(tuliprox_core::model::RecordingConfig::from(&shared::model::RecordingConfigDto { + enabled: false, + ..Default::default() + })), + }), ..tuliprox_core::model::Config::default() }; let app_disabled = test_app_config(disabled_cfg); diff --git a/backend/parser/src/m3u.rs b/backend/parser/src/m3u.rs index e248157f6..d023677f9 100644 --- a/backend/parser/src/m3u.rs +++ b/backend/parser/src/m3u.rs @@ -24,8 +24,7 @@ use tuliprox_core::{ /// The default is wrapped in `Arc` once via `LazyLock` so repeated /// `build_series_info` calls do not re-clone the regex. fn resolve_episode_pattern(cfg: &Config) -> Arc { - cfg.recording - .as_ref() + cfg.recording() .and_then(|recording| recording.episode_pattern.as_ref().map(Arc::clone)) .unwrap_or_else(default_episode_pattern_arc) } @@ -521,7 +520,7 @@ pub async fn consume_m3u(cfg: &Config, input: &ConfigInp let mut default_catchup_correction: Option> = None; let input_name = &input.name; - let video_suffixes = match cfg.recording.as_ref() { + let video_suffixes = match cfg.video.as_ref() { Some(config) => config.extensions.clone(), None => default_supported_video_extensions(), }; @@ -1235,10 +1234,11 @@ https://example.test/series/user/pass/episode-2 #[tokio::test] async fn consume_m3u_respects_custom_recording_extensions_when_present() { let cfg = Config { - recording: Some(tuliprox_core::model::RecordingConfig::from(&shared::model::RecordingConfigDto { + video: Some(tuliprox_core::model::VideoConfig { extensions: vec!["mp4".to_string()], - ..Default::default() - })), + web_search: None, + recording: None, + }), ..Config::default() }; let content = concat!("#EXTM3U\n", "#EXTINF:-1,Channel 1\n", "http://provider.example/v.mp4\n",); diff --git a/config/config.yml b/config/config.yml index 95d9f736c..802f43a78 100644 --- a/config/config.yml +++ b/config/config.yml @@ -62,16 +62,15 @@ video: - mpeg - divx - mov - download: + recording: headers: Accept: video/* User-Agent: | Mozilla/5.0 (AppleTV; U; CPU OS 14_2 like Mac OS X; en-us) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.1 Safari/605.1.15 - directory: ${env:TULIPROX_HOME}/downloads + directory: ${env:TULIPROX_HOME}/recordings organize_into_directories: false episode_pattern: .*(?P[Ss]\d{1,2}(.*?)[Ee]\d{1,2}).* - download_priority: 1 - recording_priority: 0 + priority: 1 reserve_slots_for_users: 1 max_background_per_provider: 1 retry_backoff_initial_secs: 3 @@ -79,52 +78,34 @@ video: retry_backoff_max_secs: 30 retry_backoff_jitter_percent: 20 retry_max_attempts: 5 - # DVR. Every field below is optional and shown at its default value; - # see docs/src/operator/dvr.md for the full reference. - # recording: - # # Master switch. `false` stops reconciliation, retention, and the - # # notification outbox, and the DVR routes stop doing work. - # enabled: true - # # Muxer ffmpeg writes: mpegts | matroska | mp4. mpegts survives - # # truncation, so a recording killed mid-stream still plays. - # container_format: mpegts - # directory: ${env:TULIPROX_HOME}/downloads/recordings - # timezone: UTC # IANA zone; used for rule timeslots - # filename_template: "{channel}_{program_title}_{start_time}" - # default_pre_roll_secs: 0 - # max_pre_roll_secs: 900 # 15 min - # default_post_roll_secs: 0 - # max_post_roll_secs: 1800 # 30 min - # # Estimated size for an unknown-bitrate recording, used for the - # # quota reservation before any bytes are measured. - # fallback_bytes_per_minute: 8388608 # 8 MiB/min - # retention: - # # Both policies are off when unset. Retention is the only thing - # # bounding recording disk use, so set at least one on a shared box. - # keep_last_per_channel: 10 - # delete_after_days: 30 - # sweep_interval_secs: 3600 # age/count sweep cadence - # disk: - # # Used-space percentages on the recording root's filesystem. When - # # used >= high, oldest completed recordings are deleted until - # # used <= low. Active recordings are never touched. - # high_water_percent: 85 - # low_water_percent: 70 - # cleanup_interval_secs: 3600 # watermark-check cadence - # safety_bytes: 1073741824 # 1 GiB kept free at all times - # quota: - # # Unset means unlimited. Charged by task state; see the operator doc. - # default_private_bytes: 53687091200 # 50 GiB per user - # shared_bytes: 536870912000 # 500 GiB for shared recordings - # per_user_bytes: - # "web:user-uuid-1": 107374182400 # 100 GiB override - # notifications: - # # Lifecycle notifications are delivered by a durable outbox that - # # retries per channel. outbox_buffer is fixed at startup. - # outbox_buffer: 1024 - # max_attempts: 6 - # backoff_initial_secs: 5 - # backoff_max_secs: 900 + enabled: true + container_format: mpegts + timezone: UTC + filename_template: "{channel}_{program_title}_{start_time}" + default_pre_roll_secs: 0 + max_pre_roll_secs: 900 + default_post_roll_secs: 0 + max_post_roll_secs: 1800 + fallback_bytes_per_minute: 8388608 + retention: + keep_last_per_channel: 10 + delete_after_days: 30 + sweep_interval_secs: 3600 + disk: + high_water_percent: 85 + low_water_percent: 70 + cleanup_interval_secs: 3600 + safety_bytes: 1073741824 + quota: + default_private_bytes: 53687091200 + shared_bytes: 536870912000 + per_user_bytes: + "web:user-uuid-1": 107374182400 + notifications: + outbox_buffer: 1024 + max_attempts: 6 + backoff_initial_secs: 5 + backoff_max_secs: 900 reverse_proxy: resource_retry: diff --git a/docs/src/configuration/config.md b/docs/src/configuration/config.md index 4ca6d62dc..48cde13c5 100644 --- a/docs/src/configuration/config.md +++ b/docs/src/configuration/config.md @@ -643,8 +643,8 @@ Optional video-related behaviors, mostly utilized by the Web UI. video: web_search: "https://www.imdb.com/search/title/?title={}" extensions: [ "mkv", "mp4", "avi", "ts", "webm" ] - download: - directory: /tmp/tuliprox_downloads + recording: + directory: /tmp/tuliprox_recordings headers: User-Agent: "AppleTV/tvOS/9.1.1" Accept: "video/*" @@ -654,17 +654,15 @@ video: * `web_search`: A template URL used in the Web UI to quickly search for a movie title (replaces `{}` with the title). * `extensions`: Defines which file endings Tuliprox categorizes as VOD/Video content when transforming M3U to Xtream. -* `download`: Configuration for the Web UI download and recording manager. - * `directory`: Where downloaded files and recordings are saved. +* `recording`: Configuration for the DVR. + * `directory`: Where recordings are saved. * `headers` (optional): Custom HTTP headers used for the download request. This is useful for bypassing basic user-agent filters or setting specific media types. * `organize_into_directories`: If true, Tuliprox automatically creates neat subfolders for series. * `episode_pattern`: Crucial for the directory organization. It uses the mandatory Named Capture Group `(?P...)` in the Regex to identify and strip the episode identifier (e.g., `S01E01`) from the filename, ensuring all episodes of a show land in the same base-show folder. - * `download_priority`: Default provider priority for VOD/series/episode downloads. Lower values mean higher - priority. - * `recording_priority`: Default provider priority for live recordings. Lower values mean higher priority. + * `priority`: Provider priority for recordings. Lower values mean higher priority. * `reserve_slots_for_users`: Keeps provider headroom for normal foreground users before background-priority transfers may consume the last slots. @@ -681,7 +679,7 @@ Tuliprox handles these transfers like provider-bound background streams: * They respect provider limits, user priorities, and connection preemption instead of bypassing normal stream capacity. * Waiting for provider capacity is notify-based, not polling-based. * The Web UI loads an initial transfer snapshot and then stays synchronized through websocket updates. -* Changes to `video.download` participate in hot config reloads. The background scheduler restarts and active transfers +* Changes to `video.recording` participate in hot config reloads. The background scheduler restarts and active transfers are re-queued so they continue under the updated download configuration. * RBAC integration is explicit: diff --git a/docs/src/operator/dvr.md b/docs/src/operator/dvr.md index cd13d5dbf..4b01560a5 100644 --- a/docs/src/operator/dvr.md +++ b/docs/src/operator/dvr.md @@ -7,42 +7,41 @@ consume. ## 1. Configuration reference -All new fields live under `video.download.recording` in the config file. Every field is optional; +All fields live under `video.recording` in the config file. Every field is optional; defaults match the recommended values. ```yaml video: - download: - recording: - enabled: true # default: true; false stops every DVR supervisor - container_format: mpegts # mpegts (default) | matroska | mp4 - directory: recordings/ # default: /recordings - timezone: Europe/Berlin # default: UTC (IANA required) - filename_template: "{channel}_{program_title}_{start_time}" - default_pre_roll_secs: 0 # 0..=max_pre_roll_secs - max_pre_roll_secs: 900 # ≤ 900 (15 min) - default_post_roll_secs: 0 # 0..=max_post_roll_secs - max_post_roll_secs: 1800 # ≤ 1800 (30 min) - retention: + recording: + enabled: true # default: true; false stops every DVR supervisor + container_format: mpegts # mpegts (default) | matroska | mp4 + directory: recordings/ # default: recordings + timezone: Europe/Berlin # default: UTC (IANA required) + filename_template: "{channel}_{program_title}_{start_time}" + default_pre_roll_secs: 0 # 0..=max_pre_roll_secs + max_pre_roll_secs: 900 # ≤ 900 (15 min) + default_post_roll_secs: 0 # 0..=max_post_roll_secs + max_post_roll_secs: 1800 # ≤ 1800 (30 min) + retention: keep_last_per_channel: 10 # > 0 when set delete_after_days: 30 # > 0 when set sweep_interval_secs: 3600 # default 3600; age/count sweep cadence - disk: + disk: high_water_percent: 85 # 0..=100 low_water_percent: 70 # 0..=100 and < high_water_percent cleanup_interval_secs: 3600 # > 0; watermark-check cadence safety_bytes: 1073741824 # > 0 (1 GiB) - quota: + quota: default_private_bytes: 53687091200 # 50 GiB per_user_bytes: "web:user-uuid-1": 107374182400 # 100 GiB shared_bytes: 536870912000 # 500 GiB - notifications: + notifications: outbox_buffer: 1024 # default 1024; in-memory queue depth max_attempts: 6 # default 6; then dead-lettered backoff_initial_secs: 5 # default 5 backoff_max_secs: 900 # default 900 (15 min) - fallback_bytes_per_minute: 8388608 # 8 MiB/min, > 0 + fallback_bytes_per_minute: 8388608 # 8 MiB/min, > 0 ``` | Field | Default | Range | Restart required | Effect | @@ -74,8 +73,8 @@ delete recordings you expected to keep. Before restarting after a configuration change: -1. **Read back your effective policy.** Check `video.download.recording.retention` and - `video.download.recording.disk` in `config.yml`. +1. **Read back your effective policy.** Check `video.recording.retention` and + `video.recording.disk` in `config.yml`. 2. **Work out what would be deleted.** `keep_last_per_channel: N` keeps the *N most recent* recordings per (owner, channel) and deletes the rest. `delete_after_days: N` deletes anything whose `completed_at` is more than N days old. The two are a **union**, not an intersection — @@ -667,9 +666,8 @@ The DVR is a feature flag, so a rollback does not require a binary downgrade: ```yaml video: - download: - recording: - enabled: false + recording: + enabled: false ``` That stops the supervisors and the rule scheduler, answers `501 recording_disabled` on the diff --git a/frontend/src/app/components/config/config_page.rs b/frontend/src/app/components/config/config_page.rs index c1e02467a..8afd84a92 100644 --- a/frontend/src/app/components/config/config_page.rs +++ b/frontend/src/app/components/config/config_page.rs @@ -2,8 +2,8 @@ use shared::{ error::TuliproxError, model::{ ApiProxyConfigDto, ConfigApiDto, HdHomeRunConfigDto, IpCheckConfigDto, LibraryConfigDto, LogConfigDto, - MainConfigDto, MessagingConfigDto, MetadataUpdateConfigDto, ProxyConfigDto, RecordingConfigDto, - ReverseProxyConfigDto, SchedulesConfigDto, SourcesConfigDto, WebUiConfigDto, + MainConfigDto, MessagingConfigDto, MetadataUpdateConfigDto, ProxyConfigDto, ReverseProxyConfigDto, + SchedulesConfigDto, SourcesConfigDto, VideoConfigDto, WebUiConfigDto, }, }; use std::{fmt, str::FromStr}; @@ -109,7 +109,7 @@ pub enum ConfigForm { ApiProxy(bool, ApiProxyConfigDto), Log(bool, LogConfigDto), Schedules(bool, SchedulesConfigDto), - Recording(bool, RecordingConfigDto), + Recording(bool, VideoConfigDto), MetadataUpdate(bool, MetadataUpdateConfigDto), Messaging(bool, MessagingConfigDto), WebUi(bool, WebUiConfigDto), diff --git a/frontend/src/app/components/config/config_update.rs b/frontend/src/app/components/config/config_update.rs index 6bbab7385..c630f110a 100644 --- a/frontend/src/app/components/config/config_update.rs +++ b/frontend/src/app/components/config/config_update.rs @@ -128,7 +128,12 @@ pub fn update_config(config: &mut ConfigDto, forms: Vec) { config.schedules = schedules_cfg.schedules.clone(); } } - ConfigForm::Recording(_, mut recording_cfg) => set_config_field!(config, recording_cfg, recording), + ConfigForm::Recording(_, mut video_cfg) => { + if let Some(recording) = video_cfg.recording.as_mut() { + recording.clean(); + } + config.video = Some(video_cfg); + } ConfigForm::MetadataUpdate(_, mut metadata_update_cfg) => { set_config_field!(config, metadata_update_cfg, metadata_update); } @@ -161,7 +166,7 @@ mod tests { HdHomeRunDeviceConfigDto, LibraryConfigDto, LibraryScanDirectoryDto, MessagingConfigDto, MetadataUpdateConfigDto, MsgKind, ProxyConfigDto, RecordingConfigDto, RecordingDiskConfigDto, RecordingNotificationConfigDto, RecordingQuotaConfigDto, RecordingRetentionConfigDto, StreamInfoConfigDto, - WebAuthConfigDto, WebUiConfigDto, + VideoConfigDto, WebAuthConfigDto, WebUiConfigDto, }; #[test] @@ -586,7 +591,13 @@ mod tests { }; let mut config = ConfigDto::default(); - update_config(&mut config, vec![ConfigForm::Recording(true, recording)]); + update_config( + &mut config, + vec![ConfigForm::Recording( + true, + VideoConfigDto { extensions: vec!["ts".to_string()], web_search: None, recording: Some(recording) }, + )], + ); let serialized = serde_json::to_string(&config); assert!(serialized.is_ok(), "config should serialize"); @@ -594,7 +605,7 @@ mod tests { let config: Result = serde_json::from_str(&serialized); assert!(config.is_ok(), "config should deserialize"); let Ok(config) = config else { return }; - let recording = config.recording.as_ref(); + let recording = config.video.as_ref().and_then(|video| video.recording.as_ref()); assert!(recording.is_some(), "explicit recording block should round-trip"); let Some(recording) = recording else { return }; assert!(recording.retention.is_none()); @@ -607,7 +618,17 @@ mod tests { fn update_config_round_trips_explicit_empty_recording() { let mut config = ConfigDto::default(); - update_config(&mut config, vec![ConfigForm::Recording(true, RecordingConfigDto::default())]); + update_config( + &mut config, + vec![ConfigForm::Recording( + true, + VideoConfigDto { + extensions: vec!["ts".to_string()], + web_search: None, + recording: Some(RecordingConfigDto::default()), + }, + )], + ); let serialized = serde_json::to_string(&config); assert!(serialized.is_ok(), "config should serialize"); @@ -615,7 +636,7 @@ mod tests { let config: Result = serde_json::from_str(&serialized); assert!(config.is_ok(), "config should deserialize"); let Ok(config) = config else { return }; - let recording = config.recording.as_ref(); + let recording = config.video.as_ref().and_then(|video| video.recording.as_ref()); assert!(recording.is_some(), "explicit recording block should round-trip"); let Some(recording) = recording else { return }; assert!(recording.enabled); @@ -630,7 +651,13 @@ mod tests { }; let mut config = ConfigDto::default(); - let result = apply_and_prepare_config(&mut config, vec![ConfigForm::Recording(true, invalid_recording)]); + let result = apply_and_prepare_config( + &mut config, + vec![ConfigForm::Recording( + true, + VideoConfigDto { extensions: Vec::new(), web_search: None, recording: Some(invalid_recording) }, + )], + ); assert!(result.is_err(), "invalid recording config must be rejected"); let Err(error) = result else { return }; diff --git a/frontend/src/app/components/config/recording_config_cards.rs b/frontend/src/app/components/config/recording_config_cards.rs index b5e7d5063..ec816343d 100644 --- a/frontend/src/app/components/config/recording_config_cards.rs +++ b/frontend/src/app/components/config/recording_config_cards.rs @@ -19,6 +19,7 @@ generate_form_reducer!( action_name: RecordingConfigFormAction, fields { Enabled => enabled: bool, + Priority => priority: i8, ContainerFormat => container_format: RecordingContainerFormat, Directory => directory: Option, Timezone => timezone: Option, @@ -159,7 +160,7 @@ pub const RECORDING_CARDS: [RecordingCardDescriptor; 5] = [ pub const RECORDING_FIELDS: [RecordingFieldDescriptor; 25] = [ RecordingFieldDescriptor { id: "enabled", label: "LABEL.RECORDING_ENABLED", card: 0 }, - RecordingFieldDescriptor { id: "recording_priority", label: "LABEL.PRIORITY", card: 0 }, + RecordingFieldDescriptor { id: "priority", label: "LABEL.PRIORITY", card: 0 }, RecordingFieldDescriptor { id: "container_format", label: "LABEL.CONTAINER", card: 0 }, RecordingFieldDescriptor { id: "directory", label: "LABEL.DIRECTORY", card: 0 }, RecordingFieldDescriptor { id: "timezone", label: "LABEL.TIMEZONE", card: 0 }, @@ -201,11 +202,9 @@ pub fn recording_container_from_id(id: &str) -> Option #[derive(Properties, Clone, PartialEq)] pub struct RecordingConfigCardsProps { pub recording: RecordingConfigDto, - pub recording_priority: i8, pub reload_generation: u64, pub edit_mode: bool, pub on_change: Callback<(bool, RecordingConfigDto)>, - pub on_recording_priority_change: Callback, pub on_error: Callback, } @@ -300,7 +299,7 @@ pub fn RecordingConfigCards(props: &RecordingConfigCardsProps) -> Html { let render_view_field = |field: &RecordingFieldDescriptor| { let label = translate.t(field.label); let value = match field.id { - "recording_priority" => props.recording_priority.to_string(), + "priority" => direct_state.form.priority.to_string(), "container_format" => recording_container_id(direct_state.form.container_format).to_string(), "directory" => direct_state.form.directory.clone().unwrap_or_default(), "timezone" => direct_state.form.timezone.clone().unwrap_or_default(), @@ -375,9 +374,9 @@ pub fn RecordingConfigCards(props: &RecordingConfigCardsProps) -> Html { let state = direct_state.clone(); html! {
} } - "recording_priority" => { - let callback = props.on_recording_priority_change.clone(); - html! { | { if let Some(value) = value.and_then(|value| i8::try_from(value).ok()) { callback.emit(value); } })} /> } + "priority" => { + let state = direct_state.clone(); + html! { | { if let Some(value) = value.and_then(|value| i8::try_from(value).ok()) { state.dispatch(RecordingConfigFormAction::Priority(value)); } })} /> } } "container_format" => { let state = direct_state.clone(); @@ -852,7 +851,7 @@ mod tests { let actual = RECORDING_FIELDS.iter().map(|field| field.id).collect::>(); let expected = std::collections::HashSet::from([ "enabled", - "recording_priority", + "priority", "container_format", "directory", "timezone", @@ -984,11 +983,9 @@ mod browser_tests { @@ -1002,7 +999,14 @@ mod browser_tests { let edit_mode = use_state(|| true); let emissions = Rc::clone(&props.emissions); let config = shared::model::AppConfigDto { - config: shared::model::ConfigDto { recording: Some(props.recording.clone()), ..Default::default() }, + config: shared::model::ConfigDto { + video: Some(shared::model::VideoConfigDto { + extensions: Vec::new(), + web_search: None, + recording: Some(props.recording.clone()), + }), + ..Default::default() + }, ..Default::default() }; let config_ctx = ConfigContext { config: Some(Rc::new(config)), api_proxy: None }; @@ -1212,13 +1216,16 @@ mod browser_tests { emissions.borrow_mut().clear(); set_input(&input(&root, "timezone")?, "UTC")?; - set_input(&input(&root, "recording_priority")?, "9")?; + set_input(&input(&root, "priority")?, "9")?; settle().await; let last = emissions.borrow().last().cloned(); assert!(last.is_some_and(|form| match form { - ConfigForm::Recording(modified, recording) => { - modified && recording.priority == 9 && recording.timezone.as_deref() == Some("UTC") + ConfigForm::Recording(modified, video) => { + modified + && video.recording.as_ref().is_some_and(|recording| { + recording.priority == 9 && recording.timezone.as_deref() == Some("UTC") + }) } _ => false, })); diff --git a/frontend/src/app/components/config/recording_config_view.rs b/frontend/src/app/components/config/recording_config_view.rs index 9a4bacd64..caf5ce074 100644 --- a/frontend/src/app/components/config/recording_config_view.rs +++ b/frontend/src/app/components/config/recording_config_view.rs @@ -6,7 +6,7 @@ use crate::{ config_page::LABEL_RECORDING_CONFIG, config_view_context::ConfigViewContext, use_emit_mapped, RecordingConfigCards, }, - Card, Chip, KeyValueEditor, + Card, KeyValueEditor, }, context::ConfigContext, }, @@ -16,7 +16,7 @@ use crate::{ i18n::use_translation, use_default_form_reducer, }; -use shared::model::RecordingConfigDto; +use shared::model::{RecordingConfigDto, VideoConfigDto}; use std::collections::HashMap; use yew::prelude::*; @@ -39,12 +39,10 @@ generate_form_reducer!( state: RecordingConfigFormState { form: RecordingConfigDto }, action_name: RecordingConfigFormAction, fields { - Extensions => extensions: Vec, OrganizeIntoDirectories => organize_into_directories: bool, Directory => directory: Option, EpisodePattern => episode_pattern: Option, Headers => headers: HashMap, - Priority => priority: i8, ReserveSlotsForUsers => reserve_slots_for_users: u8, MaxBackgroundPerProvider => max_background_per_provider: u8, RetryBackoffInitialSecs => retry_backoff_initial_secs: u64, @@ -55,6 +53,14 @@ generate_form_reducer!( } ); +generate_form_reducer!( + state: VideoConfigFormState { form: VideoConfigDto }, + action_name: VideoConfigFormAction, + fields { + Extensions => extensions: Vec, + } +); + #[component] pub fn RecordingConfigView() -> Html { let translate = use_translation(); @@ -63,16 +69,23 @@ pub fn RecordingConfigView() -> Html { let config_view_ctx = use_context::().expect("ConfigViewContext not found"); let state: UseReducerHandle = use_default_form_reducer!(RecordingConfigFormState { form: RecordingConfigDto::default() }); + let video_state: UseReducerHandle = + use_default_form_reducer!(VideoConfigFormState { form: VideoConfigDto::default() }); let reload_generation = use_state(|| 0_u64); { let state = state.clone(); + let video_state = video_state.clone(); let reload_generation = reload_generation.clone(); - let recording = config_ctx.config.as_ref().and_then(|config| config.config.recording.clone()); + let video = config_ctx.config.as_ref().and_then(|config| config.config.video.as_ref()).cloned(); let edit_mode = *config_view_ctx.edit_mode; - use_effect_with((recording, edit_mode), move |(recording, _)| { + use_effect_with((video, edit_mode), move |(video, _)| { + video_state.dispatch(VideoConfigFormAction::SetAll(video.clone().unwrap_or_default())); state.dispatch(RecordingConfigFormAction::SetAll( - recording.clone().unwrap_or_else(|| RecordingConfigDto { enabled: false, ..Default::default() }), + video + .as_ref() + .and_then(|video| video.recording.clone()) + .unwrap_or_else(|| RecordingConfigDto { enabled: false, ..Default::default() }), )); reload_generation.set((*reload_generation).wrapping_add(1)); || () @@ -80,9 +93,12 @@ pub fn RecordingConfigView() -> Html { } use_emit_mapped( - (state.form.clone(), state.modified), + (state.form.clone(), video_state.form.clone(), state.modified || video_state.modified), config_view_ctx.on_form_change.clone(), - |(form, modified)| ConfigForm::Recording(modified, form), + |(recording, mut video, modified)| { + video.recording = Some(recording); + ConfigForm::Recording(modified, video) + }, ); let handle_headers = { @@ -93,16 +109,11 @@ pub fn RecordingConfigView() -> Html { let state = state.clone(); Callback::from(move |(_, recording)| state.dispatch(RecordingConfigFormAction::SetAll(recording))) }; - let handle_priority = { - let state = state.clone(); - Callback::from(move |priority| state.dispatch(RecordingConfigFormAction::Priority(priority))) - }; - let transfer_view = html! { <> { config_field_child!(translate.t(LABEL_EXTENSIONS), "RECORDING_CONFIG.EXTENSIONS", { - html! {
{ for state.form.extensions.iter().map(|extension| html! { }) }
} + html! {
{ for video_state.form.extensions.iter().map(|extension| html! { {extension} }) }
} }) }
@@ -128,7 +139,7 @@ pub fn RecordingConfigView() -> Html { let transfer_edit = html! { <> - { edit_field_list!(state, translate.t(LABEL_EXTENSIONS), extensions, RecordingConfigFormAction::Extensions, translate.t(LABEL_ADD_EXTENSION)) } + { edit_field_list!(video_state, translate.t(LABEL_EXTENSIONS), extensions, VideoConfigFormAction::Extensions, translate.t(LABEL_ADD_EXTENSION)) } { edit_field_bool!(state, translate.t(LABEL_ORGANIZE_INTO_DIRECTORIES), organize_into_directories, RecordingConfigFormAction::OrganizeIntoDirectories) } { edit_field_text_option!(state, translate.t(LABEL_DIRECTORY), directory, RecordingConfigFormAction::Directory) } @@ -155,11 +166,9 @@ pub fn RecordingConfigView() -> Html { { if *config_view_ctx.edit_mode { transfer_edit } else { transfer_view } } diff --git a/frontend/src/app/components/playlist/epg_view.rs b/frontend/src/app/components/playlist/epg_view.rs index 170b43201..d583c307e 100644 --- a/frontend/src/app/components/playlist/epg_view.rs +++ b/frontend/src/app/components/playlist/epg_view.rs @@ -243,7 +243,6 @@ pub fn EpgView() -> Html { .config .as_ref() .and_then(|cfg| cfg.config.video.as_ref()) - .and_then(|video| video.download.as_ref()) .and_then(|video| video.recording.as_ref()); Rc::new(PaddingBounds { default_pre_roll_secs: rec.and_then(|c| c.default_pre_roll_secs).unwrap_or(0), diff --git a/frontend/src/app/components/playlist/playlist_explorer.rs b/frontend/src/app/components/playlist/playlist_explorer.rs index 4c5f015f9..df1d0f7e2 100644 --- a/frontend/src/app/components/playlist/playlist_explorer.rs +++ b/frontend/src/app/components/playlist/playlist_explorer.rs @@ -159,10 +159,18 @@ pub fn PlaylistExplorer() -> Html { let can_write_downloads = service_ctx.auth.has_permission(Permission::RecordingWrite); let can_write_recordings = service_ctx.auth.has_permission(Permission::RecordingWrite); let is_admin_role = service_ctx.auth.is_admin(); - let default_download_priority = - config_ctx.config.as_ref().and_then(|cfg| cfg.config.recording.as_ref()).map(|recording| recording.priority); + let default_dvr_priority = config_ctx + .config + .as_ref() + .and_then(|cfg| cfg.config.video.as_ref()) + .and_then(|video| video.recording.as_ref()) + .map(|recording| recording.priority); let recording_padding = { - let rec = config_ctx.config.as_ref().and_then(|cfg| cfg.config.recording.as_ref()); + let rec = config_ctx + .config + .as_ref() + .and_then(|cfg| cfg.config.video.as_ref()) + .and_then(|video| video.recording.as_ref()); PaddingBounds { default_pre_roll_secs: rec.and_then(|c| c.default_pre_roll_secs).unwrap_or(0), max_pre_roll_secs: rec.map_or(900, |c| c.max_pre_roll_secs), @@ -426,7 +434,7 @@ pub fn PlaylistExplorer() -> Html { let services = services.clone(); let translate_clone = translate_clone.clone(); let playlist_request = (*playlist_ctx.playlist_request).clone(); - let default_download_priority = default_download_priority; + let default_dvr_priority = default_dvr_priority; let selected = dto.clone(); spawn_local(async move { let resolved_url = if !selected.url.is_empty() { @@ -467,9 +475,9 @@ pub fn PlaylistExplorer() -> Html { let default_filename = build_download_filename(&selected.title, &resolved_url); let filename_value = Rc::new(RefCell::new(default_filename.clone())); - let default_download_priority_value = - default_download_priority.map_or_else(String::new, |priority| priority.to_string()); - let priority_value = Rc::new(RefCell::new(default_download_priority_value.clone())); + let default_dvr_priority_value = + default_dvr_priority.map_or_else(String::new, |priority| priority.to_string()); + let priority_value = Rc::new(RefCell::new(default_dvr_priority_value.clone())); let actions = DialogActions { left: Some(vec![DialogAction::new( "cancel", @@ -513,7 +521,7 @@ pub fn PlaylistExplorer() -> Html { min="-127" max="127" step="1" - value={default_download_priority_value.clone()} + value={default_dvr_priority_value.clone()} oninput={Callback::from(move |event: InputEvent| { let input: HtmlInputElement = event.target_unchecked_into(); *priority_value_input.borrow_mut() = input.value(); diff --git a/frontend/src/app/components/recording/recording_library_view.rs b/frontend/src/app/components/recording/recording_library_view.rs index 0227b1646..3d45d521f 100644 --- a/frontend/src/app/components/recording/recording_library_view.rs +++ b/frontend/src/app/components/recording/recording_library_view.rs @@ -8,8 +8,8 @@ use crate::{ }; use shared::{ model::{ - DownloadsDelta, DownloadsResponse, FileDownloadDto, Permission, ProtocolMessage, RecordingTypeDto, SortOrder, - TransferStatusDto, + DownloadsDelta, DownloadsResponse, Permission, ProtocolMessage, RecordingTypeDto, SortOrder, TransferStatusDto, + TransferTaskDto, }, utils::unix_ts_to_str, }; @@ -45,9 +45,9 @@ struct DownloadActionAvailability { fn normalize_download_tab( current: &DownloadTab, - queue: &[FileDownloadDto], - finished: &[FileDownloadDto], - active: &[Rc], + queue: &[TransferTaskDto], + finished: &[TransferTaskDto], + active: &[Rc], ) -> DownloadTab { match current { DownloadTab::Finished if finished.is_empty() && (!active.is_empty() || !queue.is_empty()) => DownloadTab::Queue, @@ -57,13 +57,13 @@ fn normalize_download_tab( fn collect_downloads_for_tab( tab: &DownloadTab, - queue: &Rc>, - finished: &Rc>, - active: &Rc>>, -) -> Vec> { + queue: &Rc>, + finished: &Rc>, + active: &Rc>>, +) -> Vec> { match tab { DownloadTab::Queue => { - let mut items: Vec> = active.iter().cloned().collect(); + let mut items: Vec> = active.iter().cloned().collect(); items.extend(queue.iter().cloned().map(Rc::new)); items } @@ -71,7 +71,7 @@ fn collect_downloads_for_tab( } } -fn sort_download_items(items: &mut [Rc], sort: Option<(usize, SortOrder)>) { +fn sort_download_items(items: &mut [Rc], sort: Option<(usize, SortOrder)>) { if let Some((col, order)) = sort { items.sort_by(|a, b| match order { SortOrder::Asc => compare_downloads(a, b, col), @@ -83,11 +83,11 @@ fn sort_download_items(items: &mut [Rc], sort: Option<(usize, S fn collect_sorted_downloads_for_tab( tab: &DownloadTab, - queue: &Rc>, - finished: &Rc>, - active: &Rc>>, + queue: &Rc>, + finished: &Rc>, + active: &Rc>>, sort: Option<(usize, SortOrder)>, -) -> Vec> { +) -> Vec> { let mut items = collect_downloads_for_tab(tab, queue, finished, active); sort_download_items(&mut items, sort); items @@ -102,7 +102,7 @@ fn format_download_kind(translate: &crate::i18n::YewI18n, kind: RecordingTypeDto } } -fn format_download_progress(download: &FileDownloadDto) -> String { +fn format_download_progress(download: &TransferTaskDto) -> String { if let Some(total) = download.total_bytes { if total > 0 { let percent = ((download.downloaded_bytes as f64 / total as f64) * 100.0).round() as u32; @@ -112,7 +112,7 @@ fn format_download_progress(download: &FileDownloadDto) -> String { format_bytes(download.downloaded_bytes) } -fn render_download_progress(download: &FileDownloadDto) -> Html { +fn render_download_progress(download: &TransferTaskDto) -> Html { let text = format_download_progress(download); let bar = download.total_bytes.filter(|total| *total > 0).map(|total| { html! { @@ -130,11 +130,11 @@ fn render_download_progress(download: &FileDownloadDto) -> Html { } } -fn format_download_start(download: &FileDownloadDto) -> String { +fn format_download_start(download: &TransferTaskDto) -> String { download.scheduled_start_at.and_then(unix_ts_to_str).unwrap_or_default() } -fn format_download_duration(download: &FileDownloadDto) -> String { +fn format_download_duration(download: &TransferTaskDto) -> String { download .duration_secs .map(|seconds| { @@ -149,7 +149,7 @@ fn format_download_duration(download: &FileDownloadDto) -> String { .unwrap_or_default() } -fn format_download_error_parts(download: &FileDownloadDto, attempt_label: &str, next_retry_label: &str) -> String { +fn format_download_error_parts(download: &TransferTaskDto, attempt_label: &str, next_retry_label: &str) -> String { let mut parts = Vec::new(); if let Some(error) = download.error.as_ref().filter(|error| !error.is_empty()) { parts.push(error.clone()); @@ -163,11 +163,11 @@ fn format_download_error_parts(download: &FileDownloadDto, attempt_label: &str, parts.join(" | ") } -fn format_download_error(translate: &crate::i18n::YewI18n, download: &FileDownloadDto) -> String { +fn format_download_error(translate: &crate::i18n::YewI18n, download: &TransferTaskDto) -> String { format_download_error_parts(download, &translate.t("LABEL.ATTEMPT"), &translate.t("LABEL.NEXT_RETRY")) } -fn compare_downloads(a: &FileDownloadDto, b: &FileDownloadDto, col: usize) -> Ordering { +fn compare_downloads(a: &TransferTaskDto, b: &TransferTaskDto, col: usize) -> Ordering { match col { 1 => a.title.cmp(&b.title), 2 => a.kind.cmp(&b.kind), @@ -183,17 +183,22 @@ fn compare_downloads(a: &FileDownloadDto, b: &FileDownloadDto, col: usize) -> Or fn is_sortable(col: usize) -> bool { (1..=8).contains(&col) } -fn download_action_availability(can_write: bool, dto: &FileDownloadDto) -> DownloadActionAvailability { +fn download_action_availability(can_write: bool, dto: &TransferTaskDto) -> DownloadActionAvailability { if !can_write { return DownloadActionAvailability::default(); } + // Live recordings cannot be paused/resumed: stopping ffmpeg loses + // an unrecoverable part of the broadcast. All other recording + // kinds follow the same state machine as a normal transfer. + let is_live = matches!(dto.recording_type, RecordingTypeDto::Live); DownloadActionAvailability { - pause: matches!( - dto.status, - TransferStatusDto::Running | TransferStatusDto::WaitingForCapacity | TransferStatusDto::RetryWaiting - ), - resume: dto.status == TransferStatusDto::Paused, + pause: !is_live + && matches!( + dto.status, + TransferStatusDto::Running | TransferStatusDto::WaitingForCapacity | TransferStatusDto::RetryWaiting + ), + resume: !is_live && dto.status == TransferStatusDto::Paused, cancel: matches!( dto.status, TransferStatusDto::Running @@ -212,7 +217,7 @@ fn download_action_availability(can_write: bool, dto: &FileDownloadDto) -> Downl } fn optimistic_active_delta( - active: &Rc>>, + active: &Rc>>, uuid: &str, status: TransferStatusDto, ) -> Option { @@ -223,6 +228,54 @@ fn optimistic_active_delta( }) } +/// Split a flat per-user recording snapshot into the (queue, finished, +/// active) view the table renders. The recording WebSocket delivers a +/// single `Vec` filtered by the session; the table +/// still wants the three buckets the Downloads-WS used to ship. +fn partition_recording_tasks( + tasks: &[TransferTaskDto], +) -> (Vec, Vec, Vec>) { + let mut queue = Vec::new(); + let mut finished = Vec::new(); + let mut active = Vec::new(); + for task in tasks { + match task.status { + TransferStatusDto::Running + | TransferStatusDto::Paused + | TransferStatusDto::WaitingForCapacity + | TransferStatusDto::RetryWaiting => active.push(Rc::new(task.clone())), + TransferStatusDto::Completed | TransferStatusDto::Failed | TransferStatusDto::Cancelled => { + finished.push(task.clone()) + } + TransferStatusDto::Queued | TransferStatusDto::Scheduled => queue.push(task.clone()), + } + } + (queue, finished, active) +} + +fn split_recording_snapshot( + tasks: &Rc>, + queue_state: &UseStateHandle>>, + finished_state: &UseStateHandle>>, + active_state: &UseStateHandle>>>, +) { + let (queue, finished, active) = partition_recording_tasks(tasks); + queue_state.set(Rc::new(queue)); + finished_state.set(Rc::new(finished)); + active_state.set(Rc::new(active)); +} + +fn apply_recording_delta( + tasks: &Rc>, + queue_state: &UseStateHandle>>, + finished_state: &UseStateHandle>>, + active_state: &UseStateHandle>>>, +) { + // Server-reconciled full snapshot over the same revision. Cheap to + // re-partition; keeps the split logic in one place. + split_recording_snapshot(tasks, queue_state, finished_state, active_state); +} + fn handle_download_action_result( result: Result, success_message: &str, @@ -247,9 +300,9 @@ fn handle_download_action_result( fn apply_download_snapshot( response: &DownloadsResponse, - queue_state: &UseStateHandle>>, - finished_state: &UseStateHandle>>, - active_download: &UseStateHandle>>>, + queue_state: &UseStateHandle>>, + finished_state: &UseStateHandle>>, + active_download: &UseStateHandle>>>, ) { queue_state.set(Rc::new(response.queue.clone())); finished_state.set(Rc::new(response.finished.clone())); @@ -258,16 +311,16 @@ fn apply_download_snapshot( fn apply_download_delta( delta: &DownloadsDelta, - queue_state: &UseStateHandle>>, - finished_state: &UseStateHandle>>, - active_download: &UseStateHandle>>>, + queue_state: &UseStateHandle>>, + finished_state: &UseStateHandle>>, + active_download: &UseStateHandle>>>, ) { match delta { DownloadsDelta::SnapshotReset(response) => { apply_download_snapshot(response, queue_state, finished_state, active_download); } DownloadsDelta::ActivePatched(download) => { - let mut active_items: Vec> = active_download.iter().cloned().collect(); + let mut active_items: Vec> = active_download.iter().cloned().collect(); if let Some(existing) = active_items.iter_mut().find(|item| item.id == download.id) { *existing = Rc::new(download.clone()); } else { @@ -294,10 +347,10 @@ pub fn recording_library_view() -> Html { let dialog = use_context::().expect("Dialog service not found"); let has_download_write = services.auth.has_permission(Permission::RecordingWrite); let active_tab = use_state(|| DownloadTab::Queue); - let queue_state = use_state(|| Rc::new(Vec::::new())); - let finished_state = use_state(|| Rc::new(Vec::::new())); - let active_download = use_state(|| Rc::new(Vec::>::new())); - let table_items = use_state(|| None::>>>); + let queue_state = use_state(|| Rc::new(Vec::::new())); + let finished_state = use_state(|| Rc::new(Vec::::new())); + let active_download = use_state(|| Rc::new(Vec::>::new())); + let table_items = use_state(|| None::>>>); let sort_state = use_state(|| None::<(usize, SortOrder)>); // Distinguishes "still waiting for the first snapshot" from "queue is empty" let initial_loaded = use_state(|| false); @@ -305,7 +358,7 @@ pub fn recording_library_view() -> Html { let request_downloads = { let services = services.clone(); Callback::from(move |()| { - let _ = services.websocket.send_message(ProtocolMessage::DownloadsRequest); + let _ = services.websocket.send_message(ProtocolMessage::RecordingSnapshotRequest); }) }; @@ -319,13 +372,13 @@ pub fn recording_library_view() -> Html { use_effect_with((), move |()| { request_downloads_effect.emit(()); let sub_id = services.event.subscribe(move |msg| match msg { - crate::model::EventMessage::DownloadsUpdate(snapshot) => { + crate::model::EventMessage::RecordingSnapshot { tasks, .. } => { initial_loaded.set(true); - apply_download_snapshot(&snapshot, &queue_state, &finished_state, &active_download); + split_recording_snapshot(&tasks, &queue_state, &finished_state, &active_download); } - crate::model::EventMessage::DownloadsDeltaUpdate(delta) => { + crate::model::EventMessage::RecordingDelta { tasks, .. } => { initial_loaded.set(true); - apply_download_delta(&delta, &queue_state, &finished_state, &active_download); + apply_recording_delta(&tasks, &queue_state, &finished_state, &active_download); } crate::model::EventMessage::WebSocketStatus(true) => { request_downloads_effect.emit(()); @@ -510,8 +563,8 @@ pub fn recording_library_view() -> Html { let handle_cancel = handle_cancel.clone(); let handle_remove = handle_remove.clone(); let handle_retry = handle_retry.clone(); - Callback::<(usize, usize, Rc), Html>::from( - move |(_row, col, dto): (usize, usize, Rc)| match col { + Callback::<(usize, usize, Rc), Html>::from( + move |(_row, col, dto): (usize, usize, Rc)| match col { 0 => { let actions = download_action_availability(has_download_write, &dto); let retry_label = if dto.status == TransferStatusDto::Cancelled { "Resume" } else { "Retry" }; @@ -578,7 +631,7 @@ pub fn recording_library_view() -> Html { }) }; - let table_definition = Rc::new(TableDefinition:: { + let table_definition = Rc::new(TableDefinition:: { items: (*table_items).clone(), num_cols: HEADERS.len(), is_sortable: Callback::from(is_sortable), @@ -615,7 +668,7 @@ pub fn recording_library_view() -> Html {
if *initial_loaded { - definition={table_definition} /> + definition={table_definition} /> } else { } @@ -632,11 +685,11 @@ mod tests { collect_downloads_for_tab, collect_sorted_downloads_for_tab, download_action_availability, format_download_error_parts, is_sortable, normalize_download_tab, optimistic_active_delta, DownloadTab, }; - use shared::model::{DownloadsDelta, FileDownloadDto, RecordingTypeDto, SortOrder, TaskKindDto, TransferStatusDto}; + use shared::model::{DownloadsDelta, RecordingTypeDto, SortOrder, TaskKindDto, TransferStatusDto, TransferTaskDto}; use std::rc::Rc; - fn download(id: &str, status: TransferStatusDto) -> FileDownloadDto { - FileDownloadDto { + fn download(id: &str, status: TransferStatusDto) -> TransferTaskDto { + TransferTaskDto { id: id.to_string(), title: format!("{id}.mp4"), kind: TaskKindDto::Download, @@ -684,7 +737,7 @@ mod tests { fn finished_tab_stays_when_has_items() { let queue = vec![]; let finished = vec![download("done", TransferStatusDto::Completed)]; - let active: Rc>> = Rc::new(Vec::new()); + let active: Rc>> = Rc::new(Vec::new()); assert_eq!( normalize_download_tab(&DownloadTab::Finished, &queue, &finished, active.as_ref()), @@ -696,7 +749,7 @@ mod tests { fn finished_tab_falls_back_to_queue_when_empty() { let queue = vec![download("q1", TransferStatusDto::Queued)]; let finished = vec![]; - let active: Rc>> = Rc::new(Vec::new()); + let active: Rc>> = Rc::new(Vec::new()); assert_eq!( normalize_download_tab(&DownloadTab::Finished, &queue, &finished, active.as_ref()), @@ -705,13 +758,13 @@ mod tests { } fn apply_active_delta( - active: &Rc>>, + active: &Rc>>, delta: &DownloadsDelta, - ) -> Rc>> { + ) -> Rc>> { match delta { DownloadsDelta::SnapshotReset(response) => Rc::new(response.active.iter().cloned().map(Rc::new).collect()), DownloadsDelta::ActivePatched(download) => { - let mut active_items: Vec> = active.iter().cloned().collect(); + let mut active_items: Vec> = active.iter().cloned().collect(); if let Some(existing) = active_items.iter_mut().find(|item| item.id == download.id) { *existing = Rc::new(download.clone()); } else { @@ -821,7 +874,7 @@ mod tests { #[test] fn cancelled_recordings_do_not_offer_retry_semantics() { - let dto = FileDownloadDto { + let dto = TransferTaskDto { id: "rec".to_string(), title: "rec.ts".to_string(), kind: TaskKindDto::Recording, @@ -846,7 +899,7 @@ mod tests { #[test] fn download_actions_hide_retry_for_cancelled_recordings() { - let dto = FileDownloadDto { + let dto = TransferTaskDto { id: "rec".to_string(), title: "rec.ts".to_string(), kind: TaskKindDto::Recording, diff --git a/frontend/src/app/components/setup/setup_helpers.rs b/frontend/src/app/components/setup/setup_helpers.rs index cc4e96647..9776d30e8 100644 --- a/frontend/src/app/components/setup/setup_helpers.rs +++ b/frontend/src/app/components/setup/setup_helpers.rs @@ -179,7 +179,7 @@ fn apply_setup_config_forms(config: &mut shared::model::ConfigDto, forms: Vec config.api = api_cfg, ConfigForm::Log(_, log_cfg) => config.log = Some(log_cfg), ConfigForm::Schedules(_, schedules_cfg) => config.schedules = schedules_cfg.schedules, - ConfigForm::Recording(_, recording_cfg) => config.recording = Some(recording_cfg), + ConfigForm::Recording(_, video_cfg) => config.video = Some(video_cfg), ConfigForm::MetadataUpdate(_, mut metadata_update_cfg) => { if metadata_update_cfg.is_empty() { config.metadata_update = None; diff --git a/frontend/src/services/recording_service.rs b/frontend/src/services/recording_service.rs index 76dd1071d..ac1b94887 100644 --- a/frontend/src/services/recording_service.rs +++ b/frontend/src/services/recording_service.rs @@ -300,8 +300,8 @@ pub enum RecordingError { /// recording that has not finished yet. NotTerminal, /// `recording_disabled` — the DVR cannot run on this server: - /// either the `video.download` block is missing from the - /// configuration, or `video.download.recording.enabled` is + /// either the `video.recording` block is missing from the + /// configuration, or `video.recording.enabled` is /// `false`. Disabled, /// Catch-all for unrecognised codes so the frontend does not @@ -479,8 +479,6 @@ impl RecordingService { Ok(()) } - /// POST /recording/tasks/{id}/cancel - /// POST /recording/tasks/{id}/pause pub async fn pause_task(&self, id: &str) -> Result<(), RecordingError> { let req = RecordingTaskId { id: id.to_string() }; @@ -525,13 +523,10 @@ impl RecordingService { /// DELETE /recording/tasks/{id}/remove pub async fn remove_task(&self, id: &str) -> Result<(), RecordingError> { - let _ = request_delete::( - &format!("{}/remove", self.task_path(id)), - None, - Some(Encoding::Json), - ) - .await - .map_err(network)?; + let _ = + request_delete::(&format!("{}/remove", self.task_path(id)), None, Some(Encoding::Json)) + .await + .map_err(network)?; Ok(()) } diff --git a/shared/src/defaults/paths.rs b/shared/src/defaults/paths.rs index d1b54d183..096d1ac7a 100644 --- a/shared/src/defaults/paths.rs +++ b/shared/src/defaults/paths.rs @@ -15,7 +15,7 @@ pub const DEFAULT_BACKUP_DIR: &str = "backup"; pub const DEFAULT_CACHE_DIR: &str = "cache"; pub const DEFAULT_STORAGE_TEMP_DIR: &str = "tmp"; pub const DEFAULT_USER_CONFIG_DIR: &str = "user_config"; -pub const DEFAULT_DOWNLOAD_DIR: &str = "downloads"; +pub const DEFAULT_RECORDING_DIR: &str = "recordings"; pub const DEFAULT_STORAGE_DIR: &str = "data"; // TODO rename to storage and use data for config, storage, ... pub const DEFAULT_CUSTOM_STREAM_RESPONSE_PATH: &str = "resources"; @@ -39,10 +39,10 @@ pub fn is_default_dir_path(value: &str, default_dir: &str) -> bool { normalized == default_dir } -pub fn is_blank_or_default_download_dir(path: &Option) -> bool { - path.as_ref().is_none_or(|value| value.trim().is_empty() || is_default_dir_path(value, DEFAULT_DOWNLOAD_DIR)) +pub fn is_blank_or_default_recording_dir(path: &Option) -> bool { + path.as_ref().is_none_or(|value| value.trim().is_empty() || is_default_dir_path(value, DEFAULT_RECORDING_DIR)) } -pub fn default_download_dir() -> Option { Some(DEFAULT_DOWNLOAD_DIR.to_string()) } +pub fn default_recording_dir() -> Option { Some(DEFAULT_RECORDING_DIR.to_string()) } pub fn default_main_storage_dir() -> Option { Some(DEFAULT_STORAGE_DIR.to_string()) } pub fn default_main_backup_dir() -> Option { Some(DEFAULT_BACKUP_DIR.to_string()) } diff --git a/shared/src/error/tuliprox_error.rs b/shared/src/error/tuliprox_error.rs index 538330ff2..fe9762c56 100644 --- a/shared/src/error/tuliprox_error.rs +++ b/shared/src/error/tuliprox_error.rs @@ -127,8 +127,8 @@ pub enum TuliproxError { #[error("config stream history error: {0}")] ConfigStreamHistory(String), - #[error("config video download error: {0}")] - ConfigVideoDownload(String), + #[error("config recording error: {0}")] + ConfigRecording(String), #[error("config target error: {0}")] ConfigTarget(String), @@ -232,7 +232,7 @@ impl TuliproxError { | Self::ConfigSource(msg) | Self::ConfigStream(msg) | Self::ConfigStreamHistory(msg) - | Self::ConfigVideoDownload(msg) + | Self::ConfigRecording(msg) | Self::ConfigTarget(msg) | Self::ConfigWebUi(msg) | Self::RepositoryEpg(msg) diff --git a/shared/src/model/config/base.rs b/shared/src/model/config/base.rs index 3dcf4cf0f..b546a24a1 100644 --- a/shared/src/model/config/base.rs +++ b/shared/src/model/config/base.rs @@ -10,14 +10,14 @@ use crate::{ is_default_connect_timeout_secs, is_default_custom_stream_response_error_status, is_default_interner_gc_interval_secs, is_default_interner_gc_min_pool_size, is_false, is_none_or_empty_metadata_update, is_true, is_zero_u32, normalize_optional_config_file_path, - normalize_optional_dir, DEFAULT_BACKUP_DIR, DEFAULT_CUSTOM_STREAM_RESPONSE_PATH, DEFAULT_DOWNLOAD_DIR, - DEFAULT_STORAGE_DIR, DEFAULT_USER_CONFIG_DIR, MAPPING_FILE, TEMPLATE_FILE, + normalize_optional_dir, DEFAULT_BACKUP_DIR, DEFAULT_CUSTOM_STREAM_RESPONSE_PATH, DEFAULT_STORAGE_DIR, + DEFAULT_USER_CONFIG_DIR, MAPPING_FILE, TEMPLATE_FILE, }, error::TuliproxError, model::{ ConfigApiDto, HdHomeRunConfigDto, IpCheckConfigDto, LibraryConfigDto, LogConfigDto, MessagingConfigDto, - MetadataUpdateConfigDto, ProxyConfigDto, RecordingConfigDto, ReverseProxyConfigDto, ScheduleConfigDto, - VideoConfigDto, WebUiConfigDto, + MetadataUpdateConfigDto, ProxyConfigDto, ReverseProxyConfigDto, ScheduleConfigDto, VideoConfigDto, + WebUiConfigDto, }, utils::is_blank_optional_string, }; @@ -64,22 +64,7 @@ pub struct ConfigDto { skip_serializing_if = "is_default_custom_stream_response_error_status" )] pub custom_stream_response_error_status: u16, - /// Canonical top-level DVR configuration. The split is intentional: - /// download-side fields (`headers`, `extensions`, `episode_pattern`, - /// `retry_backoff_*`, ...) stay on - /// [`VideoDownloadConfigDto`] for the live/VOD download pipeline; - /// this block owns the DVR-only fields (directory, timezone, - /// retention, disk, quota, notifications). The legacy - /// `VideoDownloadConfigDto::recording` carrier remains populated - /// in memory while the compat shadow is consumed by readers. #[serde(default, skip_serializing_if = "Option::is_none")] - pub recording: Option, - /// Compat shadow of the legacy `video:` block. Populated on - /// deserialize so frontend forms still see what they expect, but - /// `skip_serializing` so canonical saves emit no `video:` key - /// (`web_search` and the legacy nested `recording:` ride along). - /// Remove in Task 11 once the frontend has moved off it. - #[serde(default, skip_serializing)] pub video: Option, #[serde(default, skip_serializing_if = "is_none_or_empty_metadata_update")] pub metadata_update: Option, @@ -142,7 +127,6 @@ impl Default for ConfigDto { custom_stream_response_timeout_secs: 0, custom_stream_response_enabled: true, custom_stream_response_error_status: default_custom_stream_response_error_status(), - recording: None, video: None, metadata_update: None, schedules: None, @@ -167,12 +151,8 @@ impl Default for ConfigDto { } } -// Hand-written `Deserialize` to enforce the recording-unification contract: -// * both `recording:` AND `video.download.recording:` set with non-empty -// content is rejected as ambiguous (the plan's fail-closed case). -// `Serialize` is derived: `#[serde(skip_serializing)]` on the `video` -// compat shadow keeps `web_search` and the legacy nested recording out -// of canonical output without hand-rolling a shadow struct. +// Hand-written deserialization keeps the top-level schema strict while +// preserving the project-specific defaults used by the configuration form. impl<'de> serde::Deserialize<'de> for ConfigDto { fn deserialize>(deserializer: D) -> Result { #[derive(serde::Deserialize)] @@ -202,8 +182,6 @@ impl<'de> serde::Deserialize<'de> for ConfigDto { #[serde(default = "default_custom_stream_response_error_status")] custom_stream_response_error_status: u16, #[serde(default)] - recording: Option, - #[serde(default)] video: Option, #[serde(default)] metadata_update: Option, @@ -247,22 +225,6 @@ impl<'de> serde::Deserialize<'de> for ConfigDto { let raw = Raw::deserialize(deserializer)?; - // Ambiguity guard: if both canonical and legacy nested - // recording blocks carry non-empty content, fail closed. - // Empty legacy `recording: {}` is allowed (frontend - // round-trips sometimes emit empty blocks). - if raw.recording.is_some() && raw.video.is_some() { - return Err(serde::de::Error::custom( - "ambiguous canonical `recording:` and legacy `video:` blocks; remove the legacy block", - )); - } - - // Derive the canonical recording block: - // * explicit `recording:` wins (canonical or empty) - // * otherwise lift `video.download.recording` into canonical - // * otherwise leave None (the default form has no recording) - let recording = raw.recording.or_else(|| raw.video.as_ref().map(migrate_legacy_video)); - Ok(Self { process_parallel: raw.process_parallel, api: raw.api, @@ -276,7 +238,6 @@ impl<'de> serde::Deserialize<'de> for ConfigDto { custom_stream_response_timeout_secs: raw.custom_stream_response_timeout_secs, custom_stream_response_enabled: raw.custom_stream_response_enabled, custom_stream_response_error_status: raw.custom_stream_response_error_status, - recording, video: raw.video, metadata_update: raw.metadata_update, schedules: raw.schedules, @@ -301,35 +262,6 @@ impl<'de> serde::Deserialize<'de> for ConfigDto { } } -fn migrate_legacy_video(video: &VideoConfigDto) -> RecordingConfigDto { - let Some(download) = video.download.as_ref() else { - return RecordingConfigDto { - enabled: false, - extensions: video.extensions.clone(), - ..RecordingConfigDto::default() - }; - }; - - let mut recording = download.recording.clone().unwrap_or_default(); - recording.headers.clone_from(&download.headers); - recording.extensions.clone_from(&video.extensions); - recording.organize_into_directories = download.organize_into_directories; - recording.episode_pattern.clone_from(&download.episode_pattern); - recording.priority = download.recording_priority; - recording.reserve_slots_for_users = download.reserve_slots_for_users; - recording.max_background_per_provider = download.max_background_per_provider; - recording.retry_backoff_initial_secs = download.retry_backoff_initial_secs; - recording.retry_backoff_multiplier = download.retry_backoff_multiplier; - recording.retry_backoff_max_secs = download.retry_backoff_max_secs; - recording.retry_backoff_jitter_percent = download.retry_backoff_jitter_percent; - recording.retry_max_attempts = download.retry_max_attempts; - if recording.directory.is_none() { - let download_dir = download.directory.as_deref().unwrap_or(DEFAULT_DOWNLOAD_DIR); - recording.directory = Some(crate::model::default_recording_directory(download_dir)); - } - recording -} - // This MainConfigDto is a copy of ConfigDto simple fields for form editing. // It has no other purpose than editing and saving the simple config values. // `recording` is intentionally stripped here — the main-config form edits @@ -559,8 +491,8 @@ impl ConfigDto { None => { self.video = Some(VideoConfigDto { extensions: default_supported_video_extensions(), - download: None, web_search: None, + recording: None, }); } Some(video) => match video.prepare() { @@ -569,29 +501,6 @@ impl ConfigDto { }, } - // Also prepare the canonical top-level `recording` block. When - // the user has only a `video.extensions` (no download, no nested - // recording), the canonical recording is silently disabled — - // there is no directory to record from. When `video.download` - // is present but no nested recording, the canonical recording defaults - // to enabled (the recording default). - let download_dir = self.video.as_ref().and_then(|v| v.download.as_ref()).and_then(|d| d.directory.clone()); - let has_download = self.video.as_ref().and_then(|v| v.download.as_ref()).is_some(); - let fallback_dir = download_dir.as_deref().unwrap_or(DEFAULT_DOWNLOAD_DIR); - if let Some(recording) = self.recording.as_mut() { - super::video_download::prepare_recording_config(recording, fallback_dir)?; - } else { - let mut recording = if has_download { - RecordingConfigDto::default() - } else { - RecordingConfigDto { enabled: false, ..RecordingConfigDto::default() } - }; - super::video_download::prepare_recording_config(&mut recording, fallback_dir)?; - if !recording.is_empty() || has_download { - self.recording = Some(recording); - } - } - Ok(()) } @@ -615,8 +524,8 @@ impl ConfigDto { } if let Some(video) = &self.video { - if let Some(download) = &video.download { - if let Some(episode_pattern) = &download.episode_pattern { + if let Some(recording) = &video.recording { + if let Some(episode_pattern) = &recording.episode_pattern { if !episode_pattern.is_empty() { let re = crate::model::REGEX_CACHE.get_or_compile(episode_pattern); if re.is_err() { @@ -673,7 +582,7 @@ impl ConfigDto { #[cfg(test)] mod tests { use super::*; - use crate::defaults::{default_supported_video_extensions, CONFIG_PATH}; + use crate::defaults::CONFIG_PATH; use serde_json::json; #[test] @@ -716,43 +625,6 @@ mod tests { assert_eq!(cfg.custom_stream_response_error_status, 502); } - #[test] - fn serializing_skips_video_for_default_values() { - let cfg = ConfigDto { - video: Some(VideoConfigDto { - extensions: default_supported_video_extensions(), - download: None, - web_search: None, - }), - ..ConfigDto::default() - }; - - let serialized = serde_json::to_string(&cfg).expect("config serialization should succeed"); - assert!(!serialized.contains("\"video\""), "expected no video field, got: {serialized}"); - } - - #[test] - fn serializing_omits_video_compat_shadow_even_when_populated() { - // The compat `video:` shadow is no longer emitted on - // serialization (the `skip_serializing` contract). Frontend - // forms keep populating it programmatically; canonical saves - // never re-emit it. - let cfg = ConfigDto { - video: Some(VideoConfigDto { - extensions: default_supported_video_extensions(), - download: None, - web_search: Some("https://example.org?q={}".to_string()), - }), - ..ConfigDto::default() - }; - - let serialized = serde_json::to_string(&cfg).expect("config serialization should succeed"); - assert!( - !serialized.contains("\"video\""), - "video is a compat shadow and must not be re-emitted, got: {serialized}" - ); - } - #[test] fn serializing_skips_default_storage_backup_and_user_config_dirs() { let cfg = ConfigDto { @@ -980,323 +852,4 @@ reverse_proxy: assert!(cfg.reverse_proxy.is_some()); assert!(cfg.reverse_proxy.as_ref().and_then(|rp| rp.stream_history.as_ref()).is_none()); } - - // --- Recording schema-lift tests --- - // - // These tests pin the additive contract: `recording:` lives at the - // top of the config going forward, while the legacy - // `video.download.recording:` path keeps working until the compat - // shadow is removed. - // The custom deserializer in this module is responsible for - // populating BOTH fields during the compatibility window. - - #[test] - fn recording_top_level_deserializes_and_serializes_as_recording_only() { - // Top-level `recording:` populates only the canonical field. - let raw = r#" -api: - host: 127.0.0.1 - port: 8901 - web_root: ./web -recording: - enabled: true - directory: /var/recordings - timezone: Europe/Berlin -"#; - - let cfg: ConfigDto = serde_saphyr::from_str(raw).expect("canonical recording deserializes"); - let recording = cfg.recording.as_ref().expect("recording should be Some"); - assert!(recording.enabled); - assert_eq!(recording.directory.as_deref(), Some("/var/recordings")); - assert_eq!(recording.timezone.as_deref(), Some("Europe/Berlin")); - - // Round-trip: serialize → parse, then assert actual field values. - let serialized = serde_json::to_string(&cfg).expect("serialize"); - let roundtrip: ConfigDto = serde_json::from_str(&serialized).expect("round-trip parse"); - let rtp_recording = roundtrip.recording.as_ref().expect("round-trip recording preserved"); - assert!(rtp_recording.enabled, "round-trip recording.enabled must remain true"); - assert_eq!(rtp_recording.directory.as_deref(), Some("/var/recordings")); - assert_eq!(rtp_recording.timezone.as_deref(), Some("Europe/Berlin")); - assert!(!serialized.contains("\"video\""), "video compat shadow must not be re-emitted, got: {serialized}"); - } - - #[test] - fn recording_legacy_video_download_nested_recording_migrates_without_losing_fields() { - // Legacy nested `video.download.recording:` populates BOTH the - // compat shadow and the canonical top-level field, without - // dropping any DVR setting. - let raw = r#" -api: - host: 127.0.0.1 - port: 8901 - web_root: ./web -video: - extensions: [".mp4"] - download: - directory: /data/downloads - recording: - enabled: true - directory: /data/recordings - timezone: Europe/London - filename_template: "{channel}_{program_title}_{start_time}" -"#; - - let cfg: ConfigDto = serde_saphyr::from_str(raw).expect("legacy config deserializes"); - - // Compat shadow populated from the raw input. - let legacy = cfg - .video - .as_ref() - .and_then(|v| v.download.as_ref()) - .and_then(|d| d.recording.as_ref()) - .expect("legacy video.download.recording must round-trip"); - assert_eq!(legacy.directory.as_deref(), Some("/data/recordings")); - assert_eq!(legacy.timezone.as_deref(), Some("Europe/London")); - - // Canonical top-level field carries the migrated values. - let canonical = cfg.recording.as_ref().expect("canonical recording must be populated"); - assert!(canonical.enabled); - assert_eq!(canonical.directory.as_deref(), Some("/data/recordings")); - assert_eq!(canonical.timezone.as_deref(), Some("Europe/London")); - assert_eq!(canonical.filename_template.as_deref(), Some("{channel}_{program_title}_{start_time}")); - } - - #[test] - fn recording_canonical_directory_wins_over_download_directory_default() { - // `recording.directory` set explicitly takes precedence over - // the `/recordings` default. - let raw = r#" -api: - host: 127.0.0.1 - port: 8901 - web_root: ./web -video: - download: - directory: /data/downloads - recording: - directory: /srv/recordings -"#; - - let mut cfg: ConfigDto = serde_saphyr::from_str(raw).expect("config deserializes"); - cfg.prepare(false).expect("prepare should succeed"); - - let canonical = cfg.recording.as_ref().expect("recording should be present"); - assert_eq!(canonical.directory.as_deref(), Some("/srv/recordings")); - } - - #[test] - fn recording_canonical_directory_defaults_to_download_directory_recordings() { - // When `recording.directory` is absent, it falls back to - // `/recordings`. This is the only place - // the canonical field depends on the legacy `download`. - let raw = r#" -api: - host: 127.0.0.1 - port: 8901 - web_root: ./web -video: - download: - directory: /data/downloads - recording: {} -"#; - - let mut cfg: ConfigDto = serde_saphyr::from_str(raw).expect("config deserializes"); - cfg.prepare(false).expect("prepare should succeed"); - - let canonical = cfg.recording.as_ref().expect("recording should be present"); - assert_eq!(canonical.directory.as_deref(), Some("/data/downloads/recordings")); - } - - #[test] - fn recording_only_extensions_yields_disabled_canonical_recording() { - // `video.extensions` alone — no `download`, no nested - // recording — produces a canonical `recording` with - // `enabled: false`. There is no directory to record from. - let raw = r#" -api: - host: 127.0.0.1 - port: 8901 - web_root: ./web -video: - extensions: [".ts"] -"#; - - let mut cfg: ConfigDto = serde_saphyr::from_str(raw).expect("config deserializes"); - cfg.prepare(false).expect("prepare should succeed"); - - let canonical = cfg.recording.as_ref().expect("recording should be present"); - assert!(!canonical.enabled, "extensions-only video must yield a disabled recording"); - } - - #[test] - fn recording_download_without_nested_recording_preserves_enabled_default() { - // `video.download` with no nested recording block keeps the - // RecordingConfigDto default (`enabled: true`). The presence - // of a download directory is a signal that DVR is in scope. - let raw = r#" -api: - host: 127.0.0.1 - port: 8901 - web_root: ./web -video: - download: - directory: /data/downloads -"#; - - let mut cfg: ConfigDto = serde_saphyr::from_str(raw).expect("config deserializes"); - cfg.prepare(false).expect("prepare should succeed"); - - let canonical = cfg.recording.as_ref().expect("recording should be present"); - assert!(canonical.enabled, "video.download without nested recording preserves the default"); - } - - #[test] - fn recording_canonical_and_legacy_nested_recording_both_non_empty_is_ambiguous() { - // Both `recording:` AND `video.download.recording:` set with - // conflicting content must be rejected. We can't safely pick - // one; this is the fail-closed contract from the plan. - let raw = r#" -api: - host: 127.0.0.1 - port: 8901 - web_root: ./web -recording: - enabled: true - directory: /var/recordings -video: - extensions: [".mp4"] - download: - directory: /data/downloads - recording: - enabled: true - directory: /data/recordings -"#; - - let err = serde_saphyr::from_str::(raw) - .expect_err("canonical + legacy nested recording must be rejected as ambiguous"); - let msg = format!("{err}"); - assert!(msg.contains("ambiguous") || msg.contains("recording"), "unexpected error: {msg}"); - } - - #[test] - fn recording_web_search_is_accepted_from_legacy_input_but_never_serialized() { - // `video.web_search` round-trips in-memory (frontend uses it) - // but the canonical config never re-emits it. - let raw = r#" -api: - host: 127.0.0.1 - port: 8901 - web_root: ./web -video: - extensions: [".mp4"] - web_search: "https://example.org/?q={query}" -"#; - - let cfg: ConfigDto = serde_saphyr::from_str(raw).expect("config deserializes"); - let web_search = cfg.video.as_ref().and_then(|v| v.web_search.as_ref()).expect("web_search present"); - assert_eq!(web_search, "https://example.org/?q={query}"); - - let serialized = serde_json::to_string(&cfg).expect("serialize"); - assert!(!serialized.contains("web_search"), "web_search must never be serialized, got: {serialized}"); - } - - #[test] - fn recording_round_trips_full_canonical_block_via_canonical_field() { - // All four DVR sub-blocks populated at once — the canonical - // path must preserve retention/disk/quota/notifications - // bit-for-bit across serialize → deserialize. - use crate::model::{ - RecordingDiskConfigDto, RecordingNotificationConfigDto, RecordingQuotaConfigDto, - RecordingRetentionConfigDto, - }; - use std::collections::HashMap; - - let original = RecordingConfigDto { - enabled: true, - directory: Some("/srv/recordings".to_string()), - timezone: Some("Asia/Tokyo".to_string()), - filename_template: Some("{channel}_{program_title}_{start_time}".to_string()), - default_pre_roll_secs: Some(30), - max_pre_roll_secs: 600, - default_post_roll_secs: Some(60), - max_post_roll_secs: 1200, - retention: Some(RecordingRetentionConfigDto { - keep_last_per_channel: Some(7), - delete_after_days: Some(14), - sweep_interval_secs: 1800, - }), - disk: Some(RecordingDiskConfigDto { - high_water_percent: Some(90), - low_water_percent: Some(70), - cleanup_interval_secs: Some(900), - safety_bytes: Some(2 * 1024 * 1024 * 1024), - }), - quota: Some(RecordingQuotaConfigDto { - default_private_bytes: Some(50 * 1024 * 1024 * 1024), - per_user_bytes: HashMap::from([("alice".to_string(), 100 * 1024 * 1024 * 1024)]), - shared_bytes: Some(200 * 1024 * 1024 * 1024), - }), - notifications: Some(RecordingNotificationConfigDto { - outbox_buffer: 2048, - max_attempts: 8, - backoff_initial_secs: 7, - backoff_max_secs: 1200, - }), - ..RecordingConfigDto::default() - }; - - let mut cfg = ConfigDto { recording: Some(original.clone()), ..ConfigDto::default() }; - cfg.prepare(false).expect("prepare should succeed"); - - let serialized = serde_json::to_string(&cfg).expect("serialize"); - let roundtrip: ConfigDto = serde_json::from_str(&serialized).expect("round-trip parse"); - - let rtp = roundtrip.recording.as_ref().expect("recording preserved"); - assert_eq!(rtp.directory.as_deref(), Some("/srv/recordings")); - assert_eq!(rtp.timezone.as_deref(), Some("Asia/Tokyo")); - let retention = rtp.retention.as_ref().expect("retention preserved"); - assert_eq!(retention.keep_last_per_channel, Some(7)); - assert_eq!(retention.delete_after_days, Some(14)); - let disk = rtp.disk.as_ref().expect("disk preserved"); - assert_eq!(disk.high_water_percent, Some(90)); - assert_eq!(disk.low_water_percent, Some(70)); - let quota = rtp.quota.as_ref().expect("quota preserved"); - assert_eq!(quota.shared_bytes, Some(200 * 1024 * 1024 * 1024)); - let notifications = rtp.notifications.as_ref().expect("notifications preserved"); - assert_eq!(notifications.outbox_buffer, 2048); - } - - #[test] - fn config_dto_default_recording_is_none_before_prepare_and_some_disabled_after() { - // Pin: `ConfigDto::default()` has no canonical recording - // block (frontend reads `None` until the operator populates - // anything). After `prepare(false)` with no `video.download`, - // the canonical block is created in disabled form — there is - // nothing to record from. - let mut cfg = ConfigDto::default(); - assert!(cfg.recording.is_none(), "default ConfigDto has no recording block"); - cfg.prepare(false).expect("prepare on default should succeed"); - let recording = cfg.recording.as_ref().expect("prepare creates a disabled canonical recording"); - assert!(!recording.enabled, "no download directory implies recording is disabled"); - } - - #[test] - fn is_none_or_empty_video_deletion_does_not_change_video_serialization() { - // The dead `is_none_or_empty_video` was the only thing - // referenced by the old `skip_serializing_if` on `video:`. - // After its removal the field is `skip_serializing`. This - // test pins that the behavior — never emitting `video:` — is - // preserved regardless of which underlying predicate the - // derive sees. - let cfg = ConfigDto { - video: Some(VideoConfigDto { - extensions: vec![".mp4".to_string()], - download: None, - web_search: Some("https://example.org/?q={query}".to_string()), - }), - ..ConfigDto::default() - }; - let serialized = serde_json::to_string(&cfg).expect("serialize"); - assert!(!serialized.contains("\"video\""), "video compat shadow is never emitted: {serialized}"); - } } diff --git a/shared/src/model/config/mod.rs b/shared/src/model/config/mod.rs index d3bb60ab7..468fcf45b 100644 --- a/shared/src/model/config/mod.rs +++ b/shared/src/model/config/mod.rs @@ -29,6 +29,7 @@ mod playlist_update_state; mod proxy; mod proxy_type; mod rate_limit; +mod recording; mod rename; mod reverse_proxy; mod schedule; @@ -39,7 +40,6 @@ mod stream_history; mod target; mod template_definition; mod trakt; -mod video_download; mod web_auth; mod web_ui; @@ -89,6 +89,7 @@ pub use proxy_type::*; pub use proxy_user_status::*; pub use qos_aggregation::*; pub use rate_limit::*; +pub use recording::*; pub use rename::*; pub use reverse_proxy::*; pub use schedule::*; @@ -99,6 +100,5 @@ pub use stream_history::*; pub use target::*; pub use template_definition::*; pub use trakt::*; -pub use video_download::*; pub use web_auth::*; pub use web_ui::*; diff --git a/shared/src/model/config/recording.rs b/shared/src/model/config/recording.rs new file mode 100644 index 000000000..d7ed146b6 --- /dev/null +++ b/shared/src/model/config/recording.rs @@ -0,0 +1,708 @@ +use crate::{ + defaults::{ + default_episode_pattern, default_recording_dir, default_supported_video_extensions, + is_blank_or_default_episode_pattern, is_blank_or_default_recording_dir, is_default_supported_video_extensions, + is_false, DEFAULT_USER_AGENT, F64_DEFAULT_EPSILON, + }, + error::TuliproxError, + utils::{is_blank_optional_str, is_blank_optional_string}, +}; +use std::collections::HashMap; + +const fn default_retry_backoff_initial_secs() -> u64 { 3 } +const fn default_retry_backoff_multiplier() -> f64 { 3.0 } +const fn default_retry_backoff_max_secs() -> u64 { 30 } +const fn default_retry_backoff_jitter_percent() -> u8 { 20 } +const fn default_retry_max_attempts() -> u8 { 5 } +fn is_default_retry_backoff_initial_secs(value: &u64) -> bool { *value == default_retry_backoff_initial_secs() } +fn is_default_retry_backoff_multiplier(value: &f64) -> bool { + (*value - default_retry_backoff_multiplier()).abs() < F64_DEFAULT_EPSILON +} +fn is_default_retry_backoff_max_secs(value: &u64) -> bool { *value == default_retry_backoff_max_secs() } +fn is_default_retry_backoff_jitter_percent(value: &u8) -> bool { *value == default_retry_backoff_jitter_percent() } +fn is_default_retry_max_attempts(value: &u8) -> bool { *value == default_retry_max_attempts() } +fn is_zero_u8(value: &u8) -> bool { *value == 0 } +fn is_zero_i8(value: &i8) -> bool { *value == 0 } + +// Recording configuration constants. +const DEFAULT_RECORDING_DIRECTORY_SUFFIX: &str = "recordings"; +const DEFAULT_RECORDING_TIMEZONE: &str = "UTC"; +const DEFAULT_RECORDING_FILENAME_TEMPLATE: &str = "{channel}_{program_title}_{start_time}"; +const DEFAULT_RECORDING_MAX_PRE_ROLL_SECS: u64 = 15 * 60; // 15 minutes +const DEFAULT_RECORDING_MAX_POST_ROLL_SECS: u64 = 30 * 60; // 30 minutes +const DEFAULT_RECORDING_CLEANUP_INTERVAL_SECS: u64 = 60 * 60; // 1 hour +const DEFAULT_RECORDING_DISK_SAFETY_BYTES: u64 = 1024 * 1024 * 1024; // 1 GiB +const DEFAULT_RECORDING_FALLBACK_BYTES_PER_MINUTE: u64 = 8 * 1024 * 1024; // 8 MiB +const DEFAULT_RECORDING_RETENTION_SWEEP_INTERVAL_SECS: u64 = 60 * 60; // 1 hour +const DEFAULT_RECORDING_NOTIFICATION_OUTBOX_BUFFER: usize = 1024; +const DEFAULT_RECORDING_NOTIFICATION_MAX_ATTEMPTS: u32 = 6; +const DEFAULT_RECORDING_NOTIFICATION_BACKOFF_INITIAL_SECS: u64 = 5; +const DEFAULT_RECORDING_NOTIFICATION_BACKOFF_MAX_SECS: u64 = 900; // 15 minutes +const MAX_FILENAME_TEMPLATE_BYTES: usize = 240; + +/// Container the recorder muxes into. Recordings used to be hard-coded +/// to MPEG-TS regardless of the source codecs; operators recording +/// H.265 or AAC-only channels want a container that can hold them. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RecordingContainerFormat { + /// MPEG-TS. Robust against truncation, so a recording killed + /// mid-stream still plays — which is why it is the default. + #[default] + Mpegts, + Matroska, + Mp4, +} + +impl RecordingContainerFormat { + /// The `-f` argument for the muxer. + pub fn ffmpeg_format(self) -> &'static str { + match self { + Self::Mpegts => "mpegts", + Self::Matroska => "matroska", + Self::Mp4 => "mp4", + } + } + + /// The extension recordings in this container get, without the dot. + pub fn file_extension(self) -> &'static str { + match self { + Self::Mpegts => "ts", + Self::Matroska => "mkv", + Self::Mp4 => "mp4", + } + } +} + +pub fn default_recording_directory(download_dir: &str) -> String { + format!("{download_dir}/{DEFAULT_RECORDING_DIRECTORY_SUFFIX}") +} + +pub fn default_recording_timezone() -> String { DEFAULT_RECORDING_TIMEZONE.to_string() } +pub fn default_recording_filename_template() -> String { DEFAULT_RECORDING_FILENAME_TEMPLATE.to_string() } +pub const fn default_recording_max_pre_roll_secs() -> u64 { DEFAULT_RECORDING_MAX_PRE_ROLL_SECS } +pub const fn default_recording_max_post_roll_secs() -> u64 { DEFAULT_RECORDING_MAX_POST_ROLL_SECS } +pub const fn default_recording_cleanup_interval_secs() -> u64 { DEFAULT_RECORDING_CLEANUP_INTERVAL_SECS } +pub const fn default_recording_disk_safety_bytes() -> u64 { DEFAULT_RECORDING_DISK_SAFETY_BYTES } +pub const fn default_recording_fallback_bytes_per_minute() -> u64 { DEFAULT_RECORDING_FALLBACK_BYTES_PER_MINUTE } + +fn is_default_recording_max_pre_roll_secs(value: &u64) -> bool { *value == default_recording_max_pre_roll_secs() } +fn is_default_recording_max_post_roll_secs(value: &u64) -> bool { *value == default_recording_max_post_roll_secs() } +pub const fn default_recording_retention_sweep_interval_secs() -> u64 { + DEFAULT_RECORDING_RETENTION_SWEEP_INTERVAL_SECS +} +pub const fn default_recording_notification_outbox_buffer() -> usize { DEFAULT_RECORDING_NOTIFICATION_OUTBOX_BUFFER } +pub const fn default_recording_notification_max_attempts() -> u32 { DEFAULT_RECORDING_NOTIFICATION_MAX_ATTEMPTS } +pub const fn default_recording_notification_backoff_initial_secs() -> u64 { + DEFAULT_RECORDING_NOTIFICATION_BACKOFF_INITIAL_SECS +} +pub const fn default_recording_notification_backoff_max_secs() -> u64 { + DEFAULT_RECORDING_NOTIFICATION_BACKOFF_MAX_SECS +} +pub const fn default_recording_enabled() -> bool { true } + +fn is_default_recording_fallback_bytes_per_minute(value: &u64) -> bool { + *value == default_recording_fallback_bytes_per_minute() +} +fn is_default_recording_retention_sweep_interval_secs(value: &u64) -> bool { + *value == default_recording_retention_sweep_interval_secs() +} +fn is_default_recording_notification_outbox_buffer(value: &usize) -> bool { + *value == default_recording_notification_outbox_buffer() +} +fn is_default_recording_notification_max_attempts(value: &u32) -> bool { + *value == default_recording_notification_max_attempts() +} +fn is_default_recording_notification_backoff_initial_secs(value: &u64) -> bool { + *value == default_recording_notification_backoff_initial_secs() +} +fn is_default_recording_notification_backoff_max_secs(value: &u64) -> bool { + *value == default_recording_notification_backoff_max_secs() +} +fn is_recording_enabled(value: &bool) -> bool { *value } +fn is_default_recording_container_format(value: &RecordingContainerFormat) -> bool { + *value == RecordingContainerFormat::default() +} +// `skip_serializing_if` predicates that distinguish "field absent" from +// "field present with a zero value". An explicit `Some(0)` is a real +// configuration choice and must round-trip; only `None` means "absent". +fn is_zero_u64_opt(value: &Option) -> bool { value.is_none() } +fn is_zero_u32_opt(value: &Option) -> bool { value.is_none() } +fn is_zero_u8_opt(value: &Option) -> bool { value.is_none() } + +/// DVR recording configuration block. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct RecordingConfigDto { + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub headers: HashMap, + #[serde(default, skip_serializing_if = "is_false")] + pub organize_into_directories: bool, + #[serde(default = "default_episode_pattern", skip_serializing_if = "is_blank_or_default_episode_pattern")] + pub episode_pattern: Option, + #[serde(default, skip_serializing_if = "is_zero_i8")] + pub priority: i8, + #[serde(default, skip_serializing_if = "is_zero_u8")] + pub reserve_slots_for_users: u8, + #[serde(default, skip_serializing_if = "is_zero_u8")] + pub max_background_per_provider: u8, + #[serde( + default = "default_retry_backoff_initial_secs", + skip_serializing_if = "is_default_retry_backoff_initial_secs" + )] + pub retry_backoff_initial_secs: u64, + #[serde(default = "default_retry_backoff_multiplier", skip_serializing_if = "is_default_retry_backoff_multiplier")] + pub retry_backoff_multiplier: f64, + #[serde(default = "default_retry_backoff_max_secs", skip_serializing_if = "is_default_retry_backoff_max_secs")] + pub retry_backoff_max_secs: u64, + #[serde( + default = "default_retry_backoff_jitter_percent", + skip_serializing_if = "is_default_retry_backoff_jitter_percent" + )] + pub retry_backoff_jitter_percent: u8, + #[serde(default = "default_retry_max_attempts", skip_serializing_if = "is_default_retry_max_attempts")] + pub retry_max_attempts: u8, + /// Master switch for the whole DVR feature. `false` stops the + /// supervisors, so nothing is materialized, swept, or notified. + #[serde(default = "default_recording_enabled", skip_serializing_if = "is_recording_enabled")] + pub enabled: bool, + #[serde(default, skip_serializing_if = "is_default_recording_container_format")] + pub container_format: RecordingContainerFormat, + #[serde(default = "default_recording_dir", skip_serializing_if = "is_blank_or_default_recording_dir")] + pub directory: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timezone: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub filename_template: Option, + #[serde(default, skip_serializing_if = "is_zero_u64_opt")] + pub default_pre_roll_secs: Option, + #[serde( + default = "default_recording_max_pre_roll_secs", + skip_serializing_if = "is_default_recording_max_pre_roll_secs" + )] + pub max_pre_roll_secs: u64, + #[serde(default, skip_serializing_if = "is_zero_u64_opt")] + pub default_post_roll_secs: Option, + #[serde( + default = "default_recording_max_post_roll_secs", + skip_serializing_if = "is_default_recording_max_post_roll_secs" + )] + pub max_post_roll_secs: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retention: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disk: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub quota: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub notifications: Option, + #[serde( + default = "default_recording_fallback_bytes_per_minute", + skip_serializing_if = "is_default_recording_fallback_bytes_per_minute" + )] + pub fallback_bytes_per_minute: u64, +} + +/// Hand-written so `Default` agrees with the serde defaults. A derived +/// `Default` would produce `enabled: false` and zero padding limits — +/// i.e. a silently disabled DVR — which is the opposite of what an +/// absent `recording:` block means. +impl Default for RecordingConfigDto { + fn default() -> Self { + Self { + headers: HashMap::new(), + organize_into_directories: false, + episode_pattern: default_episode_pattern(), + priority: 0, + reserve_slots_for_users: 0, + max_background_per_provider: 0, + retry_backoff_initial_secs: default_retry_backoff_initial_secs(), + retry_backoff_multiplier: default_retry_backoff_multiplier(), + retry_backoff_max_secs: default_retry_backoff_max_secs(), + retry_backoff_jitter_percent: default_retry_backoff_jitter_percent(), + retry_max_attempts: default_retry_max_attempts(), + enabled: default_recording_enabled(), + container_format: RecordingContainerFormat::default(), + directory: None, + timezone: None, + filename_template: None, + default_pre_roll_secs: None, + max_pre_roll_secs: default_recording_max_pre_roll_secs(), + default_post_roll_secs: None, + max_post_roll_secs: default_recording_max_post_roll_secs(), + retention: None, + disk: None, + quota: None, + notifications: None, + fallback_bytes_per_minute: default_recording_fallback_bytes_per_minute(), + } + } +} + +impl RecordingConfigDto { + pub fn is_empty(&self) -> bool { + self.headers.is_empty() + && !self.organize_into_directories + && is_blank_or_default_episode_pattern(&self.episode_pattern) + && self.priority == 0 + && self.reserve_slots_for_users == 0 + && self.max_background_per_provider == 0 + && is_default_retry_backoff_initial_secs(&self.retry_backoff_initial_secs) + && is_default_retry_backoff_multiplier(&self.retry_backoff_multiplier) + && is_default_retry_backoff_max_secs(&self.retry_backoff_max_secs) + && is_default_retry_backoff_jitter_percent(&self.retry_backoff_jitter_percent) + && is_default_retry_max_attempts(&self.retry_max_attempts) + && self.enabled == default_recording_enabled() + && is_default_recording_container_format(&self.container_format) + && self.notifications.is_none() + && self.directory.is_none() + && self.timezone.is_none() + && self.filename_template.is_none() + && self.default_pre_roll_secs.is_none() + && is_default_recording_max_pre_roll_secs(&self.max_pre_roll_secs) + && self.default_post_roll_secs.is_none() + && is_default_recording_max_post_roll_secs(&self.max_post_roll_secs) + && self.retention.is_none() + && self.disk.is_none() + && self.quota.is_none() + && self.fallback_bytes_per_minute == default_recording_fallback_bytes_per_minute() + } + + pub fn clean(&mut self) { + self.retention = self.retention.take().filter(|value| !value.is_empty()); + self.disk = self.disk.take().filter(|value| !value.is_empty()); + self.quota = self.quota.take().filter(|value| !value.is_empty()); + self.notifications = self.notifications.take().filter(|value| !value.is_empty()); + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct RecordingRetentionConfigDto { + #[serde(default, skip_serializing_if = "is_zero_u32_opt")] + pub keep_last_per_channel: Option, + #[serde(default, skip_serializing_if = "is_zero_u32_opt")] + pub delete_after_days: Option, + /// How often the age/count sweep runs. Independent of + /// `disk.cleanup_interval_secs`, which paces the watermark check. + #[serde( + default = "default_recording_retention_sweep_interval_secs", + skip_serializing_if = "is_default_recording_retention_sweep_interval_secs" + )] + pub sweep_interval_secs: u64, +} + +impl Default for RecordingRetentionConfigDto { + fn default() -> Self { + Self { + keep_last_per_channel: None, + delete_after_days: None, + sweep_interval_secs: default_recording_retention_sweep_interval_secs(), + } + } +} + +impl RecordingRetentionConfigDto { + pub fn is_empty(&self) -> bool { + self.keep_last_per_channel.is_none() + && self.delete_after_days.is_none() + && is_default_recording_retention_sweep_interval_secs(&self.sweep_interval_secs) + } +} + +/// Lifecycle-notification delivery. The outbox worker owns these; the +/// recorder itself never blocks on a notification. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct RecordingNotificationConfigDto { + /// Bounded in-memory queue between the recorder and the outbox + /// worker. A full channel drops the newest entry rather than + /// stalling a recording. + #[serde( + default = "default_recording_notification_outbox_buffer", + skip_serializing_if = "is_default_recording_notification_outbox_buffer" + )] + pub outbox_buffer: usize, + /// Delivery attempts before an entry is dead-lettered. + #[serde( + default = "default_recording_notification_max_attempts", + skip_serializing_if = "is_default_recording_notification_max_attempts" + )] + pub max_attempts: u32, + #[serde( + default = "default_recording_notification_backoff_initial_secs", + skip_serializing_if = "is_default_recording_notification_backoff_initial_secs" + )] + pub backoff_initial_secs: u64, + #[serde( + default = "default_recording_notification_backoff_max_secs", + skip_serializing_if = "is_default_recording_notification_backoff_max_secs" + )] + pub backoff_max_secs: u64, +} + +impl Default for RecordingNotificationConfigDto { + fn default() -> Self { + Self { + outbox_buffer: default_recording_notification_outbox_buffer(), + max_attempts: default_recording_notification_max_attempts(), + backoff_initial_secs: default_recording_notification_backoff_initial_secs(), + backoff_max_secs: default_recording_notification_backoff_max_secs(), + } + } +} + +impl RecordingNotificationConfigDto { + pub fn is_empty(&self) -> bool { *self == Self::default() } +} + +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct RecordingDiskConfigDto { + #[serde(default, skip_serializing_if = "is_zero_u8_opt")] + pub high_water_percent: Option, + #[serde(default, skip_serializing_if = "is_zero_u8_opt")] + pub low_water_percent: Option, + #[serde(default, skip_serializing_if = "is_zero_u64_opt")] + pub cleanup_interval_secs: Option, + #[serde(default, skip_serializing_if = "is_zero_u64_opt")] + pub safety_bytes: Option, +} + +impl RecordingDiskConfigDto { + pub fn is_empty(&self) -> bool { + self.high_water_percent.is_none() + && self.low_water_percent.is_none() + && self.cleanup_interval_secs.is_none() + && self.safety_bytes.is_none() + } +} + +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct RecordingQuotaConfigDto { + #[serde(default, skip_serializing_if = "is_zero_u64_opt")] + pub default_private_bytes: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub per_user_bytes: HashMap, + #[serde(default, skip_serializing_if = "is_zero_u64_opt")] + pub shared_bytes: Option, +} + +impl RecordingQuotaConfigDto { + pub fn is_empty(&self) -> bool { + self.default_private_bytes.is_none() && self.per_user_bytes.is_empty() && self.shared_bytes.is_none() + } +} + +/// Allowed placeholders in the recording filename template. +/// Order matters for error messages and the validation regex. +pub const RECORDING_FILENAME_PLACEHOLDERS: &[&str] = + &["{channel}", "{program_title}", "{start_time}", "{end_time}", "{episode}", "{owner}"]; + +/// Validates a recording filename template. Returns the cleaned template and +/// the matched placeholder set on success. +fn validate_recording_filename_template(template: &str) -> Result<(), TuliproxError> { + if template.is_empty() { + return Err(TuliproxError::ConfigRecording("recording.filename_template must not be empty".to_string())); + } + if template.len() > MAX_FILENAME_TEMPLATE_BYTES { + return Err(TuliproxError::ConfigRecording(format!( + "recording.filename_template must not exceed {MAX_FILENAME_TEMPLATE_BYTES} bytes" + ))); + } + + let bytes = template.as_bytes(); + let mut i = 0; + let mut found_placeholder = false; + while i < bytes.len() { + if bytes[i] == b'{' { + if let Some(close) = template[i + 1..].find('}') { + let end = i + 1 + close; + let placeholder = &template[i..=end]; + if !RECORDING_FILENAME_PLACEHOLDERS.contains(&placeholder) { + return Err(TuliproxError::ConfigRecording(format!( + "recording.filename_template contains unknown placeholder '{placeholder}'" + ))); + } + found_placeholder = true; + i = end + 1; + } else { + return Err(TuliproxError::ConfigRecording( + "recording.filename_template has an unmatched '{'".to_string(), + )); + } + } else if bytes[i] == b'}' { + return Err(TuliproxError::ConfigRecording("recording.filename_template has an unmatched '}'".to_string())); + } else { + i += 1; + } + } + + if !found_placeholder { + return Err(TuliproxError::ConfigRecording( + "recording.filename_template must contain at least one placeholder".to_string(), + )); + } + Ok(()) +} + +fn validate_recording_timezone(tz: &str) -> Result<(), TuliproxError> { + if tz.is_empty() { + return Err(TuliproxError::ConfigRecording("recording.timezone must not be empty".to_string())); + } + tz.parse::() + .map(|_| ()) + .map_err(|_| TuliproxError::ConfigRecording(format!("recording.timezone '{tz}' is not a valid IANA timezone"))) +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct VideoConfigDto { + #[serde( + default = "default_supported_video_extensions", + skip_serializing_if = "is_default_supported_video_extensions" + )] + pub extensions: Vec, + #[serde(default, skip_serializing_if = "is_blank_optional_string")] + pub web_search: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recording: Option, +} + +impl VideoConfigDto { + pub fn is_empty(&self) -> bool { + (self.extensions.is_empty() || is_default_supported_video_extensions(&self.extensions)) + && is_blank_optional_str(self.web_search.as_deref()) + && self.recording.as_ref().is_none_or(RecordingConfigDto::is_empty) + } + + pub fn clean(&mut self) { + if let Some(recording) = self.recording.as_mut() { + recording.clean(); + } + if self.recording.as_ref().is_some_and(RecordingConfigDto::is_empty) { + self.recording = None; + } + } + + pub fn prepare(&mut self) -> Result<(), TuliproxError> { + if self.extensions.is_empty() { + self.extensions = default_supported_video_extensions(); + } + if let Some(recording) = self.recording.as_mut() { + prepare_recording_config(recording)?; + } + Ok(()) + } +} + +pub(crate) fn prepare_recording_config(recording: &mut RecordingConfigDto) -> Result<(), TuliproxError> { + if recording.headers.is_empty() { + recording.headers.insert("Accept".to_string(), "video/*".to_string()); + recording.headers.insert("User-Agent".to_string(), DEFAULT_USER_AGENT.to_string()); + } + if is_blank_or_default_episode_pattern(&recording.episode_pattern) { + recording.episode_pattern = default_episode_pattern(); + } else if let Some(pattern) = recording.episode_pattern.as_ref() { + recording.episode_pattern = Some(pattern.trim().to_string()); + } + if let Some(pattern) = recording.episode_pattern.as_ref() { + crate::model::REGEX_CACHE + .get_or_compile(pattern) + .map_err(|err| TuliproxError::RegexCompile(format!("{pattern} {err}")))?; + } + recording.retry_backoff_initial_secs = recording.retry_backoff_initial_secs.max(1); + recording.retry_backoff_multiplier = recording.retry_backoff_multiplier.max(1.0); + recording.retry_backoff_max_secs = recording.retry_backoff_max_secs.max(recording.retry_backoff_initial_secs); + recording.retry_backoff_jitter_percent = recording.retry_backoff_jitter_percent.min(95); + recording.retry_max_attempts = recording.retry_max_attempts.max(1); + + // Directory is independent of video extensions and download settings. + if let Some(dir) = recording.directory.as_ref() { + let trimmed = dir.trim(); + if trimmed.is_empty() { + recording.directory = None; + } else if trimmed != dir { + recording.directory = Some(trimmed.to_string()); + } + } + if recording.directory.is_none() { + recording.directory = default_recording_dir(); + } + + // timezone: default UTC; validate IANA. + if let Some(tz) = recording.timezone.as_ref() { + let trimmed = tz.trim(); + if trimmed.is_empty() { + recording.timezone = None; + } else if trimmed != tz { + recording.timezone = Some(trimmed.to_string()); + } + } + if let Some(tz) = recording.timezone.as_ref() { + if tz.parse::().is_err() { + // Surface a warning before the strict validator below turns + // this into a hard error, so the operator sees both signals. + log::warn!("recording.timezone '{tz}' is not a valid IANA timezone"); + } + } + if recording.timezone.is_none() { + recording.timezone = Some(default_recording_timezone()); + } + validate_recording_timezone(recording.timezone.as_deref().unwrap_or("UTC"))?; + + // filename_template: default; validate placeholders. + if let Some(template) = recording.filename_template.as_ref() { + let trimmed = template.trim(); + if trimmed != template { + recording.filename_template = Some(trimmed.to_string()); + } + } + if recording.filename_template.is_none() { + recording.filename_template = Some(default_recording_filename_template()); + } + validate_recording_filename_template(recording.filename_template.as_deref().unwrap_or(""))?; + + // padding: default <= max, fallback to defaults if missing. + if recording.default_pre_roll_secs.is_none() { + recording.default_pre_roll_secs = Some(0); + } + if recording.default_pre_roll_secs.unwrap_or(0) > recording.max_pre_roll_secs { + return Err(TuliproxError::ConfigRecording(format!( + "recording.default_pre_roll_secs ({}) must not exceed max_pre_roll_secs ({})", + recording.default_pre_roll_secs.unwrap_or(0), + recording.max_pre_roll_secs + ))); + } + if recording.default_post_roll_secs.is_none() { + recording.default_post_roll_secs = Some(0); + } + if recording.default_post_roll_secs.unwrap_or(0) > recording.max_post_roll_secs { + return Err(TuliproxError::ConfigRecording(format!( + "recording.default_post_roll_secs ({}) must not exceed max_post_roll_secs ({})", + recording.default_post_roll_secs.unwrap_or(0), + recording.max_post_roll_secs + ))); + } + + // retention: each present policy must be > 0. + if let Some(retention) = recording.retention.as_ref() { + if let Some(keep) = retention.keep_last_per_channel { + if keep == 0 { + return Err(TuliproxError::ConfigRecording( + "recording.retention.keep_last_per_channel must be > 0".to_string(), + )); + } + } + if let Some(days) = retention.delete_after_days { + if days == 0 { + return Err(TuliproxError::ConfigRecording( + "recording.retention.delete_after_days must be > 0".to_string(), + )); + } + } + if retention.sweep_interval_secs == 0 { + return Err(TuliproxError::ConfigRecording( + "recording.retention.sweep_interval_secs must be > 0".to_string(), + )); + } + if retention.keep_last_per_channel.is_none() && retention.delete_after_days.is_none() { + // A retention block that expresses no policy reads as + // "retention is configured" while nothing is ever deleted. + log::warn!( + "recording.retention has neither keep_last_per_channel nor delete_after_days; \ + no recording will ever be deleted by policy" + ); + } + } + + // notifications: an outbox that cannot hold or retry anything would + // silently drop every lifecycle notification. + if let Some(notifications) = recording.notifications.as_ref() { + if notifications.outbox_buffer == 0 { + return Err(TuliproxError::ConfigRecording( + "recording.notifications.outbox_buffer must be > 0".to_string(), + )); + } + if notifications.max_attempts == 0 { + return Err(TuliproxError::ConfigRecording("recording.notifications.max_attempts must be > 0".to_string())); + } + if notifications.backoff_initial_secs == 0 { + return Err(TuliproxError::ConfigRecording( + "recording.notifications.backoff_initial_secs must be > 0".to_string(), + )); + } + if notifications.backoff_max_secs < notifications.backoff_initial_secs { + return Err(TuliproxError::ConfigRecording(format!( + "recording.notifications.backoff_max_secs ({}) must be >= backoff_initial_secs ({})", + notifications.backoff_max_secs, notifications.backoff_initial_secs + ))); + } + } + + // disk: percentages in 0..=100, low < high, cleanup > 0, safety non-zero. + if let Some(disk) = recording.disk.as_ref() { + if let Some(high) = disk.high_water_percent { + if high > 100 { + return Err(TuliproxError::ConfigRecording(format!( + "recording.disk.high_water_percent ({high}) must be 0..=100" + ))); + } + } + if let Some(low) = disk.low_water_percent { + if low > 100 { + return Err(TuliproxError::ConfigRecording(format!( + "recording.disk.low_water_percent ({low}) must be 0..=100" + ))); + } + } + if let (Some(low), Some(high)) = (disk.low_water_percent, disk.high_water_percent) { + if low >= high { + return Err(TuliproxError::ConfigRecording(format!( + "recording.disk.low_water_percent ({low}) must be < high_water_percent ({high})" + ))); + } + } + if let Some(interval) = disk.cleanup_interval_secs { + if interval == 0 { + return Err(TuliproxError::ConfigRecording( + "recording.disk.cleanup_interval_secs must be > 0".to_string(), + )); + } + } + if let Some(safety) = disk.safety_bytes { + if safety == 0 { + return Err(TuliproxError::ConfigRecording("recording.disk.safety_bytes must be > 0".to_string())); + } + } + } + + // quota: fallback bytes per minute must be > 0. + if recording.fallback_bytes_per_minute == 0 { + return Err(TuliproxError::ConfigRecording("recording.fallback_bytes_per_minute must be > 0".to_string())); + } + + // Nothing bounds recording disk use unless at least one of the three + // policies is set. Worth a warning, not an error: a dedicated + // filesystem is a legitimate reason to run without any of them. + let has_policy_retention = recording + .retention + .as_ref() + .is_some_and(|retention| retention.keep_last_per_channel.is_some() || retention.delete_after_days.is_some()); + let has_watermarks = recording + .disk + .as_ref() + .is_some_and(|disk| disk.high_water_percent.is_some() && disk.low_water_percent.is_some()); + let has_quota = recording.quota.as_ref().is_some_and(|quota| { + quota.default_private_bytes.is_some() || quota.shared_bytes.is_some() || !quota.per_user_bytes.is_empty() + }); + if recording.enabled && !has_policy_retention && !has_watermarks && !has_quota { + log::warn!( + "recording is enabled with no retention, no disk watermarks, and no quota; \ + recording disk usage is unbounded" + ); + } + + Ok(()) +} diff --git a/shared/src/model/config/video_download.rs b/shared/src/model/config/video_download.rs deleted file mode 100644 index 8347f8a79..000000000 --- a/shared/src/model/config/video_download.rs +++ /dev/null @@ -1,1488 +0,0 @@ -use crate::{ - defaults::{ - default_download_dir, default_episode_pattern, default_supported_video_extensions, - is_blank_or_default_download_dir, is_blank_or_default_episode_pattern, is_default_supported_video_extensions, - is_false, DEFAULT_DOWNLOAD_DIR, DEFAULT_USER_AGENT, F64_DEFAULT_EPSILON, - }, - error::TuliproxError, - utils::{is_blank_optional_str, is_blank_optional_string}, -}; -use std::{borrow::BorrowMut, collections::HashMap}; - -const fn default_retry_backoff_initial_secs() -> u64 { 3 } -const fn default_retry_backoff_multiplier() -> f64 { 3.0 } -const fn default_retry_backoff_max_secs() -> u64 { 30 } -const fn default_retry_backoff_jitter_percent() -> u8 { 20 } -const fn default_retry_max_attempts() -> u8 { 5 } -fn is_default_retry_backoff_initial_secs(value: &u64) -> bool { *value == default_retry_backoff_initial_secs() } -fn is_default_retry_backoff_multiplier(value: &f64) -> bool { - (*value - default_retry_backoff_multiplier()).abs() < F64_DEFAULT_EPSILON -} -fn is_default_retry_backoff_max_secs(value: &u64) -> bool { *value == default_retry_backoff_max_secs() } -fn is_default_retry_backoff_jitter_percent(value: &u8) -> bool { *value == default_retry_backoff_jitter_percent() } -fn is_default_retry_max_attempts(value: &u8) -> bool { *value == default_retry_max_attempts() } -fn is_zero_u8(value: &u8) -> bool { *value == 0 } -fn is_zero_i8(value: &i8) -> bool { *value == 0 } - -// Recording configuration constants. -const DEFAULT_RECORDING_DIRECTORY_SUFFIX: &str = "recordings"; -const DEFAULT_RECORDING_TIMEZONE: &str = "UTC"; -const DEFAULT_RECORDING_FILENAME_TEMPLATE: &str = "{channel}_{program_title}_{start_time}"; -const DEFAULT_RECORDING_MAX_PRE_ROLL_SECS: u64 = 15 * 60; // 15 minutes -const DEFAULT_RECORDING_MAX_POST_ROLL_SECS: u64 = 30 * 60; // 30 minutes -const DEFAULT_RECORDING_CLEANUP_INTERVAL_SECS: u64 = 60 * 60; // 1 hour -const DEFAULT_RECORDING_DISK_SAFETY_BYTES: u64 = 1024 * 1024 * 1024; // 1 GiB -const DEFAULT_RECORDING_FALLBACK_BYTES_PER_MINUTE: u64 = 8 * 1024 * 1024; // 8 MiB -const DEFAULT_RECORDING_RETENTION_SWEEP_INTERVAL_SECS: u64 = 60 * 60; // 1 hour -const DEFAULT_RECORDING_NOTIFICATION_OUTBOX_BUFFER: usize = 1024; -const DEFAULT_RECORDING_NOTIFICATION_MAX_ATTEMPTS: u32 = 6; -const DEFAULT_RECORDING_NOTIFICATION_BACKOFF_INITIAL_SECS: u64 = 5; -const DEFAULT_RECORDING_NOTIFICATION_BACKOFF_MAX_SECS: u64 = 900; // 15 minutes -const MAX_FILENAME_TEMPLATE_BYTES: usize = 240; - -/// Container the recorder muxes into. Recordings used to be hard-coded -/// to MPEG-TS regardless of the source codecs; operators recording -/// H.265 or AAC-only channels want a container that can hold them. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RecordingContainerFormat { - /// MPEG-TS. Robust against truncation, so a recording killed - /// mid-stream still plays — which is why it is the default. - #[default] - Mpegts, - Matroska, - Mp4, -} - -impl RecordingContainerFormat { - /// The `-f` argument for the muxer. - pub fn ffmpeg_format(self) -> &'static str { - match self { - Self::Mpegts => "mpegts", - Self::Matroska => "matroska", - Self::Mp4 => "mp4", - } - } - - /// The extension recordings in this container get, without the dot. - pub fn file_extension(self) -> &'static str { - match self { - Self::Mpegts => "ts", - Self::Matroska => "mkv", - Self::Mp4 => "mp4", - } - } -} - -pub fn default_recording_directory(download_dir: &str) -> String { - format!("{download_dir}/{DEFAULT_RECORDING_DIRECTORY_SUFFIX}") -} - -pub fn default_recording_timezone() -> String { DEFAULT_RECORDING_TIMEZONE.to_string() } -pub fn default_recording_filename_template() -> String { DEFAULT_RECORDING_FILENAME_TEMPLATE.to_string() } -pub const fn default_recording_max_pre_roll_secs() -> u64 { DEFAULT_RECORDING_MAX_PRE_ROLL_SECS } -pub const fn default_recording_max_post_roll_secs() -> u64 { DEFAULT_RECORDING_MAX_POST_ROLL_SECS } -pub const fn default_recording_cleanup_interval_secs() -> u64 { DEFAULT_RECORDING_CLEANUP_INTERVAL_SECS } -pub const fn default_recording_disk_safety_bytes() -> u64 { DEFAULT_RECORDING_DISK_SAFETY_BYTES } -pub const fn default_recording_fallback_bytes_per_minute() -> u64 { DEFAULT_RECORDING_FALLBACK_BYTES_PER_MINUTE } - -fn is_default_recording_max_pre_roll_secs(value: &u64) -> bool { *value == default_recording_max_pre_roll_secs() } -fn is_default_recording_max_post_roll_secs(value: &u64) -> bool { *value == default_recording_max_post_roll_secs() } -pub const fn default_recording_retention_sweep_interval_secs() -> u64 { - DEFAULT_RECORDING_RETENTION_SWEEP_INTERVAL_SECS -} -pub const fn default_recording_notification_outbox_buffer() -> usize { DEFAULT_RECORDING_NOTIFICATION_OUTBOX_BUFFER } -pub const fn default_recording_notification_max_attempts() -> u32 { DEFAULT_RECORDING_NOTIFICATION_MAX_ATTEMPTS } -pub const fn default_recording_notification_backoff_initial_secs() -> u64 { - DEFAULT_RECORDING_NOTIFICATION_BACKOFF_INITIAL_SECS -} -pub const fn default_recording_notification_backoff_max_secs() -> u64 { - DEFAULT_RECORDING_NOTIFICATION_BACKOFF_MAX_SECS -} -pub const fn default_recording_enabled() -> bool { true } - -fn is_default_recording_fallback_bytes_per_minute(value: &u64) -> bool { - *value == default_recording_fallback_bytes_per_minute() -} -fn is_default_recording_retention_sweep_interval_secs(value: &u64) -> bool { - *value == default_recording_retention_sweep_interval_secs() -} -fn is_default_recording_notification_outbox_buffer(value: &usize) -> bool { - *value == default_recording_notification_outbox_buffer() -} -fn is_default_recording_notification_max_attempts(value: &u32) -> bool { - *value == default_recording_notification_max_attempts() -} -fn is_default_recording_notification_backoff_initial_secs(value: &u64) -> bool { - *value == default_recording_notification_backoff_initial_secs() -} -fn is_default_recording_notification_backoff_max_secs(value: &u64) -> bool { - *value == default_recording_notification_backoff_max_secs() -} -fn is_recording_enabled(value: &bool) -> bool { *value } -fn is_default_recording_container_format(value: &RecordingContainerFormat) -> bool { - *value == RecordingContainerFormat::default() -} -// `skip_serializing_if` predicates that distinguish "field absent" from -// "field present with a zero value". An explicit `Some(0)` is a real -// configuration choice and must round-trip; only `None` means "absent". -fn is_zero_u64_opt(value: &Option) -> bool { value.is_none() } -fn is_zero_u32_opt(value: &Option) -> bool { value.is_none() } -fn is_zero_u8_opt(value: &Option) -> bool { value.is_none() } - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] -#[serde(deny_unknown_fields)] -pub struct VideoDownloadConfigDto { - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub headers: HashMap, - #[serde(default = "default_download_dir", skip_serializing_if = "is_blank_or_default_download_dir")] - pub directory: Option, - #[serde(default, skip_serializing_if = "is_false")] - pub organize_into_directories: bool, - #[serde(default = "default_episode_pattern", skip_serializing_if = "is_blank_or_default_episode_pattern")] - pub episode_pattern: Option, - #[serde(default, skip_serializing_if = "is_zero_i8")] - pub download_priority: i8, - #[serde(default, skip_serializing_if = "is_zero_i8")] - pub recording_priority: i8, - #[serde(default, skip_serializing_if = "is_zero_u8")] - pub reserve_slots_for_users: u8, - #[serde(default, skip_serializing_if = "is_zero_u8")] - pub max_background_per_provider: u8, - #[serde( - default = "default_retry_backoff_initial_secs", - skip_serializing_if = "is_default_retry_backoff_initial_secs" - )] - pub retry_backoff_initial_secs: u64, - #[serde(default = "default_retry_backoff_multiplier", skip_serializing_if = "is_default_retry_backoff_multiplier")] - pub retry_backoff_multiplier: f64, - #[serde(default = "default_retry_backoff_max_secs", skip_serializing_if = "is_default_retry_backoff_max_secs")] - pub retry_backoff_max_secs: u64, - #[serde( - default = "default_retry_backoff_jitter_percent", - skip_serializing_if = "is_default_retry_backoff_jitter_percent" - )] - pub retry_backoff_jitter_percent: u8, - #[serde(default = "default_retry_max_attempts", skip_serializing_if = "is_default_retry_max_attempts")] - pub retry_max_attempts: u8, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub recording: Option, -} - -impl VideoDownloadConfigDto { - pub fn is_empty(&self) -> bool { - !self.organize_into_directories - && self.headers.is_empty() - && is_blank_or_default_download_dir(&self.directory) - && is_blank_or_default_episode_pattern(&self.episode_pattern) - && self.download_priority == 0 - && self.recording_priority == 0 - && self.reserve_slots_for_users == 0 - && self.max_background_per_provider == 0 - && is_default_retry_backoff_initial_secs(&self.retry_backoff_initial_secs) - && is_default_retry_backoff_multiplier(&self.retry_backoff_multiplier) - && is_default_retry_backoff_max_secs(&self.retry_backoff_max_secs) - && is_default_retry_backoff_jitter_percent(&self.retry_backoff_jitter_percent) - && is_default_retry_max_attempts(&self.retry_max_attempts) - && self.recording.is_none() - } -} - -/// DVR recording configuration block. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] -#[serde(deny_unknown_fields)] -pub struct RecordingConfigDto { - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub headers: HashMap, - #[serde( - default = "default_supported_video_extensions", - skip_serializing_if = "is_default_supported_video_extensions" - )] - pub extensions: Vec, - #[serde(default, skip_serializing_if = "is_false")] - pub organize_into_directories: bool, - #[serde(default = "default_episode_pattern", skip_serializing_if = "is_blank_or_default_episode_pattern")] - pub episode_pattern: Option, - #[serde(default, skip_serializing_if = "is_zero_i8")] - pub priority: i8, - #[serde(default, skip_serializing_if = "is_zero_u8")] - pub reserve_slots_for_users: u8, - #[serde(default, skip_serializing_if = "is_zero_u8")] - pub max_background_per_provider: u8, - #[serde( - default = "default_retry_backoff_initial_secs", - skip_serializing_if = "is_default_retry_backoff_initial_secs" - )] - pub retry_backoff_initial_secs: u64, - #[serde(default = "default_retry_backoff_multiplier", skip_serializing_if = "is_default_retry_backoff_multiplier")] - pub retry_backoff_multiplier: f64, - #[serde(default = "default_retry_backoff_max_secs", skip_serializing_if = "is_default_retry_backoff_max_secs")] - pub retry_backoff_max_secs: u64, - #[serde( - default = "default_retry_backoff_jitter_percent", - skip_serializing_if = "is_default_retry_backoff_jitter_percent" - )] - pub retry_backoff_jitter_percent: u8, - #[serde(default = "default_retry_max_attempts", skip_serializing_if = "is_default_retry_max_attempts")] - pub retry_max_attempts: u8, - /// Master switch for the whole DVR feature. `false` stops the - /// supervisors, so nothing is materialized, swept, or notified. - #[serde(default = "default_recording_enabled", skip_serializing_if = "is_recording_enabled")] - pub enabled: bool, - #[serde(default, skip_serializing_if = "is_default_recording_container_format")] - pub container_format: RecordingContainerFormat, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub directory: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub timezone: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub filename_template: Option, - #[serde(default, skip_serializing_if = "is_zero_u64_opt")] - pub default_pre_roll_secs: Option, - #[serde( - default = "default_recording_max_pre_roll_secs", - skip_serializing_if = "is_default_recording_max_pre_roll_secs" - )] - pub max_pre_roll_secs: u64, - #[serde(default, skip_serializing_if = "is_zero_u64_opt")] - pub default_post_roll_secs: Option, - #[serde( - default = "default_recording_max_post_roll_secs", - skip_serializing_if = "is_default_recording_max_post_roll_secs" - )] - pub max_post_roll_secs: u64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub retention: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub disk: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub quota: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub notifications: Option, - #[serde( - default = "default_recording_fallback_bytes_per_minute", - skip_serializing_if = "is_default_recording_fallback_bytes_per_minute" - )] - pub fallback_bytes_per_minute: u64, -} - -/// Hand-written so `Default` agrees with the serde defaults. A derived -/// `Default` would produce `enabled: false` and zero padding limits — -/// i.e. a silently disabled DVR — which is the opposite of what an -/// absent `recording:` block means. -impl Default for RecordingConfigDto { - fn default() -> Self { - Self { - headers: HashMap::new(), - extensions: default_supported_video_extensions(), - organize_into_directories: false, - episode_pattern: default_episode_pattern(), - priority: 0, - reserve_slots_for_users: 0, - max_background_per_provider: 0, - retry_backoff_initial_secs: default_retry_backoff_initial_secs(), - retry_backoff_multiplier: default_retry_backoff_multiplier(), - retry_backoff_max_secs: default_retry_backoff_max_secs(), - retry_backoff_jitter_percent: default_retry_backoff_jitter_percent(), - retry_max_attempts: default_retry_max_attempts(), - enabled: default_recording_enabled(), - container_format: RecordingContainerFormat::default(), - directory: None, - timezone: None, - filename_template: None, - default_pre_roll_secs: None, - max_pre_roll_secs: default_recording_max_pre_roll_secs(), - default_post_roll_secs: None, - max_post_roll_secs: default_recording_max_post_roll_secs(), - retention: None, - disk: None, - quota: None, - notifications: None, - fallback_bytes_per_minute: default_recording_fallback_bytes_per_minute(), - } - } -} - -impl RecordingConfigDto { - pub fn is_empty(&self) -> bool { - self.headers.is_empty() - && is_default_supported_video_extensions(&self.extensions) - && !self.organize_into_directories - && is_blank_or_default_episode_pattern(&self.episode_pattern) - && self.priority == 0 - && self.reserve_slots_for_users == 0 - && self.max_background_per_provider == 0 - && is_default_retry_backoff_initial_secs(&self.retry_backoff_initial_secs) - && is_default_retry_backoff_multiplier(&self.retry_backoff_multiplier) - && is_default_retry_backoff_max_secs(&self.retry_backoff_max_secs) - && is_default_retry_backoff_jitter_percent(&self.retry_backoff_jitter_percent) - && is_default_retry_max_attempts(&self.retry_max_attempts) - && self.enabled == default_recording_enabled() - && is_default_recording_container_format(&self.container_format) - && self.notifications.is_none() - && self.directory.is_none() - && self.timezone.is_none() - && self.filename_template.is_none() - && self.default_pre_roll_secs.is_none() - && is_default_recording_max_pre_roll_secs(&self.max_pre_roll_secs) - && self.default_post_roll_secs.is_none() - && is_default_recording_max_post_roll_secs(&self.max_post_roll_secs) - && self.retention.is_none() - && self.disk.is_none() - && self.quota.is_none() - && self.fallback_bytes_per_minute == default_recording_fallback_bytes_per_minute() - } - - pub fn clean(&mut self) { - self.retention = self.retention.take().filter(|value| !value.is_empty()); - self.disk = self.disk.take().filter(|value| !value.is_empty()); - self.quota = self.quota.take().filter(|value| !value.is_empty()); - self.notifications = self.notifications.take().filter(|value| !value.is_empty()); - } -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] -#[serde(deny_unknown_fields)] -pub struct RecordingRetentionConfigDto { - #[serde(default, skip_serializing_if = "is_zero_u32_opt")] - pub keep_last_per_channel: Option, - #[serde(default, skip_serializing_if = "is_zero_u32_opt")] - pub delete_after_days: Option, - /// How often the age/count sweep runs. Independent of - /// `disk.cleanup_interval_secs`, which paces the watermark check. - #[serde( - default = "default_recording_retention_sweep_interval_secs", - skip_serializing_if = "is_default_recording_retention_sweep_interval_secs" - )] - pub sweep_interval_secs: u64, -} - -impl Default for RecordingRetentionConfigDto { - fn default() -> Self { - Self { - keep_last_per_channel: None, - delete_after_days: None, - sweep_interval_secs: default_recording_retention_sweep_interval_secs(), - } - } -} - -impl RecordingRetentionConfigDto { - pub fn is_empty(&self) -> bool { - self.keep_last_per_channel.is_none() - && self.delete_after_days.is_none() - && is_default_recording_retention_sweep_interval_secs(&self.sweep_interval_secs) - } -} - -/// Lifecycle-notification delivery. The outbox worker owns these; the -/// recorder itself never blocks on a notification. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] -#[serde(deny_unknown_fields)] -pub struct RecordingNotificationConfigDto { - /// Bounded in-memory queue between the recorder and the outbox - /// worker. A full channel drops the newest entry rather than - /// stalling a recording. - #[serde( - default = "default_recording_notification_outbox_buffer", - skip_serializing_if = "is_default_recording_notification_outbox_buffer" - )] - pub outbox_buffer: usize, - /// Delivery attempts before an entry is dead-lettered. - #[serde( - default = "default_recording_notification_max_attempts", - skip_serializing_if = "is_default_recording_notification_max_attempts" - )] - pub max_attempts: u32, - #[serde( - default = "default_recording_notification_backoff_initial_secs", - skip_serializing_if = "is_default_recording_notification_backoff_initial_secs" - )] - pub backoff_initial_secs: u64, - #[serde( - default = "default_recording_notification_backoff_max_secs", - skip_serializing_if = "is_default_recording_notification_backoff_max_secs" - )] - pub backoff_max_secs: u64, -} - -impl Default for RecordingNotificationConfigDto { - fn default() -> Self { - Self { - outbox_buffer: default_recording_notification_outbox_buffer(), - max_attempts: default_recording_notification_max_attempts(), - backoff_initial_secs: default_recording_notification_backoff_initial_secs(), - backoff_max_secs: default_recording_notification_backoff_max_secs(), - } - } -} - -impl RecordingNotificationConfigDto { - pub fn is_empty(&self) -> bool { *self == Self::default() } -} - -#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq)] -#[serde(deny_unknown_fields)] -pub struct RecordingDiskConfigDto { - #[serde(default, skip_serializing_if = "is_zero_u8_opt")] - pub high_water_percent: Option, - #[serde(default, skip_serializing_if = "is_zero_u8_opt")] - pub low_water_percent: Option, - #[serde(default, skip_serializing_if = "is_zero_u64_opt")] - pub cleanup_interval_secs: Option, - #[serde(default, skip_serializing_if = "is_zero_u64_opt")] - pub safety_bytes: Option, -} - -impl RecordingDiskConfigDto { - pub fn is_empty(&self) -> bool { - self.high_water_percent.is_none() - && self.low_water_percent.is_none() - && self.cleanup_interval_secs.is_none() - && self.safety_bytes.is_none() - } -} - -#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq)] -#[serde(deny_unknown_fields)] -pub struct RecordingQuotaConfigDto { - #[serde(default, skip_serializing_if = "is_zero_u64_opt")] - pub default_private_bytes: Option, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub per_user_bytes: HashMap, - #[serde(default, skip_serializing_if = "is_zero_u64_opt")] - pub shared_bytes: Option, -} - -impl RecordingQuotaConfigDto { - pub fn is_empty(&self) -> bool { - self.default_private_bytes.is_none() && self.per_user_bytes.is_empty() && self.shared_bytes.is_none() - } -} - -/// Allowed placeholders in the recording filename template. -/// Order matters for error messages and the validation regex. -pub const RECORDING_FILENAME_PLACEHOLDERS: &[&str] = - &["{channel}", "{program_title}", "{start_time}", "{end_time}", "{episode}", "{owner}"]; - -/// Validates a recording filename template. Returns the cleaned template and -/// the matched placeholder set on success. -fn validate_recording_filename_template(template: &str) -> Result<(), TuliproxError> { - if template.is_empty() { - return Err(TuliproxError::ConfigVideoDownload("recording.filename_template must not be empty".to_string())); - } - if template.len() > MAX_FILENAME_TEMPLATE_BYTES { - return Err(TuliproxError::ConfigVideoDownload(format!( - "recording.filename_template must not exceed {MAX_FILENAME_TEMPLATE_BYTES} bytes" - ))); - } - - let bytes = template.as_bytes(); - let mut i = 0; - let mut found_placeholder = false; - while i < bytes.len() { - if bytes[i] == b'{' { - if let Some(close) = template[i + 1..].find('}') { - let end = i + 1 + close; - let placeholder = &template[i..=end]; - if !RECORDING_FILENAME_PLACEHOLDERS.contains(&placeholder) { - return Err(TuliproxError::ConfigVideoDownload(format!( - "recording.filename_template contains unknown placeholder '{placeholder}'" - ))); - } - found_placeholder = true; - i = end + 1; - } else { - return Err(TuliproxError::ConfigVideoDownload( - "recording.filename_template has an unmatched '{'".to_string(), - )); - } - } else if bytes[i] == b'}' { - return Err(TuliproxError::ConfigVideoDownload( - "recording.filename_template has an unmatched '}'".to_string(), - )); - } else { - i += 1; - } - } - - if !found_placeholder { - return Err(TuliproxError::ConfigVideoDownload( - "recording.filename_template must contain at least one placeholder".to_string(), - )); - } - Ok(()) -} - -fn validate_recording_timezone(tz: &str) -> Result<(), TuliproxError> { - if tz.is_empty() { - return Err(TuliproxError::ConfigVideoDownload("recording.timezone must not be empty".to_string())); - } - tz.parse::().map(|_| ()).map_err(|_| { - TuliproxError::ConfigVideoDownload(format!("recording.timezone '{tz}' is not a valid IANA timezone")) - }) -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] -#[serde(deny_unknown_fields)] -pub struct VideoConfigDto { - #[serde( - default = "default_supported_video_extensions", - skip_serializing_if = "is_default_supported_video_extensions" - )] - pub extensions: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub download: Option, - #[serde(default, skip_serializing_if = "is_blank_optional_string")] - pub web_search: Option, -} - -impl VideoConfigDto { - pub fn is_empty(&self) -> bool { - (self.extensions.is_empty() || is_default_supported_video_extensions(&self.extensions)) - && is_blank_optional_str(self.web_search.as_deref()) - && (self.download.is_none() || self.download.as_ref().is_some_and(VideoDownloadConfigDto::is_empty)) - } - - pub fn clean(&mut self) { - if let Some(download) = self.download.as_mut() { - if let Some(recording) = download.recording.as_mut() { - recording.retention = recording.retention.take().filter(|retention| !retention.is_empty()); - recording.disk = recording.disk.take().filter(|disk| !disk.is_empty()); - recording.quota = recording.quota.take().filter(|quota| !quota.is_empty()); - recording.notifications = - recording.notifications.take().filter(|notifications| !notifications.is_empty()); - } - } - if self.download.as_ref().is_some_and(VideoDownloadConfigDto::is_empty) { - self.download = None; - } - } - - /// # Panics - /// - /// Will panic if default `RegEx` gets invalid - pub fn prepare(&mut self) -> Result<(), TuliproxError> { - if self.extensions.is_empty() { - self.extensions = default_supported_video_extensions(); - } - match &mut self.download { - None => {} - Some(downl) => { - if is_blank_or_default_download_dir(&downl.directory) { - downl.directory = default_download_dir(); - } else if let Some(directory) = downl.directory.as_ref() { - downl.directory = Some(directory.trim().to_string()); - } - - if downl.headers.is_empty() { - downl.headers.borrow_mut().insert("Accept".to_string(), "video/*".to_string()); - downl.headers.borrow_mut().insert("User-Agent".to_string(), DEFAULT_USER_AGENT.to_string()); - } - - if is_blank_or_default_episode_pattern(&downl.episode_pattern) { - downl.episode_pattern = default_episode_pattern(); - } else if let Some(episode_pattern) = downl.episode_pattern.as_ref() { - downl.episode_pattern = Some(episode_pattern.trim().to_string()); - } - - if let Some(episode_pattern) = &downl.episode_pattern { - if let Err(err) = crate::model::REGEX_CACHE.get_or_compile(episode_pattern) { - return Err(TuliproxError::RegexCompile(format!("{episode_pattern} {err}"))); - } - } - - downl.retry_backoff_initial_secs = downl.retry_backoff_initial_secs.max(1); - downl.retry_backoff_multiplier = downl.retry_backoff_multiplier.max(1.0); - downl.retry_backoff_max_secs = downl.retry_backoff_max_secs.max(downl.retry_backoff_initial_secs); - downl.retry_backoff_jitter_percent = downl.retry_backoff_jitter_percent.min(95); - downl.retry_max_attempts = downl.retry_max_attempts.max(1); - - if let Some(recording) = downl.recording.as_mut() { - prepare_recording_config(recording, downl.directory.as_deref().unwrap_or(DEFAULT_DOWNLOAD_DIR))?; - } - } - } - Ok(()) - } -} - -pub(crate) fn prepare_recording_config( - recording: &mut RecordingConfigDto, - download_dir: &str, -) -> Result<(), TuliproxError> { - if recording.headers.is_empty() { - recording.headers.insert("Accept".to_string(), "video/*".to_string()); - recording.headers.insert("User-Agent".to_string(), DEFAULT_USER_AGENT.to_string()); - } - if recording.extensions.is_empty() { - recording.extensions = default_supported_video_extensions(); - } - if is_blank_or_default_episode_pattern(&recording.episode_pattern) { - recording.episode_pattern = default_episode_pattern(); - } else if let Some(pattern) = recording.episode_pattern.as_ref() { - recording.episode_pattern = Some(pattern.trim().to_string()); - } - if let Some(pattern) = recording.episode_pattern.as_ref() { - crate::model::REGEX_CACHE - .get_or_compile(pattern) - .map_err(|err| TuliproxError::RegexCompile(format!("{pattern} {err}")))?; - } - recording.retry_backoff_initial_secs = recording.retry_backoff_initial_secs.max(1); - recording.retry_backoff_multiplier = recording.retry_backoff_multiplier.max(1.0); - recording.retry_backoff_max_secs = recording.retry_backoff_max_secs.max(recording.retry_backoff_initial_secs); - recording.retry_backoff_jitter_percent = recording.retry_backoff_jitter_percent.min(95); - recording.retry_max_attempts = recording.retry_max_attempts.max(1); - - // directory: default to /recordings when blank. - if let Some(dir) = recording.directory.as_ref() { - let trimmed = dir.trim(); - if trimmed.is_empty() { - recording.directory = None; - } else if trimmed != dir { - recording.directory = Some(trimmed.to_string()); - } - } - if recording.directory.is_none() { - recording.directory = Some(default_recording_directory(download_dir)); - } - - // timezone: default UTC; validate IANA. - if let Some(tz) = recording.timezone.as_ref() { - let trimmed = tz.trim(); - if trimmed.is_empty() { - recording.timezone = None; - } else if trimmed != tz { - recording.timezone = Some(trimmed.to_string()); - } - } - if let Some(tz) = recording.timezone.as_ref() { - if tz.parse::().is_err() { - // Surface a warning before the strict validator below turns - // this into a hard error, so the operator sees both signals. - log::warn!("recording.timezone '{tz}' is not a valid IANA timezone"); - } - } - if recording.timezone.is_none() { - recording.timezone = Some(default_recording_timezone()); - } - validate_recording_timezone(recording.timezone.as_deref().unwrap_or("UTC"))?; - - // filename_template: default; validate placeholders. - if let Some(template) = recording.filename_template.as_ref() { - let trimmed = template.trim(); - if trimmed != template { - recording.filename_template = Some(trimmed.to_string()); - } - } - if recording.filename_template.is_none() { - recording.filename_template = Some(default_recording_filename_template()); - } - validate_recording_filename_template(recording.filename_template.as_deref().unwrap_or(""))?; - - // padding: default <= max, fallback to defaults if missing. - if recording.default_pre_roll_secs.is_none() { - recording.default_pre_roll_secs = Some(0); - } - if recording.default_pre_roll_secs.unwrap_or(0) > recording.max_pre_roll_secs { - return Err(TuliproxError::ConfigVideoDownload(format!( - "recording.default_pre_roll_secs ({}) must not exceed max_pre_roll_secs ({})", - recording.default_pre_roll_secs.unwrap_or(0), - recording.max_pre_roll_secs - ))); - } - if recording.default_post_roll_secs.is_none() { - recording.default_post_roll_secs = Some(0); - } - if recording.default_post_roll_secs.unwrap_or(0) > recording.max_post_roll_secs { - return Err(TuliproxError::ConfigVideoDownload(format!( - "recording.default_post_roll_secs ({}) must not exceed max_post_roll_secs ({})", - recording.default_post_roll_secs.unwrap_or(0), - recording.max_post_roll_secs - ))); - } - - // retention: each present policy must be > 0. - if let Some(retention) = recording.retention.as_ref() { - if let Some(keep) = retention.keep_last_per_channel { - if keep == 0 { - return Err(TuliproxError::ConfigVideoDownload( - "recording.retention.keep_last_per_channel must be > 0".to_string(), - )); - } - } - if let Some(days) = retention.delete_after_days { - if days == 0 { - return Err(TuliproxError::ConfigVideoDownload( - "recording.retention.delete_after_days must be > 0".to_string(), - )); - } - } - if retention.sweep_interval_secs == 0 { - return Err(TuliproxError::ConfigVideoDownload( - "recording.retention.sweep_interval_secs must be > 0".to_string(), - )); - } - if retention.keep_last_per_channel.is_none() && retention.delete_after_days.is_none() { - // A retention block that expresses no policy reads as - // "retention is configured" while nothing is ever deleted. - log::warn!( - "recording.retention has neither keep_last_per_channel nor delete_after_days; \ - no recording will ever be deleted by policy" - ); - } - } - - // notifications: an outbox that cannot hold or retry anything would - // silently drop every lifecycle notification. - if let Some(notifications) = recording.notifications.as_ref() { - if notifications.outbox_buffer == 0 { - return Err(TuliproxError::ConfigVideoDownload( - "recording.notifications.outbox_buffer must be > 0".to_string(), - )); - } - if notifications.max_attempts == 0 { - return Err(TuliproxError::ConfigVideoDownload( - "recording.notifications.max_attempts must be > 0".to_string(), - )); - } - if notifications.backoff_initial_secs == 0 { - return Err(TuliproxError::ConfigVideoDownload( - "recording.notifications.backoff_initial_secs must be > 0".to_string(), - )); - } - if notifications.backoff_max_secs < notifications.backoff_initial_secs { - return Err(TuliproxError::ConfigVideoDownload(format!( - "recording.notifications.backoff_max_secs ({}) must be >= backoff_initial_secs ({})", - notifications.backoff_max_secs, notifications.backoff_initial_secs - ))); - } - } - - // disk: percentages in 0..=100, low < high, cleanup > 0, safety non-zero. - if let Some(disk) = recording.disk.as_ref() { - if let Some(high) = disk.high_water_percent { - if high > 100 { - return Err(TuliproxError::ConfigVideoDownload(format!( - "recording.disk.high_water_percent ({high}) must be 0..=100" - ))); - } - } - if let Some(low) = disk.low_water_percent { - if low > 100 { - return Err(TuliproxError::ConfigVideoDownload(format!( - "recording.disk.low_water_percent ({low}) must be 0..=100" - ))); - } - } - if let (Some(low), Some(high)) = (disk.low_water_percent, disk.high_water_percent) { - if low >= high { - return Err(TuliproxError::ConfigVideoDownload(format!( - "recording.disk.low_water_percent ({low}) must be < high_water_percent ({high})" - ))); - } - } - if let Some(interval) = disk.cleanup_interval_secs { - if interval == 0 { - return Err(TuliproxError::ConfigVideoDownload( - "recording.disk.cleanup_interval_secs must be > 0".to_string(), - )); - } - } - if let Some(safety) = disk.safety_bytes { - if safety == 0 { - return Err(TuliproxError::ConfigVideoDownload("recording.disk.safety_bytes must be > 0".to_string())); - } - } - } - - // quota: fallback bytes per minute must be > 0. - if recording.fallback_bytes_per_minute == 0 { - return Err(TuliproxError::ConfigVideoDownload("recording.fallback_bytes_per_minute must be > 0".to_string())); - } - - // Nothing bounds recording disk use unless at least one of the three - // policies is set. Worth a warning, not an error: a dedicated - // filesystem is a legitimate reason to run without any of them. - let has_policy_retention = recording - .retention - .as_ref() - .is_some_and(|retention| retention.keep_last_per_channel.is_some() || retention.delete_after_days.is_some()); - let has_watermarks = recording - .disk - .as_ref() - .is_some_and(|disk| disk.high_water_percent.is_some() && disk.low_water_percent.is_some()); - let has_quota = recording.quota.as_ref().is_some_and(|quota| { - quota.default_private_bytes.is_some() || quota.shared_bytes.is_some() || !quota.per_user_bytes.is_empty() - }); - if recording.enabled && !has_policy_retention && !has_watermarks && !has_quota { - log::warn!( - "recording is enabled with no retention, no disk watermarks, and no quota; \ - recording disk usage is unbounded" - ); - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::defaults::DEFAULT_DOWNLOAD_DIR; - - fn make_test_download_config() -> VideoDownloadConfigDto { - VideoDownloadConfigDto { - headers: HashMap::new(), - directory: None, - organize_into_directories: false, - episode_pattern: None, - download_priority: 0, - recording_priority: 0, - reserve_slots_for_users: 0, - max_background_per_provider: 0, - retry_backoff_initial_secs: default_retry_backoff_initial_secs(), - retry_backoff_multiplier: default_retry_backoff_multiplier(), - retry_backoff_max_secs: default_retry_backoff_max_secs(), - retry_backoff_jitter_percent: default_retry_backoff_jitter_percent(), - retry_max_attempts: default_retry_max_attempts(), - recording: None, - } - } - - #[test] - fn prepare_sets_default_download_dir_when_missing() { - let mut video = - VideoConfigDto { extensions: Vec::new(), download: Some(make_test_download_config()), web_search: None }; - video.prepare().expect("prepare should succeed"); - let download = video.download.expect("download should exist"); - assert_eq!(download.directory.as_deref(), Some(DEFAULT_DOWNLOAD_DIR)); - } - - #[test] - fn prepare_sets_default_episode_pattern_when_missing() { - let mut video = - VideoConfigDto { extensions: Vec::new(), download: Some(make_test_download_config()), web_search: None }; - video.prepare().expect("prepare should succeed"); - let download = video.download.expect("download should exist"); - assert!(download.episode_pattern.is_some(), "expected default episode pattern to be set"); - } - - #[test] - fn prepare_keeps_custom_download_dir() { - let mut video = VideoConfigDto { - extensions: Vec::new(), - download: Some(VideoDownloadConfigDto { - directory: Some("custom-downloads".to_string()), - ..make_test_download_config() - }), - web_search: None, - }; - video.prepare().expect("prepare should succeed"); - let download = video.download.expect("download should exist"); - assert_eq!(download.directory.as_deref(), Some("custom-downloads")); - } - - #[test] - fn serializing_skips_default_download_dir() { - let download = - VideoDownloadConfigDto { directory: Some(DEFAULT_DOWNLOAD_DIR.to_string()), ..make_test_download_config() }; - let serialized = serde_json::to_string(&download).expect("download serialization should succeed"); - assert!( - !serialized.contains("\"directory\""), - "expected no directory field for default value, got: {serialized}" - ); - } - - #[test] - fn serializing_keeps_custom_download_dir() { - let download = - VideoDownloadConfigDto { directory: Some("custom-downloads".to_string()), ..make_test_download_config() }; - let serialized = serde_json::to_string(&download).expect("download serialization should succeed"); - assert!(serialized.contains("\"directory\""), "expected directory field for custom value, got: {serialized}"); - } - - #[test] - fn serializing_skips_default_episode_pattern() { - let download = - VideoDownloadConfigDto { episode_pattern: default_episode_pattern(), ..make_test_download_config() }; - let serialized = serde_json::to_string(&download).expect("download serialization should succeed"); - assert!( - !serialized.contains("\"episode_pattern\""), - "expected no episode_pattern field for default value, got: {serialized}" - ); - } - - #[test] - fn prepare_preserves_download_retry_backoff_settings() { - let mut video = VideoConfigDto { - extensions: Vec::new(), - download: Some(VideoDownloadConfigDto { - retry_backoff_initial_secs: 3, - retry_backoff_multiplier: 2.5, - retry_backoff_max_secs: 45, - retry_backoff_jitter_percent: 0, - retry_max_attempts: 7, - ..make_test_download_config() - }), - web_search: None, - }; - - video.prepare().expect("prepare should succeed"); - let download = video.download.expect("download should exist"); - assert_eq!(download.retry_backoff_initial_secs, 3); - assert!((download.retry_backoff_multiplier - 2.5).abs() < F64_DEFAULT_EPSILON); - assert_eq!(download.retry_backoff_max_secs, 45); - assert_eq!(download.retry_backoff_jitter_percent, 0); - assert_eq!(download.retry_max_attempts, 7); - } - - #[test] - fn serializing_keeps_custom_download_retry_backoff_settings() { - let download = VideoDownloadConfigDto { - retry_backoff_initial_secs: 4, - retry_backoff_multiplier: 2.0, - retry_backoff_max_secs: 60, - retry_backoff_jitter_percent: 10, - retry_max_attempts: 6, - ..make_test_download_config() - }; - - let serialized = serde_json::to_string(&download).expect("download serialization should succeed"); - assert!(serialized.contains("\"retry_backoff_initial_secs\":4")); - assert!(serialized.contains("\"retry_backoff_multiplier\":2.0")); - assert!(serialized.contains("\"retry_backoff_max_secs\":60")); - assert!(serialized.contains("\"retry_backoff_jitter_percent\":10")); - assert!(serialized.contains("\"retry_max_attempts\":6")); - } - - #[test] - fn serializing_keeps_scheduler_policy_settings() { - let download = VideoDownloadConfigDto { - reserve_slots_for_users: 1, - max_background_per_provider: 2, - ..make_test_download_config() - }; - - let serialized = serde_json::to_string(&download).expect("download serialization should succeed"); - assert!(serialized.contains("\"reserve_slots_for_users\":1")); - assert!(serialized.contains("\"max_background_per_provider\":2")); - } - - #[test] - fn clean_preserves_download_block_when_non_zero_priorities_are_set() { - let mut video = VideoConfigDto { - extensions: Vec::new(), - download: Some(VideoDownloadConfigDto { - download_priority: -2, - recording_priority: 3, - ..make_test_download_config() - }), - web_search: None, - }; - - video.clean(); - - assert!(video.download.is_some()); - } - - #[test] - fn serializing_keeps_non_zero_priorities_and_skips_zero_priorities() { - let non_zero = - VideoDownloadConfigDto { download_priority: -1, recording_priority: 2, ..make_test_download_config() }; - let zero = VideoDownloadConfigDto { download_priority: 0, recording_priority: 0, ..non_zero.clone() }; - - let non_zero_serialized = serde_json::to_string(&non_zero).expect("non-zero priorities serialize"); - let zero_serialized = serde_json::to_string(&zero).expect("zero priorities serialize"); - - assert!(non_zero_serialized.contains("\"download_priority\":-1")); - assert!(non_zero_serialized.contains("\"recording_priority\":2")); - assert!(!zero_serialized.contains("\"download_priority\"")); - assert!(!zero_serialized.contains("\"recording_priority\"")); - } - - // --- Recording config tests --- - - fn make_recording_config() -> RecordingConfigDto { RecordingConfigDto::default() } - - #[test] - fn recording_config_default_matches_the_serde_defaults() { - // A derived `Default` would produce `enabled: false` and zero - // padding limits — a silently disabled DVR. Deserializing an empty - // mapping and calling `default()` must agree. - let deserialized: RecordingConfigDto = serde_json::from_str("{}").expect("empty recording block deserializes"); - assert_eq!(deserialized, RecordingConfigDto::default()); - assert!(RecordingConfigDto::default().enabled); - assert!(RecordingConfigDto::default().is_empty()); - assert_eq!( - RecordingRetentionConfigDto::default().sweep_interval_secs, - default_recording_retention_sweep_interval_secs() - ); - } - - #[test] - fn recording_container_format_round_trips_and_defaults_to_mpegts() { - assert_eq!(RecordingContainerFormat::default(), RecordingContainerFormat::Mpegts); - assert_eq!(RecordingContainerFormat::Mpegts.ffmpeg_format(), "mpegts"); - assert_eq!(RecordingContainerFormat::Matroska.file_extension(), "mkv"); - let parsed: RecordingConfigDto = - serde_json::from_str(r#"{"container_format":"matroska"}"#).expect("parse container format"); - assert_eq!(parsed.container_format, RecordingContainerFormat::Matroska); - } - - #[test] - fn recording_notification_block_rejects_impossible_values() { - let zero_cases = [ - ( - "outbox_buffer", - RecordingNotificationConfigDto { outbox_buffer: 0, ..RecordingNotificationConfigDto::default() }, - ), - ( - "max_attempts", - RecordingNotificationConfigDto { max_attempts: 0, ..RecordingNotificationConfigDto::default() }, - ), - ( - "backoff_initial_secs", - RecordingNotificationConfigDto { backoff_initial_secs: 0, ..RecordingNotificationConfigDto::default() }, - ), - ]; - - for (field, notifications) in zero_cases { - let mut recording = make_recording_config(); - recording.notifications = Some(notifications); - let mut video = make_recording_video_config(recording); - let result = video.prepare(); - assert!(result.as_ref().is_err_and(|err| err.to_string().contains(field)), "field: {field}"); - } - - let mut recording = make_recording_config(); - recording.notifications = Some(RecordingNotificationConfigDto { - backoff_initial_secs: 60, - backoff_max_secs: 10, - ..RecordingNotificationConfigDto::default() - }); - let mut video = make_recording_video_config(recording); - let result = video.prepare(); - assert!(result.as_ref().is_err_and(|err| err.to_string().contains("backoff_max_secs"))); - } - - #[test] - fn recording_retention_rejects_a_zero_sweep_interval() { - let mut recording = make_recording_config(); - recording.retention = Some(RecordingRetentionConfigDto { - keep_last_per_channel: Some(5), - sweep_interval_secs: 0, - ..RecordingRetentionConfigDto::default() - }); - let mut video = make_recording_video_config(recording); - assert!(video.prepare().is_err()); - } - - fn make_recording_video_config(recording: RecordingConfigDto) -> VideoConfigDto { - VideoConfigDto { - extensions: Vec::new(), - download: Some(VideoDownloadConfigDto { recording: Some(recording), ..make_test_download_config() }), - web_search: None, - } - } - - #[test] - fn recording_defaults_fill_in_when_absent() { - let mut video = make_recording_video_config(make_recording_config()); - video.prepare().expect("prepare should succeed"); - let recording = video.download.as_ref().and_then(|d| d.recording.as_ref()).expect("recording should exist"); - assert_eq!(recording.directory.as_deref(), Some(format!("{DEFAULT_DOWNLOAD_DIR}/recordings").as_str())); - assert!(recording.directory.as_deref().unwrap_or("").ends_with("/recordings")); - assert_eq!(recording.timezone.as_deref(), Some("UTC")); - assert_eq!(recording.filename_template.as_deref(), Some("{channel}_{program_title}_{start_time}")); - assert_eq!(recording.default_pre_roll_secs, Some(0)); - assert_eq!(recording.default_post_roll_secs, Some(0)); - } - - #[test] - fn recording_round_trips_custom_directory_and_template() { - let mut recording = make_recording_config(); - recording.directory = Some("/var/recordings".to_string()); - recording.timezone = Some("Europe/Berlin".to_string()); - recording.filename_template = Some("{channel}_{program_title}_{start_time}_{episode}".to_string()); - let mut video = make_recording_video_config(recording); - video.prepare().expect("prepare should succeed"); - let recording = video.download.as_ref().unwrap().recording.as_ref().unwrap(); - assert_eq!(recording.directory.as_deref(), Some("/var/recordings")); - assert_eq!(recording.timezone.as_deref(), Some("Europe/Berlin")); - assert_eq!(recording.filename_template.as_deref(), Some("{channel}_{program_title}_{start_time}_{episode}")); - } - - #[test] - fn recording_yaml_without_recording_block_loads() { - let yaml = r#" - extensions: [".ts"] - download: - directory: custom - "#; - let cfg: VideoConfigDto = serde_saphyr::from_str(yaml).expect("YAML should parse"); - let download = cfg.download.as_ref().expect("download should exist"); - assert!(download.recording.is_none()); - } - - #[test] - fn recording_yaml_with_recording_block_loads_and_validates() { - let yaml = r#" - extensions: [".ts"] - download: - directory: custom - recording: - directory: /data/recordings - timezone: Europe/London - filename_template: "{owner}_{channel}_{start_time}" - "#; - let cfg: VideoConfigDto = serde_saphyr::from_str(yaml).expect("YAML should parse"); - let recording = cfg.download.as_ref().unwrap().recording.as_ref().unwrap(); - assert_eq!(recording.directory.as_deref(), Some("/data/recordings")); - assert_eq!(recording.timezone.as_deref(), Some("Europe/London")); - } - - #[test] - fn recording_template_with_unknown_placeholder_is_rejected() { - let mut recording = make_recording_config(); - recording.filename_template = Some("{channel}_{unknown}".to_string()); - let mut video = make_recording_video_config(recording); - let err = video.prepare().expect_err("unknown placeholder should fail"); - assert!(err.to_string().contains("unknown placeholder"), "error: {err}"); - } - - #[test] - fn recording_template_with_unmatched_brace_is_rejected() { - let mut recording = make_recording_config(); - recording.filename_template = Some("{channel_{start_time}".to_string()); - let mut video = make_recording_video_config(recording); - let err = video.prepare().expect_err("unmatched brace should fail"); - // The parser treats `{channel_{start_time}` as a single malformed placeholder, - // which is rejected as 'unknown placeholder'. - assert!(err.to_string().contains("placeholder"), "error: {err}"); - } - - #[test] - fn recording_template_with_open_brace_is_rejected() { - let mut recording = make_recording_config(); - recording.filename_template = Some("{channel".to_string()); - let mut video = make_recording_video_config(recording); - let err = video.prepare().expect_err("open brace should fail"); - assert!(err.to_string().contains("unmatched"), "error: {err}"); - } - - #[test] - fn recording_template_empty_is_rejected() { - let mut recording = make_recording_config(); - recording.filename_template = Some(String::new()); - let mut video = make_recording_video_config(recording); - let err = video.prepare().expect_err("empty template should fail"); - assert!(err.to_string().contains("empty"), "error: {err}"); - } - - #[test] - fn recording_template_without_any_placeholder_is_rejected() { - let mut recording = make_recording_config(); - recording.filename_template = Some("static_name".to_string()); - let mut video = make_recording_video_config(recording); - let err = video.prepare().expect_err("static template should fail"); - assert!(err.to_string().contains("at least one placeholder"), "error: {err}"); - } - - #[test] - fn recording_invalid_timezone_is_rejected() { - let mut recording = make_recording_config(); - recording.timezone = Some("Not/A_Real_Zone".to_string()); - let mut video = make_recording_video_config(recording); - let err = video.prepare().expect_err("invalid timezone should fail"); - assert!(err.to_string().contains("IANA timezone"), "error: {err}"); - } - - #[test] - fn recording_discards_unmatched_quote_brace() { - let mut recording = make_recording_config(); - recording.filename_template = Some("channel}_{start_time}".to_string()); - let mut video = make_recording_video_config(recording); - let err = video.prepare().expect_err("unmatched '}' should fail"); - assert!(err.to_string().contains("unmatched"), "error: {err}"); - } - - #[test] - fn recording_default_pre_roll_above_max_is_rejected() { - let mut recording = make_recording_config(); - recording.default_pre_roll_secs = Some(1200); - recording.max_pre_roll_secs = 600; - let mut video = make_recording_video_config(recording); - let err = video.prepare().expect_err("default > max should fail"); - assert!(err.to_string().contains("default_pre_roll_secs"), "error: {err}"); - } - - #[test] - fn recording_default_post_roll_above_max_is_rejected() { - let mut recording = make_recording_config(); - recording.default_post_roll_secs = Some(2400); - recording.max_post_roll_secs = 1800; - let mut video = make_recording_video_config(recording); - let err = video.prepare().expect_err("default > max should fail"); - assert!(err.to_string().contains("default_post_roll_secs"), "error: {err}"); - } - - #[test] - fn recording_retention_zero_keep_last_is_rejected() { - let mut recording = make_recording_config(); - recording.retention = Some(RecordingRetentionConfigDto { - keep_last_per_channel: Some(0), - ..RecordingRetentionConfigDto::default() - }); - let mut video = make_recording_video_config(recording); - let err = video.prepare().expect_err("zero keep_last should fail"); - assert!(err.to_string().contains("keep_last_per_channel"), "error: {err}"); - } - - #[test] - fn recording_retention_zero_delete_after_days_is_rejected() { - let mut recording = make_recording_config(); - recording.retention = - Some(RecordingRetentionConfigDto { delete_after_days: Some(0), ..RecordingRetentionConfigDto::default() }); - let mut video = make_recording_video_config(recording); - let err = video.prepare().expect_err("zero delete_after_days should fail"); - assert!(err.to_string().contains("delete_after_days"), "error: {err}"); - } - - #[test] - fn recording_disk_high_water_above_100_is_rejected() { - let mut recording = make_recording_config(); - recording.disk = Some(RecordingDiskConfigDto { - high_water_percent: Some(101), - low_water_percent: None, - cleanup_interval_secs: Some(3600), - safety_bytes: Some(1024), - }); - let mut video = make_recording_video_config(recording); - let err = video.prepare().expect_err("high > 100 should fail"); - assert!(err.to_string().contains("high_water_percent"), "error: {err}"); - } - - #[test] - fn recording_disk_low_water_above_100_is_rejected() { - let mut recording = make_recording_config(); - recording.disk = Some(RecordingDiskConfigDto { - high_water_percent: None, - low_water_percent: Some(101), - cleanup_interval_secs: Some(3600), - safety_bytes: Some(1024), - }); - let mut video = make_recording_video_config(recording); - let err = video.prepare().expect_err("low > 100 should fail"); - assert!(err.to_string().contains("low_water_percent"), "error: {err}"); - } - - #[test] - fn recording_disk_low_ge_high_is_rejected() { - let mut recording = make_recording_config(); - recording.disk = Some(RecordingDiskConfigDto { - high_water_percent: Some(80), - low_water_percent: Some(80), - cleanup_interval_secs: Some(3600), - safety_bytes: Some(1024), - }); - let mut video = make_recording_video_config(recording); - let err = video.prepare().expect_err("low >= high should fail"); - assert!(err.to_string().contains("must be <"), "error: {err}"); - } - - #[test] - fn recording_disk_low_lt_high_is_accepted() { - let mut recording = make_recording_config(); - recording.disk = Some(RecordingDiskConfigDto { - high_water_percent: Some(85), - low_water_percent: Some(75), - cleanup_interval_secs: Some(3600), - safety_bytes: Some(1024), - }); - let mut video = make_recording_video_config(recording); - video.prepare().expect("low < high should succeed"); - } - - #[test] - fn recording_disk_zero_cleanup_interval_is_rejected() { - let mut recording = make_recording_config(); - recording.disk = Some(RecordingDiskConfigDto { - high_water_percent: None, - low_water_percent: None, - cleanup_interval_secs: Some(0), - safety_bytes: None, - }); - let mut video = make_recording_video_config(recording); - let err = video.prepare().expect_err("zero cleanup_interval should fail"); - assert!(err.to_string().contains("cleanup_interval"), "error: {err}"); - } - - #[test] - fn recording_disk_zero_safety_bytes_is_rejected() { - let mut recording = make_recording_config(); - recording.disk = Some(RecordingDiskConfigDto { - high_water_percent: None, - low_water_percent: None, - cleanup_interval_secs: Some(3600), - safety_bytes: Some(0), - }); - let mut video = make_recording_video_config(recording); - let err = video.prepare().expect_err("zero safety_bytes should fail"); - assert!(err.to_string().contains("safety_bytes"), "error: {err}"); - } - - #[test] - fn recording_zero_fallback_bytes_per_minute_is_rejected() { - let mut recording = make_recording_config(); - recording.fallback_bytes_per_minute = 0; - let mut video = make_recording_video_config(recording); - let err = video.prepare().expect_err("zero fallback should fail"); - assert!(err.to_string().contains("fallback_bytes_per_minute"), "error: {err}"); - } - - #[test] - fn recording_with_per_user_byte_quota_loads() { - let mut recording = make_recording_config(); - recording.quota = Some(RecordingQuotaConfigDto { - default_private_bytes: Some(50 * 1024 * 1024 * 1024), - per_user_bytes: HashMap::from([("user-uuid-1".to_string(), 100 * 1024 * 1024 * 1024)]), - shared_bytes: Some(200 * 1024 * 1024 * 1024), - }); - let mut video = make_recording_video_config(recording); - video.prepare().expect("quota should be accepted"); - let recording = video.download.as_ref().unwrap().recording.as_ref().unwrap(); - assert_eq!( - recording.quota.as_ref().unwrap().per_user_bytes.get("user-uuid-1"), - Some(&(100 * 1024 * 1024 * 1024)) - ); - } - - #[test] - fn recording_empty_config_is_cleaned_away() { - let mut video = make_recording_video_config(make_recording_config()); - // prepare fills defaults, so a fresh empty config gets populated — then - // a clean() pass should drop everything back to absent only if the - // user explicitly emptied it. With defaults filled, recording is - // non-empty and stays. - video.prepare().expect("prepare should succeed"); - assert!(video.download.as_ref().unwrap().recording.is_some()); - video.clean(); - // After defaults filled, clean() treats the recording as non-empty. - assert!(video.download.as_ref().unwrap().recording.is_some()); - } - - #[test] - fn clean_preserves_explicit_empty_recording_and_removes_empty_nested_blocks() { - let mut video = make_recording_video_config(RecordingConfigDto { - retention: Some(RecordingRetentionConfigDto::default()), - disk: Some(RecordingDiskConfigDto::default()), - quota: Some(RecordingQuotaConfigDto::default()), - notifications: Some(RecordingNotificationConfigDto::default()), - ..RecordingConfigDto::default() - }); - - video.clean(); - - let recording = video.download.as_ref().and_then(|download| download.recording.as_ref()); - assert!(recording.is_some(), "explicit recording block should remain"); - let Some(recording) = recording else { return }; - assert!(recording.retention.is_none()); - assert!(recording.disk.is_none()); - assert!(recording.quota.is_none()); - assert!(recording.notifications.is_none()); - } - - #[test] - fn clean_preserves_each_non_empty_recording_sibling() { - type RecordingCase = (&'static str, RecordingConfigDto, fn(&RecordingConfigDto) -> bool); - let cases: [RecordingCase; 4] = [ - ( - "retention", - RecordingConfigDto { - retention: Some(RecordingRetentionConfigDto { - keep_last_per_channel: Some(1), - ..RecordingRetentionConfigDto::default() - }), - disk: Some(RecordingDiskConfigDto::default()), - quota: Some(RecordingQuotaConfigDto::default()), - notifications: Some(RecordingNotificationConfigDto::default()), - ..RecordingConfigDto::default() - }, - |recording| recording.retention.is_some(), - ), - ( - "disk", - RecordingConfigDto { - retention: Some(RecordingRetentionConfigDto::default()), - disk: Some(RecordingDiskConfigDto { - high_water_percent: Some(90), - ..RecordingDiskConfigDto::default() - }), - quota: Some(RecordingQuotaConfigDto::default()), - notifications: Some(RecordingNotificationConfigDto::default()), - ..RecordingConfigDto::default() - }, - |recording| recording.disk.is_some(), - ), - ( - "quota", - RecordingConfigDto { - retention: Some(RecordingRetentionConfigDto::default()), - disk: Some(RecordingDiskConfigDto::default()), - quota: Some(RecordingQuotaConfigDto { - shared_bytes: Some(1024), - ..RecordingQuotaConfigDto::default() - }), - notifications: Some(RecordingNotificationConfigDto::default()), - ..RecordingConfigDto::default() - }, - |recording| recording.quota.is_some(), - ), - ( - "notifications", - RecordingConfigDto { - retention: Some(RecordingRetentionConfigDto::default()), - disk: Some(RecordingDiskConfigDto::default()), - quota: Some(RecordingQuotaConfigDto::default()), - notifications: Some(RecordingNotificationConfigDto { - max_attempts: default_recording_notification_max_attempts() + 1, - ..RecordingNotificationConfigDto::default() - }), - ..RecordingConfigDto::default() - }, - |recording| recording.notifications.is_some(), - ), - ]; - - for (name, recording, is_preserved) in cases { - let mut video = make_recording_video_config(recording); - video.clean(); - - let recording = video.download.as_ref().and_then(|download| download.recording.as_ref()); - assert!(recording.is_some(), "recording with {name} should remain"); - let Some(recording) = recording else { continue }; - assert!(is_preserved(recording), "{name} should remain"); - assert!(recording.retention.as_ref().is_none_or(|section| !section.is_empty())); - assert!(recording.disk.as_ref().is_none_or(|section| !section.is_empty())); - assert!(recording.quota.as_ref().is_none_or(|section| !section.is_empty())); - assert!(recording.notifications.as_ref().is_none_or(|section| !section.is_empty())); - } - } - - #[test] - fn recording_serializes_empty_config_to_no_field() { - let download = - VideoDownloadConfigDto { recording: Some(make_recording_config()), ..make_test_download_config() }; - let serialized = serde_json::to_string(&download).expect("serialize should succeed"); - // make_recording_config has all defaults filled, so no fields are - // serialized inside `recording` — but the parent block IS present. - assert!(serialized.contains("\"recording\""), "expected recording block, got: {serialized}"); - } - - #[test] - fn recording_serializes_omitted_field_when_absent() { - let download = VideoDownloadConfigDto { recording: None, ..make_test_download_config() }; - let serialized = serde_json::to_string(&download).expect("serialize should succeed"); - assert!(!serialized.contains("\"recording\""), "expected no recording field, got: {serialized}"); - } -} diff --git a/shared/src/utils/recording_filename.rs b/shared/src/utils/recording_filename.rs index 52c6ac959..5c91b0028 100644 --- a/shared/src/utils/recording_filename.rs +++ b/shared/src/utils/recording_filename.rs @@ -111,9 +111,7 @@ impl fmt::Display for RecordingFilenameError { impl std::error::Error for RecordingFilenameError {} /// Validate a recording filename template. Returns `Ok(())` when the -/// template is acceptable. Mirrors the validation in -/// `video_download.rs::prepare_recording_config` so the two cannot -/// diverge. +/// template is acceptable. pub fn validate_recording_template(template: &str) -> Result<(), RecordingFilenameError> { if template.is_empty() { return Err(RecordingFilenameError::Empty); diff --git a/shared/tests/fixtures/legacy_video_download.yml b/shared/tests/fixtures/legacy_video_download.yml deleted file mode 100644 index e5ecf21d3..000000000 --- a/shared/tests/fixtures/legacy_video_download.yml +++ /dev/null @@ -1,61 +0,0 @@ -# Pre-upgrade `tuliprox` configuration. This fixture is the canonical -# reference for the legacy `video.download.recording:` shape; the -# Phase 1 (T2) custom deserializer in `ConfigDto` must migrate it -# to the canonical top-level `recording:` field without losing any -# DVR settings. The literal text here is what an operator was -# running before the schema lift; do NOT regenerate it from the -# current Rust types. -# -# `serde_saphyr` parses it in `tests/recording_unification.rs`. - -api: - host: 127.0.0.1 - port: 8901 - web_root: ./web - -storage_dir: ./storage - -video: - extensions: - - .ts - - .mp4 - download: - headers: - Accept: video/* - User-Agent: legacy-agent - directory: /var/lib/tuliprox/downloads - organize_into_directories: true - episode_pattern: ".*" - download_priority: 9 - recording_priority: 7 - reserve_slots_for_users: 1 - max_background_per_provider: 2 - retry_backoff_initial_secs: 3 - retry_backoff_multiplier: 3.0 - retry_backoff_max_secs: 30 - retry_backoff_jitter_percent: 20 - retry_max_attempts: 5 - recording: - enabled: true - directory: /var/lib/tuliprox/recordings - timezone: Europe/Berlin - filename_template: "{channel}_{program_title}_{start_time}" - default_pre_roll_secs: 0 - max_pre_roll_secs: 900 - default_post_roll_secs: 0 - max_post_roll_secs: 1800 - fallback_bytes_per_minute: 8388608 - retention: - keep_last_per_channel: 5 - delete_after_days: 30 - sweep_interval_secs: 3600 - disk: - high_water_percent: 90 - low_water_percent: 75 - cleanup_interval_secs: 3600 - safety_bytes: 1073741824 - notifications: - outbox_buffer: 1024 - max_attempts: 6 - backoff_initial_secs: 5 - backoff_max_secs: 900 diff --git a/shared/tests/recording_unification.rs b/shared/tests/recording_unification.rs index 15a4ab41b..7a6bafde0 100644 --- a/shared/tests/recording_unification.rs +++ b/shared/tests/recording_unification.rs @@ -1,85 +1,157 @@ -//! Integration tests for the recording-unification schema lift. -//! The legacy fixture under `tests/fixtures/` is the literal pre-upgrade -//! configuration an operator was running; we parse it via `serde_saphyr`, -//! run `prepare()`, and assert that: -//! * the compat `video.download.recording` shadow is preserved -//! (frontend still reads it) -//! * the canonical top-level `recording:` field is populated with -//! the migrated DVR settings (no fields lost) -//! * canonical serialization re-emits only `recording:`, never the -//! legacy `video:` key -//! * `recording.directory` wins over the `/recordings` -//! default. +use shared::model::{ + ConfigDto, RecordingConfigDto, RecordingDiskConfigDto, RecordingNotificationConfigDto, RecordingQuotaConfigDto, + RecordingRetentionConfigDto, VideoConfigDto, +}; -use shared::model::ConfigDto; -use std::fs; - -const FIXTURE_PATH: &str = "tests/fixtures/legacy_video_download.yml"; +fn parse(yaml: &str) -> ConfigDto { + serde_saphyr::from_str(yaml).unwrap_or_else(|error| panic!("config must parse: {error}")) +} #[test] -fn legacy_fixture_loads_into_canonical_recording_without_losing_fields() { - let raw = fs::read_to_string(FIXTURE_PATH).expect("legacy fixture must exist"); - let mut cfg: ConfigDto = serde_saphyr::from_str(&raw).expect("legacy YAML must parse"); +fn video_round_trips_only_extensions_web_search_and_recording() { + let yaml = r#" +api: + host: 127.0.0.1 + port: 8901 + web_root: ./web +video: + extensions: [".ts", ".mp4"] + web_search: https://example.test/search + recording: + headers: {Accept: "video/*"} + directory: /var/lib/tuliprox/recordings + organize_into_directories: true + episode_pattern: ".*" + priority: 7 +"#; - // Compat shadow: legacy path still readable by frontend. - let legacy = cfg + let mut config = parse(yaml); + config.prepare(false).unwrap_or_else(|error| panic!("config must prepare: {error}")); + let video = config.video.as_ref().unwrap_or_else(|| panic!("video must exist")); + let recording = video.recording.as_ref().unwrap_or_else(|| panic!("recording must exist")); + + assert_eq!(video.extensions, [".ts", ".mp4"]); + assert_eq!(video.web_search.as_deref(), Some("https://example.test/search")); + assert_eq!(recording.directory.as_deref(), Some("/var/lib/tuliprox/recordings")); + assert_eq!(recording.headers.get("Accept").map(String::as_str), Some("video/*")); + assert!(recording.organize_into_directories); + assert_eq!(recording.episode_pattern.as_deref(), Some(".*")); + assert_eq!(recording.priority, 7); + + let serialized = serde_saphyr::to_string(&config).unwrap_or_else(|error| panic!("config must serialize: {error}")); + assert!(serialized.contains("recording:")); + assert!(!serialized.contains("download:")); + assert!(!serialized.contains("download_priority")); + assert!(!serialized.contains("recording_priority")); + + let round_trip = parse(&serialized); + let recording = round_trip .video .as_ref() - .and_then(|v| v.download.as_ref()) - .and_then(|d| d.recording.as_ref()) - .expect("legacy video.download.recording must round-trip"); - assert!(legacy.enabled); - assert_eq!(legacy.directory.as_deref(), Some("/var/lib/tuliprox/recordings")); - assert_eq!(legacy.timezone.as_deref(), Some("Europe/Berlin")); - assert_eq!(legacy.filename_template.as_deref(), Some("{channel}_{program_title}_{start_time}")); - assert_eq!(legacy.default_pre_roll_secs, Some(0)); - assert_eq!(legacy.max_pre_roll_secs, 900); - assert_eq!(legacy.default_post_roll_secs, Some(0)); - assert_eq!(legacy.max_post_roll_secs, 1800); - assert!(legacy.retention.is_some()); - assert!(legacy.disk.is_some()); - assert!(legacy.notifications.is_some()); - - cfg.prepare(false).expect("prepare should succeed"); - - // Canonical top-level field carries the migrated values. - let canonical = cfg.recording.as_ref().expect("canonical recording must be populated"); - assert!(canonical.enabled); - assert_eq!(canonical.directory.as_deref(), Some("/var/lib/tuliprox/recordings")); - assert_eq!(canonical.timezone.as_deref(), Some("Europe/Berlin")); - assert_eq!(canonical.filename_template.as_deref(), Some("{channel}_{program_title}_{start_time}")); - assert_eq!(canonical.default_pre_roll_secs, Some(0)); - assert_eq!(canonical.max_pre_roll_secs, 900); - assert_eq!(canonical.default_post_roll_secs, Some(0)); - assert_eq!(canonical.max_post_roll_secs, 1800); - assert!(canonical.retention.is_some()); - assert!(canonical.disk.is_some()); - assert!(canonical.notifications.is_some()); - assert_eq!(canonical.headers.get("Accept").map(String::as_str), Some("video/*")); - assert_eq!(canonical.headers.get("User-Agent").map(String::as_str), Some("legacy-agent")); - assert_eq!(canonical.extensions, [".ts", ".mp4"]); - assert!(canonical.organize_into_directories); - assert_eq!(canonical.episode_pattern.as_deref(), Some(".*")); - assert_eq!(canonical.priority, 7); - assert_eq!(canonical.reserve_slots_for_users, 1); - assert_eq!(canonical.max_background_per_provider, 2); - assert_eq!(canonical.retry_backoff_initial_secs, 3); - assert_eq!(canonical.retry_backoff_multiplier, 3.0); - assert_eq!(canonical.retry_backoff_max_secs, 30); - assert_eq!(canonical.retry_backoff_jitter_percent, 20); - assert_eq!(canonical.retry_max_attempts, 5); - - // Canonical serialization never re-emits the compat shadow. - let serialized = serde_json::to_string(&cfg).expect("serialize"); - assert!(!serialized.contains("\"video\""), "video compat shadow must not be re-emitted, got: {serialized}"); - assert!(serialized.contains("\"recording\""), "recording must be present, got: {serialized}"); - assert!(serialized.contains("\"headers\""), "legacy headers must migrate: {serialized}"); - assert!(serialized.contains("legacy-agent"), "legacy header values must migrate: {serialized}"); - assert!(serialized.contains("\"extensions\""), "legacy extensions must migrate: {serialized}"); - assert!(serialized.contains("\"organize_into_directories\":true"), "organize flag must migrate: {serialized}"); - assert!(serialized.contains("\"episode_pattern\":\".*\""), "episode pattern must migrate: {serialized}"); - assert!(serialized.contains("\"priority\":7"), "recording priority must migrate: {serialized}"); - assert!(!serialized.contains("download_priority"), "obsolete download priority must be discarded: {serialized}"); - assert!(serialized.contains("\"reserve_slots_for_users\":1"), "queue policy must migrate: {serialized}"); - assert!(serialized.contains("\"max_background_per_provider\":2"), "provider limit must migrate: {serialized}"); + .and_then(|video| video.recording.as_ref()) + .unwrap_or_else(|| panic!("recording must survive serialization")); + assert_eq!(recording.headers.get("Accept").map(String::as_str), Some("video/*")); + assert_eq!(recording.directory.as_deref(), Some("/var/lib/tuliprox/recordings")); + assert_eq!(recording.priority, 7); +} + +#[test] +fn recording_round_trips_transfer_and_dvr_fields() { + let recording = RecordingConfigDto { + headers: [("X-Upstream-Key".to_string(), "secret".to_string())].into(), + organize_into_directories: true, + episode_pattern: Some("(?PS\\d+E\\d+)".to_string()), + priority: -2, + reserve_slots_for_users: 1, + max_background_per_provider: 2, + retry_backoff_initial_secs: 4, + retry_backoff_multiplier: 2.0, + retry_backoff_max_secs: 60, + retry_backoff_jitter_percent: 10, + retry_max_attempts: 6, + directory: Some("/srv/recordings".to_string()), + default_pre_roll_secs: Some(30), + max_pre_roll_secs: 60, + default_post_roll_secs: Some(45), + max_post_roll_secs: 90, + retention: Some(RecordingRetentionConfigDto { + keep_last_per_channel: Some(5), + delete_after_days: Some(7), + sweep_interval_secs: 120, + }), + disk: Some(RecordingDiskConfigDto { + high_water_percent: Some(90), + low_water_percent: Some(70), + cleanup_interval_secs: Some(60), + safety_bytes: Some(1024), + }), + quota: Some(RecordingQuotaConfigDto { + default_private_bytes: Some(100), + per_user_bytes: [("user".to_string(), 200)].into(), + shared_bytes: Some(300), + }), + notifications: Some(RecordingNotificationConfigDto { + outbox_buffer: 8, + max_attempts: 3, + backoff_initial_secs: 2, + backoff_max_secs: 20, + }), + ..RecordingConfigDto::default() + }; + let mut config = ConfigDto { + video: Some(VideoConfigDto { + extensions: vec!["ts".to_string()], + web_search: None, + recording: Some(recording), + }), + ..ConfigDto::default() + }; + config.prepare(false).unwrap_or_else(|error| panic!("config must prepare: {error}")); + + let serialized = serde_saphyr::to_string(&config).unwrap_or_else(|error| panic!("config must serialize: {error}")); + let round_trip = parse(&serialized); + assert_eq!(round_trip.video, config.video); +} + +#[test] +fn recording_rejects_invalid_padding_retention_disk_and_notifications() { + let invalid = [ + RecordingConfigDto { default_pre_roll_secs: Some(2), max_pre_roll_secs: 1, ..RecordingConfigDto::default() }, + RecordingConfigDto { + retention: Some(RecordingRetentionConfigDto { keep_last_per_channel: Some(0), ..Default::default() }), + ..RecordingConfigDto::default() + }, + RecordingConfigDto { + disk: Some(RecordingDiskConfigDto { + high_water_percent: Some(70), + low_water_percent: Some(70), + ..Default::default() + }), + ..RecordingConfigDto::default() + }, + RecordingConfigDto { + notifications: Some(RecordingNotificationConfigDto { outbox_buffer: 0, ..Default::default() }), + ..RecordingConfigDto::default() + }, + ]; + + for recording in invalid { + let mut config = ConfigDto { + video: Some(VideoConfigDto { extensions: Vec::new(), web_search: None, recording: Some(recording) }), + ..ConfigDto::default() + }; + assert!(config.prepare(false).is_err()); + } +} + +#[test] +fn removed_recording_shapes_and_priority_aliases_are_rejected() { + for yaml in [ + "api: { host: 127.0.0.1, port: 8901, web_root: ./web }\nvideo: { download: {} }", + "api: { host: 127.0.0.1, port: 8901, web_root: ./web }\nrecording: {}", + "api: { host: 127.0.0.1, port: 8901, web_root: ./web }\nvideo: { recording: { download_priority: 1 } }", + "api: { host: 127.0.0.1, port: 8901, web_root: ./web }\nvideo: { recording: { recording_priority: 1 } }", + ] { + assert!(serde_saphyr::from_str::(yaml).is_err(), "obsolete shape accepted: {yaml}"); + } }