DVR Feature

This commit is contained in:
euzu
2026-08-28 19:17:39 +02:00
parent 93a5c5f162
commit 3d42c7ab9e
35 changed files with 1349 additions and 2454 deletions
@@ -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(),
+8 -1
View File
@@ -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 {
+1 -1
View File
@@ -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;
}
+7 -4
View File
@@ -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"
);
}
}
+25 -65
View File
@@ -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"));
}
}
+2 -2
View File
@@ -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::*;
@@ -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,
+2 -2
View File
@@ -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.
+18 -12
View File
@@ -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);
+6 -6
View File
@@ -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
View File
@@ -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:
+6 -8
View File
@@ -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
View File
@@ -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;
+6 -11
View File
@@ -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(())
}
+4 -4
View File
@@ -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()) }
+3 -3
View File
@@ -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
View File
@@ -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}");
}
}
+2 -2
View File
@@ -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::*;
+708
View File
@@ -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
+1 -3
View File
@@ -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
View File
@@ -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
+150 -78
View File
@@ -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}");
}
}