mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-29 10:12:22 +01:00
DVR Feature
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -761,7 +761,7 @@ pub async fn start_server(app_config: Arc<AppConfig>, targets: Arc<ProcessTarget
|
||||
.await
|
||||
.map_err(|err| TuliproxError::Server(format!("Failed to bind to {host}:{port}, {err}")))?;
|
||||
|
||||
if let Some(download_cfg) = cfg.recording.as_ref() {
|
||||
if let Some(download_cfg) = cfg.recording() {
|
||||
resume_downloads_after_bind(&app_state, download_cfg).await;
|
||||
}
|
||||
|
||||
|
||||
@@ -245,7 +245,7 @@ fn start_services(app_state: &Arc<AppState>, 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RecordingConfig>,
|
||||
pub video: Option<VideoConfig>,
|
||||
pub metadata_update: Option<MetadataUpdateConfig>,
|
||||
pub schedules: Option<Vec<ScheduleConfig>>,
|
||||
pub log: Option<LogConfig>,
|
||||
@@ -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::<PathBuf>() }
|
||||
|
||||
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<RecordingConfig>` 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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
+29
-4
@@ -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<String, String>,
|
||||
pub extensions: Vec<String>,
|
||||
pub organize_into_directories: bool,
|
||||
pub episode_pattern: Option<Arc<Regex>>,
|
||||
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<String>,
|
||||
pub web_search: Option<String>,
|
||||
pub recording: Option<RecordingConfig>,
|
||||
}
|
||||
|
||||
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<u32>,
|
||||
@@ -128,7 +155,6 @@ impl From<&RecordingConfigDto> for RecordingConfig {
|
||||
.unwrap_or_else(|| "UTC".parse::<Tz>().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,
|
||||
@@ -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<String>,
|
||||
/// 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.
|
||||
|
||||
@@ -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<bool, ServiceError> {
|
||||
pub async fn remove_recording_task(
|
||||
&self,
|
||||
claims: &shared::model::Claims,
|
||||
uuid: &str,
|
||||
) -> Result<bool, ServiceError> {
|
||||
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())),
|
||||
|
||||
@@ -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<RecordingConfig> {
|
||||
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<RecordingConfig> {
|
||||
/// (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<shared::model::RecordingConfigDto>) -> 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<Config>` 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);
|
||||
|
||||
@@ -24,8 +24,7 @@ use tuliprox_core::{
|
||||
/// The default is wrapped in `Arc<Regex>` once via `LazyLock` so repeated
|
||||
/// `build_series_info` calls do not re-clone the regex.
|
||||
fn resolve_episode_pattern(cfg: &Config) -> Arc<Regex> {
|
||||
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<F: FnMut(PlaylistItem)>(cfg: &Config, input: &ConfigInp
|
||||
let mut default_catchup_correction: Option<Arc<str>> = 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",);
|
||||
|
||||
+31
-50
@@ -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<episode>[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:
|
||||
|
||||
@@ -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<episode>...)` 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:
|
||||
|
||||
+20
-22
@@ -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: <download-dir>/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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -128,7 +128,12 @@ pub fn update_config(config: &mut ConfigDto, forms: Vec<ConfigForm>) {
|
||||
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<ConfigDto, _> = 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<ConfigDto, _> = 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 };
|
||||
|
||||
@@ -19,6 +19,7 @@ generate_form_reducer!(
|
||||
action_name: RecordingConfigFormAction,
|
||||
fields {
|
||||
Enabled => enabled: bool,
|
||||
Priority => priority: i8,
|
||||
ContainerFormat => container_format: RecordingContainerFormat,
|
||||
Directory => directory: Option<String>,
|
||||
Timezone => timezone: Option<String>,
|
||||
@@ -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<RecordingContainerFormat>
|
||||
#[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<i8>,
|
||||
pub on_error: Callback<String>,
|
||||
}
|
||||
|
||||
@@ -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! { <div class="tp__form-field tp__form-field__bool"><ToggleSwitch value={state.form.enabled} readonly={false} on_change={Callback::from(move |value| state.dispatch(RecordingConfigFormAction::Enabled(value)))} /><FieldLabel label={label} field_id={field.id} /></div> }
|
||||
}
|
||||
"recording_priority" => {
|
||||
let callback = props.on_recording_priority_change.clone();
|
||||
html! { <crate::app::components::number_input::NumberInput name={field.id} label={Some(label)} value={Some(i64::from(props.recording_priority))} min_i64={Some(i64::from(i8::MIN))} max_i64={Some(i64::from(i8::MAX))} on_change={Callback::from(move |value: Option<i64>| { if let Some(value) = value.and_then(|value| i8::try_from(value).ok()) { callback.emit(value); } })} /> }
|
||||
"priority" => {
|
||||
let state = direct_state.clone();
|
||||
html! { <crate::app::components::number_input::NumberInput name={field.id} label={Some(label)} value={Some(i64::from(state.form.priority))} min_i64={Some(i64::from(i8::MIN))} max_i64={Some(i64::from(i8::MAX))} on_change={Callback::from(move |value: Option<i64>| { 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::<std::collections::HashSet<_>>();
|
||||
let expected = std::collections::HashSet::from([
|
||||
"enabled",
|
||||
"recording_priority",
|
||||
"priority",
|
||||
"container_format",
|
||||
"directory",
|
||||
"timezone",
|
||||
@@ -984,11 +983,9 @@ mod browser_tests {
|
||||
<DialogProvider>
|
||||
<RecordingConfigCards
|
||||
recording={props.recording.clone()}
|
||||
recording_priority={props.priority}
|
||||
reload_generation={props.generation}
|
||||
edit_mode={props.edit_mode}
|
||||
on_change={Callback::from(move |value| emissions.borrow_mut().push(value))}
|
||||
on_recording_priority_change={Callback::noop()}
|
||||
on_error={Callback::noop()}
|
||||
/>
|
||||
</DialogProvider>
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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<String>,
|
||||
OrganizeIntoDirectories => organize_into_directories: bool,
|
||||
Directory => directory: Option<String>,
|
||||
EpisodePattern => episode_pattern: Option<String>,
|
||||
Headers => headers: HashMap<String, String>,
|
||||
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<String>,
|
||||
}
|
||||
);
|
||||
|
||||
#[component]
|
||||
pub fn RecordingConfigView() -> Html {
|
||||
let translate = use_translation();
|
||||
@@ -63,16 +69,23 @@ pub fn RecordingConfigView() -> Html {
|
||||
let config_view_ctx = use_context::<ConfigViewContext>().expect("ConfigViewContext not found");
|
||||
let state: UseReducerHandle<RecordingConfigFormState> =
|
||||
use_default_form_reducer!(RecordingConfigFormState { form: RecordingConfigDto::default() });
|
||||
let video_state: UseReducerHandle<VideoConfigFormState> =
|
||||
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! {
|
||||
<>
|
||||
<Card>
|
||||
{ config_field_child!(translate.t(LABEL_EXTENSIONS), "RECORDING_CONFIG.EXTENSIONS", {
|
||||
html! { <div class="tp__config-view__tags">{ for state.form.extensions.iter().map(|extension| html! { <Chip label={extension.clone()} /> }) }</div> }
|
||||
html! { <div class="tp__config-view__tags">{ for video_state.form.extensions.iter().map(|extension| html! { <span>{extension}</span> }) }</div> }
|
||||
}) }
|
||||
</Card>
|
||||
<Card class="tp__config-view__card">
|
||||
@@ -128,7 +139,7 @@ pub fn RecordingConfigView() -> Html {
|
||||
|
||||
let transfer_edit = html! {
|
||||
<>
|
||||
<Card>{ edit_field_list!(state, translate.t(LABEL_EXTENSIONS), extensions, RecordingConfigFormAction::Extensions, translate.t(LABEL_ADD_EXTENSION)) }</Card>
|
||||
<Card>{ edit_field_list!(video_state, translate.t(LABEL_EXTENSIONS), extensions, VideoConfigFormAction::Extensions, translate.t(LABEL_ADD_EXTENSION)) }</Card>
|
||||
<Card class="tp__config-view__card">
|
||||
{ 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 } }
|
||||
<RecordingConfigCards
|
||||
recording={state.form.clone()}
|
||||
recording_priority={state.form.priority}
|
||||
reload_generation={*reload_generation}
|
||||
edit_mode={*config_view_ctx.edit_mode}
|
||||
on_change={handle_recording}
|
||||
on_recording_priority_change={handle_priority}
|
||||
on_error={{ let toastr = services.toastr.clone(); Callback::from(move |message| toastr.error(message)) }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<FileDownloadDto>],
|
||||
queue: &[TransferTaskDto],
|
||||
finished: &[TransferTaskDto],
|
||||
active: &[Rc<TransferTaskDto>],
|
||||
) -> 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<Vec<FileDownloadDto>>,
|
||||
finished: &Rc<Vec<FileDownloadDto>>,
|
||||
active: &Rc<Vec<Rc<FileDownloadDto>>>,
|
||||
) -> Vec<Rc<FileDownloadDto>> {
|
||||
queue: &Rc<Vec<TransferTaskDto>>,
|
||||
finished: &Rc<Vec<TransferTaskDto>>,
|
||||
active: &Rc<Vec<Rc<TransferTaskDto>>>,
|
||||
) -> Vec<Rc<TransferTaskDto>> {
|
||||
match tab {
|
||||
DownloadTab::Queue => {
|
||||
let mut items: Vec<Rc<FileDownloadDto>> = active.iter().cloned().collect();
|
||||
let mut items: Vec<Rc<TransferTaskDto>> = 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<FileDownloadDto>], sort: Option<(usize, SortOrder)>) {
|
||||
fn sort_download_items(items: &mut [Rc<TransferTaskDto>], 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<FileDownloadDto>], sort: Option<(usize, S
|
||||
|
||||
fn collect_sorted_downloads_for_tab(
|
||||
tab: &DownloadTab,
|
||||
queue: &Rc<Vec<FileDownloadDto>>,
|
||||
finished: &Rc<Vec<FileDownloadDto>>,
|
||||
active: &Rc<Vec<Rc<FileDownloadDto>>>,
|
||||
queue: &Rc<Vec<TransferTaskDto>>,
|
||||
finished: &Rc<Vec<TransferTaskDto>>,
|
||||
active: &Rc<Vec<Rc<TransferTaskDto>>>,
|
||||
sort: Option<(usize, SortOrder)>,
|
||||
) -> Vec<Rc<FileDownloadDto>> {
|
||||
) -> Vec<Rc<TransferTaskDto>> {
|
||||
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<Vec<Rc<FileDownloadDto>>>,
|
||||
active: &Rc<Vec<Rc<TransferTaskDto>>>,
|
||||
uuid: &str,
|
||||
status: TransferStatusDto,
|
||||
) -> Option<DownloadsDelta> {
|
||||
@@ -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<TransferTaskDto>` filtered by the session; the table
|
||||
/// still wants the three buckets the Downloads-WS used to ship.
|
||||
fn partition_recording_tasks(
|
||||
tasks: &[TransferTaskDto],
|
||||
) -> (Vec<TransferTaskDto>, Vec<TransferTaskDto>, Vec<Rc<TransferTaskDto>>) {
|
||||
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<Vec<TransferTaskDto>>,
|
||||
queue_state: &UseStateHandle<Rc<Vec<TransferTaskDto>>>,
|
||||
finished_state: &UseStateHandle<Rc<Vec<TransferTaskDto>>>,
|
||||
active_state: &UseStateHandle<Rc<Vec<Rc<TransferTaskDto>>>>,
|
||||
) {
|
||||
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<Vec<TransferTaskDto>>,
|
||||
queue_state: &UseStateHandle<Rc<Vec<TransferTaskDto>>>,
|
||||
finished_state: &UseStateHandle<Rc<Vec<TransferTaskDto>>>,
|
||||
active_state: &UseStateHandle<Rc<Vec<Rc<TransferTaskDto>>>>,
|
||||
) {
|
||||
// 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<shared::model::DownloadActionResponse, crate::error::Error>,
|
||||
success_message: &str,
|
||||
@@ -247,9 +300,9 @@ fn handle_download_action_result(
|
||||
|
||||
fn apply_download_snapshot(
|
||||
response: &DownloadsResponse,
|
||||
queue_state: &UseStateHandle<Rc<Vec<FileDownloadDto>>>,
|
||||
finished_state: &UseStateHandle<Rc<Vec<FileDownloadDto>>>,
|
||||
active_download: &UseStateHandle<Rc<Vec<Rc<FileDownloadDto>>>>,
|
||||
queue_state: &UseStateHandle<Rc<Vec<TransferTaskDto>>>,
|
||||
finished_state: &UseStateHandle<Rc<Vec<TransferTaskDto>>>,
|
||||
active_download: &UseStateHandle<Rc<Vec<Rc<TransferTaskDto>>>>,
|
||||
) {
|
||||
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<Rc<Vec<FileDownloadDto>>>,
|
||||
finished_state: &UseStateHandle<Rc<Vec<FileDownloadDto>>>,
|
||||
active_download: &UseStateHandle<Rc<Vec<Rc<FileDownloadDto>>>>,
|
||||
queue_state: &UseStateHandle<Rc<Vec<TransferTaskDto>>>,
|
||||
finished_state: &UseStateHandle<Rc<Vec<TransferTaskDto>>>,
|
||||
active_download: &UseStateHandle<Rc<Vec<Rc<TransferTaskDto>>>>,
|
||||
) {
|
||||
match delta {
|
||||
DownloadsDelta::SnapshotReset(response) => {
|
||||
apply_download_snapshot(response, queue_state, finished_state, active_download);
|
||||
}
|
||||
DownloadsDelta::ActivePatched(download) => {
|
||||
let mut active_items: Vec<Rc<FileDownloadDto>> = active_download.iter().cloned().collect();
|
||||
let mut active_items: Vec<Rc<TransferTaskDto>> = 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::<DialogService>().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::<FileDownloadDto>::new()));
|
||||
let finished_state = use_state(|| Rc::new(Vec::<FileDownloadDto>::new()));
|
||||
let active_download = use_state(|| Rc::new(Vec::<Rc<FileDownloadDto>>::new()));
|
||||
let table_items = use_state(|| None::<Rc<Vec<Rc<FileDownloadDto>>>>);
|
||||
let queue_state = use_state(|| Rc::new(Vec::<TransferTaskDto>::new()));
|
||||
let finished_state = use_state(|| Rc::new(Vec::<TransferTaskDto>::new()));
|
||||
let active_download = use_state(|| Rc::new(Vec::<Rc<TransferTaskDto>>::new()));
|
||||
let table_items = use_state(|| None::<Rc<Vec<Rc<TransferTaskDto>>>>);
|
||||
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<FileDownloadDto>), Html>::from(
|
||||
move |(_row, col, dto): (usize, usize, Rc<FileDownloadDto>)| match col {
|
||||
Callback::<(usize, usize, Rc<TransferTaskDto>), Html>::from(
|
||||
move |(_row, col, dto): (usize, usize, Rc<TransferTaskDto>)| 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::<FileDownloadDto> {
|
||||
let table_definition = Rc::new(TableDefinition::<TransferTaskDto> {
|
||||
items: (*table_items).clone(),
|
||||
num_cols: HEADERS.len(),
|
||||
is_sortable: Callback::from(is_sortable),
|
||||
@@ -615,7 +668,7 @@ pub fn recording_library_view() -> Html {
|
||||
</div>
|
||||
<div class="tp__downloads-list__body tp__list-list__body">
|
||||
if *initial_loaded {
|
||||
<Table::<FileDownloadDto> definition={table_definition} />
|
||||
<Table::<TransferTaskDto> definition={table_definition} />
|
||||
} else {
|
||||
<LoadingIndicator loading={true} />
|
||||
}
|
||||
@@ -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<Vec<Rc<FileDownloadDto>>> = Rc::new(Vec::new());
|
||||
let active: Rc<Vec<Rc<TransferTaskDto>>> = 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<Vec<Rc<FileDownloadDto>>> = Rc::new(Vec::new());
|
||||
let active: Rc<Vec<Rc<TransferTaskDto>>> = 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<Vec<Rc<FileDownloadDto>>>,
|
||||
active: &Rc<Vec<Rc<TransferTaskDto>>>,
|
||||
delta: &DownloadsDelta,
|
||||
) -> Rc<Vec<Rc<FileDownloadDto>>> {
|
||||
) -> Rc<Vec<Rc<TransferTaskDto>>> {
|
||||
match delta {
|
||||
DownloadsDelta::SnapshotReset(response) => Rc::new(response.active.iter().cloned().map(Rc::new).collect()),
|
||||
DownloadsDelta::ActivePatched(download) => {
|
||||
let mut active_items: Vec<Rc<FileDownloadDto>> = active.iter().cloned().collect();
|
||||
let mut active_items: Vec<Rc<TransferTaskDto>> = 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,
|
||||
|
||||
@@ -179,7 +179,7 @@ fn apply_setup_config_forms(config: &mut shared::model::ConfigDto, forms: Vec<Co
|
||||
ConfigForm::Api(_, api_cfg) => 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;
|
||||
|
||||
@@ -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::<serde_json::Value>(
|
||||
&format!("{}/remove", self.task_path(id)),
|
||||
None,
|
||||
Some(Encoding::Json),
|
||||
)
|
||||
.await
|
||||
.map_err(network)?;
|
||||
let _ =
|
||||
request_delete::<serde_json::Value>(&format!("{}/remove", self.task_path(id)), None, Some(Encoding::Json))
|
||||
.await
|
||||
.map_err(network)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String>) -> 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<String>) -> 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<String> { Some(DEFAULT_DOWNLOAD_DIR.to_string()) }
|
||||
pub fn default_recording_dir() -> Option<String> { Some(DEFAULT_RECORDING_DIR.to_string()) }
|
||||
|
||||
pub fn default_main_storage_dir() -> Option<String> { Some(DEFAULT_STORAGE_DIR.to_string()) }
|
||||
pub fn default_main_backup_dir() -> Option<String> { Some(DEFAULT_BACKUP_DIR.to_string()) }
|
||||
|
||||
@@ -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)
|
||||
|
||||
+10
-457
@@ -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<RecordingConfigDto>,
|
||||
/// 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<VideoConfigDto>,
|
||||
#[serde(default, skip_serializing_if = "is_none_or_empty_metadata_update")]
|
||||
pub metadata_update: Option<MetadataUpdateConfigDto>,
|
||||
@@ -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<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
#[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<RecordingConfigDto>,
|
||||
#[serde(default)]
|
||||
video: Option<VideoConfigDto>,
|
||||
#[serde(default)]
|
||||
metadata_update: Option<MetadataUpdateConfigDto>,
|
||||
@@ -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 `<download.directory>/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
|
||||
// `<download.directory>/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::<ConfigDto>(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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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<u64>) -> bool { value.is_none() }
|
||||
fn is_zero_u32_opt(value: &Option<u32>) -> bool { value.is_none() }
|
||||
fn is_zero_u8_opt(value: &Option<u8>) -> 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<String, String>,
|
||||
#[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<String>,
|
||||
#[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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub timezone: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub filename_template: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "is_zero_u64_opt")]
|
||||
pub default_pre_roll_secs: Option<u64>,
|
||||
#[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<u64>,
|
||||
#[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<RecordingRetentionConfigDto>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub disk: Option<RecordingDiskConfigDto>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub quota: Option<RecordingQuotaConfigDto>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub notifications: Option<RecordingNotificationConfigDto>,
|
||||
#[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<u32>,
|
||||
#[serde(default, skip_serializing_if = "is_zero_u32_opt")]
|
||||
pub delete_after_days: Option<u32>,
|
||||
/// 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<u8>,
|
||||
#[serde(default, skip_serializing_if = "is_zero_u8_opt")]
|
||||
pub low_water_percent: Option<u8>,
|
||||
#[serde(default, skip_serializing_if = "is_zero_u64_opt")]
|
||||
pub cleanup_interval_secs: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "is_zero_u64_opt")]
|
||||
pub safety_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
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<u64>,
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub per_user_bytes: HashMap<String, u64>,
|
||||
#[serde(default, skip_serializing_if = "is_zero_u64_opt")]
|
||||
pub shared_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
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::<chrono_tz::Tz>()
|
||||
.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<String>,
|
||||
#[serde(default, skip_serializing_if = "is_blank_optional_string")]
|
||||
pub web_search: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub recording: Option<RecordingConfigDto>,
|
||||
}
|
||||
|
||||
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::<chrono_tz::Tz>().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(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
-61
@@ -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
|
||||
@@ -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 `<download.directory>/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("(?P<episode>S\\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::<ConfigDto>(yaml).is_err(), "obsolete shape accepted: {yaml}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user