mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-26 00:32:30 +01:00
Feature/maintenance (#916)
* Maintenance mode * fix(hls): release origin slot before next origin I/O
This commit is contained in:
@@ -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_";
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() })
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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(¨auts)).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
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user