Feature/maintenance (#916)

* Maintenance mode
* fix(hls): release origin slot before next origin I/O
This commit is contained in:
euzu
2026-10-13 22:30:21 +02:00
committed by GitHub
parent 588b954eeb
commit 69040f022b
122 changed files with 3313 additions and 147 deletions
+1
View File
@@ -47,5 +47,6 @@ pub const LOW_PRIORITY_PREEMPTED: &str = "low_priority_preempted.ts";
pub const USER_ACCOUNT_EXPIRED: &str = "user_account_expired.ts";
pub const PANEL_API_PROVISIONING: &str = "panel_api_provisioning.ts";
pub const HLS_SESSION_OR_LEASE_EXPIRED: &str = "hls_session_or_lease_expired.ts";
pub const MAINTENANCE: &str = "maintenance.ts";
pub const PANEL_API_PROVISIONING_HLS_SEGMENT_COUNT: usize = 6;
pub const PANEL_API_PROVISIONING_HLS_SEGMENT_PREFIX: &str = "panel_api_provisioning_hls_";
+17 -2
View File
@@ -20,7 +20,9 @@ create_bitset!(
RecordingRead,
RecordingCreate,
RecordingManage,
RecordingDelete
RecordingDelete,
MaintenanceRead,
MaintenanceWrite
);
pub const PERM_ALL: PermissionSet = PermissionSet::ALL;
@@ -44,6 +46,8 @@ pub const PERMISSION_NAMES: &[(&str, Permission)] = &[
("recording.create", Permission::RecordingCreate),
("recording.manage", Permission::RecordingManage),
("recording.delete", Permission::RecordingDelete),
("maintenance.read", Permission::MaintenanceRead),
("maintenance.write", Permission::MaintenanceWrite),
];
pub fn permission_from_name(name: &str) -> Option<Permission> {
@@ -58,6 +62,15 @@ pub fn permission_to_name(perm: Permission) -> Option<&'static str> {
mod tests {
use super::*;
#[test]
fn maintenance_permissions_have_stable_names() {
assert_eq!(permission_from_name("maintenance.read"), Some(Permission::MaintenanceRead));
assert_eq!(permission_from_name("maintenance.write"), Some(Permission::MaintenanceWrite));
assert_eq!(permission_to_name(Permission::MaintenanceWrite), Some("maintenance.write"));
assert!(PERM_ALL.contains(Permission::MaintenanceRead));
assert!(PERM_ALL.contains(Permission::MaintenanceWrite));
}
#[test]
fn test_permission_set_new_is_empty() {
let set = PermissionSet::new();
@@ -173,13 +186,15 @@ mod tests {
("recording.create", 1 << 15),
("recording.manage", 1 << 16),
("recording.delete", 1 << 17),
("maintenance.read", 1 << 18),
("maintenance.write", 1 << 19),
];
// The table is the layout of this schema version. Changing a bit means
// editing the table, and the version beside it has to move with it so
// tokens minted under the old layout fail closed.
assert_eq!(
crate::model::CURRENT_PERMISSION_SCHEMA_VERSION,
4,
5,
"the bit table changed: bump CURRENT_PERMISSION_SCHEMA_VERSION and this expected value together"
);
assert_eq!(PermissionSet::VARIANT_COUNT, expected.len());
+1 -1
View File
@@ -16,7 +16,7 @@ pub const TOKEN_NO_AUTH: &str = "authorized";
///
/// The layout this version stands for is pinned in the permission bit table
/// test, next to the expected value of this constant.
pub const CURRENT_PERMISSION_SCHEMA_VERSION: u16 = 4;
pub const CURRENT_PERMISSION_SCHEMA_VERSION: u16 = 5;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Claims {
@@ -17,6 +17,7 @@ pub enum CustomVideoStreamType {
UserAccountExpired,
Provisioning,
HlsSessionOrLeaseExpired,
Maintenance,
}
impl fmt::Display for CustomVideoStreamType {
@@ -29,6 +30,7 @@ impl fmt::Display for CustomVideoStreamType {
CustomVideoStreamType::UserAccountExpired => "user_account_expired",
CustomVideoStreamType::Provisioning => "provisioning",
CustomVideoStreamType::HlsSessionOrLeaseExpired => "hls_session_or_lease_expired",
CustomVideoStreamType::Maintenance => "maintenance",
};
write!(f, "{s}")
}
@@ -46,6 +48,7 @@ impl FromStr for CustomVideoStreamType {
"user_account_expired" => Ok(Self::UserAccountExpired),
"provisioning" => Ok(Self::Provisioning),
"hls_session_or_lease_expired" => Ok(Self::HlsSessionOrLeaseExpired),
"maintenance" => Ok(Self::Maintenance),
_ => Err(TuliproxError::Config(format!("Unknown stream type: {s}"))),
}
}
@@ -68,3 +71,16 @@ impl<'de> Deserialize<'de> for CustomVideoStreamType {
Self::from_str(&s).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::CustomVideoStreamType;
use std::str::FromStr;
#[test]
fn maintenance_round_trips() {
let parsed = CustomVideoStreamType::from_str("maintenance").unwrap();
assert_eq!(parsed, CustomVideoStreamType::Maintenance);
assert_eq!(parsed.to_string(), "maintenance");
}
}
+6 -1
View File
@@ -60,6 +60,8 @@ pub enum EventKind {
/// Payload-free progress nudge, appended last so no existing kind's bit
/// position shifts.
RecordingProgress,
/// Appended last for the same reason.
MaintenanceChanged,
}
impl EventKind {
@@ -67,7 +69,7 @@ impl EventKind {
///
/// The mask type below indexes into this, so the order is load-bearing:
/// it is the bit order, not just a listing.
pub const ALL: [Self; 42] = [
pub const ALL: [Self; 43] = [
Self::ServerError,
Self::ServerStarted,
Self::ServerShutdown,
@@ -110,6 +112,7 @@ impl EventKind {
Self::AuthSignInThrottled,
Self::AuthPermissionDenied,
Self::RecordingProgress,
Self::MaintenanceChanged,
];
/// This kind's bit position.
@@ -175,6 +178,7 @@ impl EventKind {
| Self::StreamProbeFailed
| Self::NotificationDeadLettered
| Self::ScheduledTaskFailed => Permission::SystemRead,
Self::MaintenanceChanged => Permission::MaintenanceRead,
}
}
@@ -273,6 +277,7 @@ impl EventKind {
Self::AuthSignInFailed => "auth.sign_in.failed",
Self::AuthSignInThrottled => "auth.sign_in.throttled",
Self::AuthPermissionDenied => "auth.permission.denied",
Self::MaintenanceChanged => "maintenance.changed",
}
}
+6 -1
View File
@@ -4,7 +4,7 @@ use crate::model::{
notification::{registry, EventId, Severity},
stats::SourceStats,
ActiveUserConnectionChange, ConfigReloadFailure, ConfigType, ConnectionDenied, DiskAlert, LibraryScanProgressEvent,
LibraryScanSummaryStatus, MetadataUpdateFailure, MsgKind, NotificationDeadLetter, Permission,
LibraryScanSummaryStatus, MaintenanceEntries, MetadataUpdateFailure, MsgKind, NotificationDeadLetter, Permission,
PlaylistUpdateProgressEvent, PlaylistUpdateRunId, PlaylistUpdateRunOrder, PlaylistUpdateState,
ProviderAccountEvent, ProviderAccountState, ProviderFetchFailure, ProviderPoolExhausted, ProviderPriorityFallback,
RecordingLifecycleMessage, ScheduledTaskFailure, ServerLifecycleEvent, ServerLifecycleState, StreamProbeFailure,
@@ -34,6 +34,8 @@ pub enum EventMessage {
/// session can rate limit it without also delaying a state transition.
RecordingProgress,
RecordingRulesChanged,
/// Inputs in maintenance after a change; the whole map, it is small.
MaintenanceChanged(Arc<MaintenanceEntries>),
InputMetadataUpdatesCompleted(Arc<str>),
InputMetadataUpdatesStarted(Arc<str>),
/// A metadata update cycle ended with tasks it could not finish.
@@ -126,6 +128,7 @@ impl EventMessage {
Self::RecordingChanged => EventKind::RecordingChanged,
Self::RecordingProgress => EventKind::RecordingProgress,
Self::RecordingRulesChanged => EventKind::RecordingRulesChanged,
Self::MaintenanceChanged(_) => EventKind::MaintenanceChanged,
Self::InputMetadataUpdatesCompleted(_) => EventKind::InputMetadataUpdatesCompleted,
Self::InputMetadataUpdatesStarted(_) => EventKind::InputMetadataUpdatesStarted,
Self::InputMetadataUpdatesFailed(_) => EventKind::InputMetadataUpdatesFailed,
@@ -263,6 +266,7 @@ impl EventMessage {
Self::ActiveProvider(_, _) => registry::PROVIDER_CONNECTIONS_CHANGED,
Self::RecordingChanged => registry::RECORDING_QUEUE_CHANGED,
Self::RecordingRulesChanged => registry::RECORDING_RULES_CHANGED,
Self::MaintenanceChanged(_) => registry::MAINTENANCE_CHANGED,
Self::PlaylistUpdateProgress(_) | Self::SystemInfoUpdate(_) | Self::RecordingProgress => return None,
})
}
@@ -298,6 +302,7 @@ impl EventMessage {
Self::SystemInfoUpdate(info) => encode(info.as_ref()),
Self::LibraryScanProgress(progress) => encode(progress),
Self::RecordingChanged | Self::RecordingProgress | Self::RecordingRulesChanged => serde_json::Value::Null,
Self::MaintenanceChanged(entries) => encode(entries.as_ref()),
Self::InputMetadataUpdatesStarted(input) | Self::InputMetadataUpdatesCompleted(input) => {
serde_json::json!({ "input": input.as_ref() })
}
+9
View File
@@ -62,3 +62,12 @@ fn groups_changed_alias_resolves_for_kinds() {
// to the old name keep receiving group changes.
assert_eq!(EventKind::from_wire_name("playlist.groups.changed"), Some(EventKind::PlaylistWatchChanged));
}
#[test]
fn maintenance_changed_requires_maintenance_read() {
assert_eq!(
EventKind::MaintenanceChanged.required_permission(),
crate::model::permission::Permission::MaintenanceRead
);
assert_eq!(EventKind::MaintenanceChanged.as_wire_name(), "maintenance.changed");
}
+101
View File
@@ -0,0 +1,101 @@
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// Path segment that addresses every known input at once.
pub const MAINTENANCE_WILDCARD: &str = "*";
/// Upper bound for a maintenance remark, in characters.
pub const MAINTENANCE_REASON_MAX_CHARS: usize = 500;
/// Why and since when an input is in maintenance.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InputMaintenance {
/// Unix timestamp in seconds; kept when the remark is updated.
pub since: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
/// Inputs in maintenance, keyed by canonical input name.
pub type MaintenanceEntries = BTreeMap<String, InputMaintenance>;
/// What the maintenance API returns after every call.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct MaintenanceStateDto {
pub entries: MaintenanceEntries,
/// Canonical names of all configured inputs, for the selection in the dialog.
pub inputs: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct MaintenanceRequestDto {
#[serde(default)]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MaintenanceReasonTooLong;
impl std::fmt::Display for MaintenanceReasonTooLong {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "maintenance reason exceeds {MAINTENANCE_REASON_MAX_CHARS} characters")
}
}
impl std::error::Error for MaintenanceReasonTooLong {}
/// Trims the remark; an empty remark is no remark.
pub fn normalize_maintenance_reason(reason: Option<&str>) -> Result<Option<String>, MaintenanceReasonTooLong> {
let Some(reason) = reason.map(str::trim).filter(|reason| !reason.is_empty()) else {
return Ok(None);
};
if reason.chars().count() > MAINTENANCE_REASON_MAX_CHARS {
return Err(MaintenanceReasonTooLong);
}
Ok(Some(reason.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reason_is_trimmed_and_empty_becomes_none() {
assert_eq!(normalize_maintenance_reason(None), Ok(None));
assert_eq!(normalize_maintenance_reason(Some(" ")), Ok(None));
assert_eq!(normalize_maintenance_reason(Some(" down ")), Ok(Some("down".to_string())));
}
#[test]
fn reason_longer_than_limit_is_rejected() {
let ok = "x".repeat(MAINTENANCE_REASON_MAX_CHARS);
let too_long = "x".repeat(MAINTENANCE_REASON_MAX_CHARS + 1);
assert_eq!(normalize_maintenance_reason(Some(&ok)), Ok(Some(ok.clone())));
assert_eq!(normalize_maintenance_reason(Some(&too_long)), Err(MaintenanceReasonTooLong));
}
#[test]
fn reason_limit_counts_characters_not_bytes() {
let umlauts = "ä".repeat(MAINTENANCE_REASON_MAX_CHARS);
assert!(normalize_maintenance_reason(Some(&umlauts)).is_ok());
}
#[test]
fn state_dto_round_trips_as_json() {
let mut entries = MaintenanceEntries::new();
entries
.insert("provider_a".to_string(), InputMaintenance { since: 1_760_350_000, reason: Some("down".into()) });
let dto = MaintenanceStateDto { entries, inputs: vec!["provider_a".into(), "provider_b".into()] };
let json = serde_json::to_string(&dto).unwrap();
assert_eq!(
json,
r#"{"entries":{"provider_a":{"since":1760350000,"reason":"down"}},"inputs":["provider_a","provider_b"]}"#
);
assert_eq!(serde_json::from_str::<MaintenanceStateDto>(&json).unwrap(), dto);
}
#[test]
fn missing_reason_deserializes_as_none() {
let entry: InputMaintenance = serde_json::from_str(r#"{"since":1}"#).unwrap();
assert_eq!(entry.reason, None);
}
}
+11 -10
View File
@@ -18,6 +18,7 @@ mod ip_check;
mod item_field;
mod library_request;
mod log;
mod maintenance;
mod mapping;
mod media_properties;
mod messaging;
@@ -79,16 +80,16 @@ pub mod xtream_const;
pub use self::{
active_user_connection_change::*, auth::*, auth_audit::*, cluster_flags::*, config::*, connection_denied::*,
custom_video_stream_type::*, epg::*, epg_grid::*, epg_request::*, event::*, input_refresh_policy::*,
input_update_action::*, ip_check::*, item_field::*, library_request::*, log::*, mapping::*, media_properties::*,
messaging::*, metadata_update_failure::*, notification::*, notification_dead_letter::*, pagination::*, playlist::*,
playlist_categories::*, playlist_info_document::*, playlist_request::*, playlist_update_run::*,
playlist_update_status::*, processing_order::*, progress::*, provider_fetch_failure::*, provider_pool::*,
recording::*, recording_math::*, regex_cache::*, resource::*, scheduled_task_failure::*, search_fields::*,
search_request::*, server_lifecycle::*, short_epg::*, stalker::*, stalker_item::*, stats::*, status_check::*,
stream_history::*, stream_history_record::*, stream_info::*, stream_meter::*, stream_probe_failure::*,
stream_properties::*, strm_export_style::*, system_info::*, target_bouquet::*, target_type::*, transfer::*,
ui_playlist_item::*, user_command::*, user_id::*, user_lifecycle::*, uuidtype::*, watch_health::*, web_socket::*,
xtream::*,
input_update_action::*, ip_check::*, item_field::*, library_request::*, log::*, maintenance::*, mapping::*,
media_properties::*, messaging::*, metadata_update_failure::*, notification::*, notification_dead_letter::*,
pagination::*, playlist::*, playlist_categories::*, playlist_info_document::*, playlist_request::*,
playlist_update_run::*, playlist_update_status::*, processing_order::*, progress::*, provider_fetch_failure::*,
provider_pool::*, recording::*, recording_math::*, regex_cache::*, resource::*, scheduled_task_failure::*,
search_fields::*, search_request::*, server_lifecycle::*, short_epg::*, stalker::*, stalker_item::*, stats::*,
status_check::*, stream_history::*, stream_history_record::*, stream_info::*, stream_meter::*,
stream_probe_failure::*, stream_properties::*, strm_export_style::*, system_info::*, target_bouquet::*,
target_type::*, transfer::*, ui_playlist_item::*, user_command::*, user_id::*, user_lifecycle::*, uuidtype::*,
watch_health::*, web_socket::*, xtream::*,
};
pub use ids::*;
pub use prepare::*;
@@ -71,6 +71,7 @@ pub const STREAM_PROBE_FAILED: EventId = EventId::new("stream.probe.failed");
// ---- dvr ------------------------------------------------------------
pub const RECORDING_QUEUE_CHANGED: EventId = EventId::new("recording.queue.changed");
pub const RECORDING_RULES_CHANGED: EventId = EventId::new("recording.rules.changed");
pub const MAINTENANCE_CHANGED: EventId = EventId::new("maintenance.changed");
// ---- scheduler -------------------------------------------------------
pub const SCHEDULED_TASK_FAILED: EventId = EventId::new("scheduled_task.failed");
@@ -257,6 +258,11 @@ pub const ALL: &[EventDescriptor] = &[
severity: Severity::Info,
description: "The recording rule set changed.",
},
EventDescriptor {
id: MAINTENANCE_CHANGED,
severity: Severity::Warn,
description: "An input was put into or taken out of maintenance mode.",
},
EventDescriptor {
id: SCHEDULED_TASK_FAILED,
severity: Severity::Error,
+6
View File
@@ -126,6 +126,11 @@ pub struct RecordingMetadata {
/// Persisted at-most-once notification markers.
#[serde(default)]
pub notification_markers: Vec<NotificationMarker>,
/// Set once the first provider connection was acquired. A later acquisition
/// (retry, requeue after preemption, restart) resumes the recording rather
/// than starting a new one. Internal; never projected into a DTO.
#[serde(default)]
pub provider_started: bool,
/// Terminal-only previous state while a deletion is in progress. `None`
/// outside the `Deleting` transitional state.
pub deleting_previous_state: Option<DeletionPreviousState>,
@@ -167,6 +172,7 @@ impl RecordingMetadata {
measured_bytes: 0,
completed_at: None,
notification_markers: Vec::new(),
provider_started: false,
group: None,
series_name: None,
deleting_previous_state: None,
@@ -87,6 +87,7 @@ pub enum ConnectFailureReason {
Preempted,
SessionExpired,
Provisioning,
Maintenance,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+3
View File
@@ -117,6 +117,9 @@ pub enum ProtocolMessage {
/// re-fetches `/api/v1/recording/rules` on receipt. No payload
/// — the rule list is small and the GET is cheap.
RecordingRulesChanged,
/// Inputs in maintenance after a change. Carries the whole map: it is small,
/// and a client never has to merge.
MaintenanceChangedResponse(crate::model::MaintenanceEntries),
/// The socket cannot serve recordings to this session, and the reason
/// is actionable.
///