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
+9
View File
@@ -4,6 +4,9 @@
## ⚠️ Breaking Changes
- **Web UI sessions must sign in again once.** The new maintenance permissions bump the permission schema, so tokens
issued before the update are rejected with `X-Token-Refresh: required` and the Web UI returns to the sign-in page.
- **`playlist.watch.changed` carries every watch change of one update.** The payload is now
`{ target, changes: [...] }`, where each change has a `kind` (`groups_added`, `groups_removed`, `channels_added`,
`channels_removed`). Templates using `{{watch.group}}`, `{{watch.added}}` or `{{watch.removed}}` must be updated:
@@ -183,6 +186,12 @@
## 🌟 New Features
- **Maintenance mode.** Inputs can be put into maintenance from the new dashboard card or through
`/api/v1/maintenance` (`*` addresses all inputs). New streams of an input in maintenance get the new
`maintenance.ts` custom video; playlist and EPG updates keep the last persisted state, and probes, metadata
resolution, Stalker refreshes and DVR starts do not contact the provider. Running streams and recordings continue.
The state is stored in `maintenance.json` and survives restarts. New permissions `maintenance.read` and
`maintenance.write`. See [Maintenance mode](docs/src/rest-api-cookbook.md#maintenance-mode).
- **Optional fast start for shared live HLS.** `reverse_proxy.hls_cache.startup.mode` selects `conservative`
(default, unchanged behavior), `first_ready` (admits playback after one processed start segment) or `progressive`
(streams the raw TS start segment while the shared origin fill is still running). Progressive replay memory,
+6 -1
View File
@@ -179,6 +179,9 @@ Upstream providers are not always reliable. Tuliprox is designed to preserve usa
- Independent inputs can update in parallel while optional sequential groups protect providers that must not be refreshed concurrently
- Automatic Xtream account-expiration refresh with throttling designed to reduce unnecessary provider requests
- Stalker capability knowledge is reused during a running client session to avoid repeatedly probing known-failing endpoint variants
- **Maintenance mode** takes an input out of service from the dashboard or the REST API: new streams get a
dedicated maintenance video, the last playlist and EPG keep being served, and no subsystem contacts the provider until
maintenance ends; running streams and recordings continue
**Benefit:** a broken refresh or failing alias does not have to destroy an otherwise usable catalog.
@@ -219,6 +222,8 @@ Tuliprox can improve incomplete provider data and make large catalogs easier to
Tuliprox can be configured and monitored directly from the browser.
- **Dashboard** with backend status, active streams, system metrics, and provider capacity
- **Maintenance card** on the dashboard that turns red while inputs are in maintenance, with a dialog to put inputs
into or take them out of maintenance with a remark
- **Source Editor** for M3U, Xtream, Stalker, Plex, Emby, Jellyfin, local-library, and target configuration
- Drag & drop, block selection, batch editing, and target bouquet whitelist/blacklist editing
- **Input-focused Update view** with per-input actions, affected-target selection, status, last update time, and
@@ -271,7 +276,7 @@ Expose one Tuliprox instance to multiple users without giving everyone identical
#### RBAC
- 14 permissions across 7 domains
- 20 permissions across 9 domains
- Independent `.read` and `.write` grants
- Custom groups and multi-group membership
- Additive permission resolution
@@ -0,0 +1,69 @@
//! Gate 1: a new playback request for an input in maintenance gets the
//! maintenance clip instead of a provider connection.
//!
//! Requests that continue something already admitted pass: the DVR's internal
//! proxy user (the DVR gate decided before it reserved the slot) and requests
//! carrying a slot reserved for them.
use super::admission_failure_response;
use crate::{
api::{
endpoints::hls_api::hls_admission_failure_manifest_response,
model::{AppState, UserSession},
},
auth::Fingerprint,
model::{ConfigInput, ProxyUserCredentials},
};
use axum::http::HeaderMap;
use shared::model::{ConnectFailureReason, StreamChannel};
use std::sync::Arc;
/// A session that is counted against the user's connections is a running playback; a request
/// for it continues that playback. A known but uncounted session (prepared, preserved, expired)
/// would be admitted anew.
pub(crate) fn is_running_playback(user_session: Option<&UserSession>) -> bool {
user_session.is_some_and(|session| session.lifecycle.is_counted())
}
/// The maintenance answer for a new playback, given before admission so it neither counts
/// against the user's connections nor evicts anything.
pub(crate) async fn maintenance_rejection_response(
app_state: &Arc<AppState>,
fingerprint: &Fingerprint,
user: &ProxyUserCredentials,
stream_channel: StreamChannel,
input: &ConfigInput,
req_headers: &HeaderMap,
is_hls: bool,
) -> axum::response::Response {
if is_hls {
return hls_admission_failure_manifest_response(
app_state,
fingerprint,
user,
stream_channel,
Arc::clone(&input.name),
req_headers,
ConnectFailureReason::Maintenance,
)
.await;
}
admission_failure_response(
app_state,
fingerprint,
user,
stream_channel,
Arc::clone(&input.name),
req_headers,
ConnectFailureReason::Maintenance,
)
}
pub(crate) fn blocks_new_playback(
app_state: &AppState,
input: &ConfigInput,
user: &ProxyUserCredentials,
has_preacquired_handle: bool,
) -> bool {
!has_preacquired_handle && !user.is_recording_proxy_user() && app_state.maintenance.is_in_maintenance(&input.name)
}
+2
View File
@@ -240,6 +240,7 @@ mod auth;
mod catchup;
mod clock;
mod local_files;
mod maintenance;
mod media_server;
mod metering;
mod provider_acquire;
@@ -324,6 +325,7 @@ pub(crate) use self::{
auth::{input_for_user, with_recording_headers},
catchup::get_catchup_session_ttl_secs,
local_files::local_stream_response,
maintenance::{blocks_new_playback, is_running_playback, maintenance_rejection_response},
provider_acquire::{acquire_exact_provider_handle, stream_response_with_provider_handle},
provider_urls::select_provider_stream_url,
resources::force_hls_resource_response,
@@ -1,7 +1,8 @@
use super::{
activate_session_before_stream_open, cleanup_failed_detected_catchup_hls, connection_priority_for_kind,
create_stream_response_details, detected_catchup_hls_response, get_session_reservation_ttl_secs,
get_stream_options, is_socket_bound_playback_session, is_stream_share_enabled, mark_response_as_uncompressed,
activate_session_before_stream_open, admission_failure_response, blocks_new_playback,
cleanup_failed_detected_catchup_hls, connection_priority_for_kind, create_stream_response_details,
detected_catchup_hls_response, get_session_reservation_ttl_secs, get_stream_options,
is_socket_bound_playback_session, is_stream_share_enabled, mark_response_as_uncompressed,
no_custom_video_fallback_status, prepare_body_stream, prepare_stream_metering, probe_catchup_payload,
record_connect_failed_attempt, reentry_suppressed_response, resolve_request_url_for_logging,
resolve_xtream_vod_provider_url, select_provider_stream_url, should_pin_provider_for_session,
@@ -304,6 +305,17 @@ pub(crate) async fn stream_response_with_provider_handle(
grace_mode: Option<crate::api::model::GraceMode>,
preacquired_provider_handle: Option<tuliprox_session::ManagedProviderHandle>,
) -> impl IntoResponse + Send {
if blocks_new_playback(app_state, input, user, preacquired_provider_handle.is_some()) {
return admission_failure_response(
app_state,
fingerprint,
user,
stream_channel,
Arc::clone(&input.name),
req_headers,
ConnectFailureReason::Maintenance,
);
}
let _transition_guard = app_state.active_users.acquire_playback_transition(&user.username, session_token).await;
let request_log_stream_url = resolve_request_url_for_logging(input, stream_url);
if log_enabled!(log::Level::Trace) {
@@ -22,6 +22,7 @@ pub(super) fn admission_failure_video_type(reason: ConnectFailureReason) -> Opti
ConnectFailureReason::UserAccountExpired => Some(CustomVideoStreamType::UserAccountExpired),
ConnectFailureReason::UserConnectionsExhausted => Some(CustomVideoStreamType::UserConnectionsExhausted),
ConnectFailureReason::ProviderConnectionsExhausted => Some(CustomVideoStreamType::ProviderConnectionsExhausted),
ConnectFailureReason::Maintenance => Some(CustomVideoStreamType::Maintenance),
_ => None,
}
}
+11 -2
View File
@@ -1,4 +1,9 @@
use crate::{api::model::AppState, model::ConfigInput, processing::processor::re_resolve_stalker_url};
use super::blocks_new_playback;
use crate::{
api::model::AppState,
model::{ConfigInput, ProxyUserCredentials},
processing::processor::re_resolve_stalker_url,
};
use axum::http::StatusCode;
use shared::{
error::TuliproxError,
@@ -46,12 +51,16 @@ pub(super) async fn re_resolve_stalker_url_singleflight(
pub(crate) async fn resolve_initial_stalker_playback_url(
app_state: &Arc<AppState>,
input: &ConfigInput,
user: &ProxyUserCredentials,
provider_id: u32,
cluster: XtreamCluster,
item_type: PlaylistItemType,
stream_url: &Arc<str>,
) -> Result<Arc<str>, TuliproxError> {
if !needs_initial_stalker_resolution(input.input_type, stream_url) {
// An input in maintenance is not contacted; the maintenance gate further down answers.
if !needs_initial_stalker_resolution(input.input_type, stream_url)
|| blocks_new_playback(app_state, input, user, false)
{
return Ok(Arc::clone(stream_url));
}
re_resolve_stalker_url_singleflight(app_state, input, provider_id, stalker_stream_kind(cluster, item_type), false)
+7 -2
View File
@@ -1,6 +1,6 @@
use super::{
get_redirect_alternative_url, get_stream_alternative_url, is_media_server_playback_url, redirect,
resolve_request_url_for_logging, RedirectParams,
blocks_new_playback, get_redirect_alternative_url, get_stream_alternative_url, is_media_server_playback_url,
redirect, resolve_request_url_for_logging, RedirectParams,
};
#[cfg(test)]
use super::{resolve_streaming_strategy_with_provider_handle, StreamingAcquireOptions};
@@ -120,6 +120,11 @@ pub async fn redirect_response<'a, P>(
where
P: PlaylistEntry,
{
// An input in maintenance is never redirected to its provider; the proxied
// path that follows answers with the maintenance clip.
if blocks_new_playback(app_state, params.input, params.user, false) {
return None;
}
let item_type = params.item.get_item_type();
let provider_url = params.item.get_provider_url();
if is_media_server_playback_url(params.input, provider_url.as_ref()) {
+1
View File
@@ -7,6 +7,7 @@ mod http_load;
mod http_smoke;
mod http_soak;
mod local_files;
mod maintenance;
mod network_access;
mod provider_leases;
mod provider_urls;
@@ -1327,6 +1327,7 @@ async fn test_channel_unavailable_fallback_does_not_register_body_owner_or_leak_
user_account_expired: None,
panel_api_provisioning: None,
hls_session_or_lease_expired: None,
maintenance: None,
panel_api_provisioning_hls_segments: Vec::new(),
};
app_state.app_config.custom_stream_response.store(Some(std::sync::Arc::new(custom_video)));
@@ -0,0 +1,203 @@
use super::{
create_test_app_state_for_config, create_test_fingerprint, create_test_live_channel,
create_test_provider_app_config, create_test_provider_app_state, stream_response,
};
use crate::{
api::{api_utils::blocks_new_playback, model::AppState},
model::{ConfigInput, ConfigInputAlias, ProxyUserCredentials, SourcesConfig, RECORDING_PROXY_USERNAME},
};
use shared::utils::Internable;
use std::sync::Arc;
/// The body of the function starting with `signature`, up to its closing brace in column 0,
/// so an order check cannot match code of a neighbouring function.
fn function_body<'a>(source: &'a str, signature: &str) -> &'a str {
let start = source.find(signature).expect("function present");
let body = &source[start..];
&body[..body.find("\n}\n").expect("function end")]
}
fn viewer() -> ProxyUserCredentials {
let mut user = ProxyUserCredentials::default();
user.username = "viewer".to_string();
user
}
fn provider_state() -> (Arc<AppState>, Arc<ConfigInput>) {
let app_state = create_test_app_state_for_config(Arc::new(create_test_provider_app_config()));
let input = Arc::clone(&app_state.app_config.sources.load().inputs[0]);
(app_state, input)
}
#[tokio::test]
async fn input_not_in_maintenance_is_not_gated() {
let (app_state, input) = provider_state();
assert!(!blocks_new_playback(&app_state, &input, &viewer(), false));
}
#[tokio::test]
async fn input_in_maintenance_is_gated() {
let (app_state, input) = provider_state();
app_state.maintenance.add(&input.name, None, 1).await.unwrap();
assert!(blocks_new_playback(&app_state, &input, &viewer(), false));
}
#[tokio::test]
async fn preacquired_handle_is_not_gated() {
let (app_state, input) = provider_state();
app_state.maintenance.add(&input.name, None, 1).await.unwrap();
assert!(!blocks_new_playback(&app_state, &input, &viewer(), true));
}
#[tokio::test]
async fn recording_proxy_user_is_not_gated() {
let (app_state, input) = provider_state();
app_state.maintenance.add(&input.name, None, 1).await.unwrap();
let mut recorder = ProxyUserCredentials::default();
recorder.username = RECORDING_PROXY_USERNAME.to_string();
recorder.t_is_api_user = true;
assert!(recorder.is_recording_proxy_user());
assert!(!blocks_new_playback(&app_state, &input, &recorder, false));
}
#[tokio::test]
async fn aliased_input_is_gated_by_canonical_name() {
let (app_state, provider) = provider_state();
let input = Arc::new(ConfigInput {
aliases: Some(vec![ConfigInputAlias {
id: 2,
name: "provider_1_alias".intern(),
url: "http://127.0.0.1:2".to_string(),
username: Some("user-b".to_string()),
password: Some("pass-b".to_string()),
priority: 1,
max_connections: 3,
exp_date: None,
enabled: true,
stalker: None,
}]),
..(*provider).clone()
});
app_state
.app_config
.sources
.store(Arc::new(SourcesConfig { inputs: vec![Arc::clone(&input)], ..SourcesConfig::default() }));
app_state.maintenance.add(&input.name, None, 1).await.unwrap();
assert!(!app_state.maintenance.is_in_maintenance("provider_1_alias"), "aliases are never keys");
assert!(blocks_new_playback(&app_state, &input, &viewer(), false));
}
#[test]
fn gate_runs_before_shared_stream_join() {
// A new viewer must not join a running provider stream of an input in maintenance.
let body = function_body(
include_str!("../provider_acquire.rs"),
"pub(crate) async fn stream_response_with_provider_handle(",
);
let gate = body.find("if blocks_new_playback(").expect("gate present");
let join = body.find("try_shared_stream_response_if_any(").expect("shared join present");
assert!(gate < join, "maintenance gate must run before the shared stream join");
}
#[test]
fn redirect_defers_maintenance_inputs_to_the_gated_proxy_path() {
// Redirect mode would send the client straight to the provider; for an input in
// maintenance it must fall through to the proxied path, where gate 1 answers.
let body = function_body(include_str!("../strategy.rs"), "pub async fn redirect_response<");
let gate = body.find("if blocks_new_playback(").expect("gate present");
let redirect = body.find("Some(redirect(").expect("redirect response present");
assert!(gate < redirect, "maintenance must be checked before any redirect is built");
}
/// One MPEG-TS packet whose payload marks it as the maintenance clip.
fn maintenance_packet() -> Vec<u8> {
let mut packet = vec![0xff_u8; 188];
packet[0] = 0x47;
packet[1] = 0x01;
packet[2] = 0x00;
packet[3] = 0x10;
packet[4..9].copy_from_slice(b"MAINT");
packet
}
fn maintenance_clip_target() -> Arc<crate::model::ConfigTarget> {
Arc::new(crate::model::ConfigTarget {
curation: None,
id: 1,
enabled: true,
name: "test".to_string(),
options: None,
sort: None,
filter: shared::foundation::Filter::default().into(),
output: Vec::new(),
rename: None,
mapping_ids: None,
mapping: Arc::new(arc_swap::ArcSwapOption::default()),
favourites: None,
processing_order: shared::model::ProcessingOrder::default(),
execution_plan: tuliprox_core::model::TargetExecutionPlan::default(),
watch: None,
use_memory_cache: false,
})
}
#[tokio::test]
async fn new_ts_stream_for_input_in_maintenance_gets_the_clip_without_provider_slot() {
use axum::{http::HeaderMap, response::IntoResponse};
use http_body_util::BodyExt;
let app_state = create_test_provider_app_state();
let mut cfg = (**app_state.app_config.config.load()).clone();
cfg.custom_stream_response_enabled = true;
app_state.app_config.config.store(Arc::new(cfg));
app_state.app_config.custom_stream_response.store(Some(Arc::new(crate::model::CustomStreamResponse {
channel_unavailable: None,
user_connections_exhausted: None,
provider_connections_exhausted: None,
low_priority_preempted: None,
user_account_expired: None,
panel_api_provisioning: None,
hls_session_or_lease_expired: None,
maintenance: Some(crate::api::model::TransportStreamBuffer::new(maintenance_packet())),
panel_api_provisioning_hls_segments: Vec::new(),
})));
let input = provider_state_input(&app_state);
app_state.maintenance.add(&input.name, None, 1).await.unwrap();
// Unreachable: a provider connect attempt would fail instead of answering with the clip.
let stream_url = "http://127.0.0.1:1/live/user/pass/42.ts";
let response = stream_response(
&create_test_fingerprint("127.0.0.1:55201".parse().unwrap()),
&app_state,
"maintenance-session",
None,
create_test_live_channel(stream_url),
stream_url,
None,
&HeaderMap::default(),
&input,
&maintenance_clip_target(),
&viewer(),
shared::model::UserConnectionPermission::Allowed,
crate::api::model::ConnectionKind::Normal,
false,
None,
)
.await
.into_response();
assert_eq!(response.status(), axum::http::StatusCode::OK);
assert_eq!(app_state.active_provider.get_provider_connections_count(), 0, "no provider slot was taken");
let body = tokio::time::timeout(std::time::Duration::from_secs(2), async {
let mut body = response.into_body();
let frame = body.frame().await;
frame.and_then(Result::ok).and_then(|frame| frame.into_data().ok()).unwrap_or_default()
})
.await
.unwrap_or_default();
assert!(body.windows(5).any(|window| window == b"MAINT"), "the maintenance clip is served");
}
fn provider_state_input(app_state: &AppState) -> Arc<ConfigInput> {
Arc::clone(&app_state.app_config.sources.load().inputs[0])
}
@@ -130,3 +130,36 @@ async fn resolve_streaming_strategy_rejects_stalker_url_after_forced_provider_fa
app_state.active_provider.release_connection(&busy_addr);
app_state.active_provider.release_connection(&fallback_addr);
}
#[tokio::test]
async fn initial_stalker_resolution_is_skipped_for_input_in_maintenance() {
let app_config = create_test_provider_app_config();
let Some(configured_input) = app_config.sources.load().inputs.first().cloned() else { unreachable!() };
let mut stalker_input = (*configured_input).clone();
stalker_input.input_type = InputType::Stalker;
// Unreachable portal: a resolution attempt fails instead of answering.
stalker_input.url = "http://127.0.0.1:1".to_string();
app_config
.sources
.store(Arc::new(SourcesConfig { inputs: vec![Arc::new(stalker_input)], ..SourcesConfig::default() }));
let app_state = create_test_app_state_for_config(Arc::new(app_config));
let input = Arc::clone(&app_state.app_config.sources.load().inputs[0]);
app_state.maintenance.add(&input.name, None, 1).await.unwrap();
let mut viewer = crate::model::ProxyUserCredentials::default();
viewer.username = "viewer".to_string();
let empty: Arc<str> = "".into();
let resolved = super::resolve_initial_stalker_playback_url(
&app_state,
&input,
&viewer,
347,
XtreamCluster::Live,
PlaylistItemType::Live,
&empty,
)
.await;
// The portal is not contacted; the maintenance gate further down answers the request.
assert_eq!(resolved.ok().as_deref(), Some(""));
}
@@ -450,6 +450,7 @@ pub(in crate::api::api_utils::tests) fn create_test_app_state_for_config(app_cfg
recording_capacity: crate::api::model::recording_runtime::ProviderCapacityAdapter::new(
Arc::clone(&active_provider),
Arc::clone(&connection_manager),
crate::api::model::create_test_maintenance(""),
),
app_config: app_cfg,
http_clients: Arc::default(),
@@ -468,6 +469,7 @@ pub(in crate::api::api_utils::tests) fn create_test_app_state_for_config(app_cfg
metadata_manager,
auth: crate::api::model::AuthState::for_tests(),
playlist_updates: crate::api::model::PlaylistUpdateControl::for_tests(),
maintenance: crate::api::model::create_test_maintenance(""),
})
}
@@ -584,6 +584,7 @@ mod tests {
))
.to_vec(),
)),
maintenance: None,
panel_api_provisioning_hls_segments: Vec::new(),
}
}
@@ -717,6 +718,7 @@ mod tests {
recording_capacity: crate::api::model::recording_runtime::ProviderCapacityAdapter::new(
Arc::clone(&active_provider),
Arc::clone(&connection_manager),
crate::api::model::create_test_maintenance(""),
),
app_config: app_cfg,
http_clients: Arc::default(),
@@ -735,6 +737,7 @@ mod tests {
metadata_manager,
auth: crate::api::model::AuthState::for_tests(),
playlist_updates: crate::api::model::PlaylistUpdateControl::for_tests(),
maintenance: crate::api::model::create_test_maintenance(""),
})
}
@@ -44,6 +44,7 @@ pub(in crate::api::endpoints::hls_api) fn hls_custom_video_type_for_failure_reas
ConnectFailureReason::Preempted => CustomVideoStreamType::LowPriorityPreempted,
ConnectFailureReason::Provisioning => CustomVideoStreamType::Provisioning,
ConnectFailureReason::SessionExpired => CustomVideoStreamType::HlsSessionOrLeaseExpired,
ConnectFailureReason::Maintenance => CustomVideoStreamType::Maintenance,
ConnectFailureReason::ProviderError
| ConnectFailureReason::ProviderClosed
| ConnectFailureReason::ChannelUnavailable => CustomVideoStreamType::ChannelUnavailable,
@@ -11,8 +11,9 @@ use super::{
use crate::{
api::{
api_utils::{
acquire_exact_provider_handle, connection_priority_for_kind, get_hls_playback_ttl_secs,
select_provider_stream_url, ExactProviderAcquire, HLS_MANIFEST_CAPACITY_WAIT,
acquire_exact_provider_handle, blocks_new_playback, connection_priority_for_kind,
get_hls_playback_ttl_secs, is_running_playback, select_provider_stream_url, ExactProviderAcquire,
HLS_MANIFEST_CAPACITY_WAIT,
},
model::{AppState, CustomVideoStreamType, PlaybackLeaseRef, ProviderAllocation, UserSession},
},
@@ -87,6 +88,20 @@ pub(in crate::api) async fn handle_hls_stream_request(
if app_state.active_users.is_user_blocked_for_stream(&user.username, VirtualId::new(virtual_id)).await {
return axum::http::StatusCode::BAD_REQUEST.into_response();
}
// Only a fresh entry is gated. Variant refreshes, segments and entry re-polls of a running
// session (unwrapped media playlists) belong to a running playback.
if matches!(stage, HlsRequestStage::Entry)
&& !is_running_playback(user_session)
&& blocks_new_playback(app_state, input, user, false)
{
return hls_custom_video_manifest_response(
app_state,
user,
CustomVideoStreamType::Maintenance,
StatusCode::SERVICE_UNAVAILABLE,
)
.await;
}
let stream_ref = stream_context.stream_ref().to_string();
// Variant URLs were normalized before sealing or come from an upstream master; use them as is.
@@ -1014,3 +1014,135 @@ async fn hls_proxy_manifest_invalid_token_starts_no_origin_work() {
assert_eq!(app_state.hls.proxy.metrics().snapshot().refresh_started, 0);
assert!(app_state.hls.proxy.sessions().is_empty().await);
}
#[tokio::test]
async fn hls_entry_for_input_in_maintenance_opens_no_session() {
// Same setup as `hls_cache_entry_master_playlist_uses_cache_when_target_hls_share_enabled`,
// which answers 200 with one access lease when the input is not in maintenance.
let app_state = test_app_state();
enable_hls_cache(&app_state);
configure_default_test_server(&app_state);
let mut user = ProxyUserCredentials::default();
user.username = "hls-user".to_string();
user.password = "hls-pass".to_string();
let input = test_hls_input();
let target = test_hls_share_target(true);
let original_hls_entry_path = super::super::build_virtual_hls_entry_path(&target, &input, &user, 1001);
app_state.maintenance.add(&input.name, None, 1).await.expect("maintenance");
let response = super::super::handle_hls_stream_request(
&test_fingerprint(),
&app_state,
&user,
&target,
None,
None,
"http://origin.example.com/live/user/pass/1001.m3u8",
None,
test_hls_entry_stream_context(1001, "80510", Some(2_500_000)),
&input,
&HeaderMap::new(),
UserConnectionPermission::Allowed,
Some(ConnectionKind::Normal),
&original_hls_entry_path,
super::super::HlsRequestStage::Entry,
)
.await
.into_response();
// No maintenance clip is configured here, so the configured fallback status answers.
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert!(app_state.hls.proxy.access_leases().read().await.is_empty(), "no lease for a maintenance input");
assert!(app_state.active_users.active_streams().await.is_empty());
}
#[tokio::test]
async fn hls_entry_refresh_of_a_running_session_is_not_gated() {
// Without media-playlist wrapping the player re-polls the entry URL; a known user session
// is a running playback and keeps playing through maintenance.
let app_state = test_app_state();
enable_hls_cache(&app_state);
configure_default_test_server(&app_state);
let mut user = ProxyUserCredentials::default();
user.username = "hls-user".to_string();
user.password = "hls-pass".to_string();
let input = test_hls_input();
let target = test_hls_share_target(true);
let original_hls_entry_path = super::super::build_virtual_hls_entry_path(&target, &input, &user, 1001);
app_state.maintenance.add(&input.name, None, 1).await.expect("maintenance");
let session = crate::api::model::UserSession {
token: "running-hls-session".to_string(),
virtual_id: 1001,
provider: Arc::clone(&input.name),
lifecycle: crate::api::model::PlaybackLifecycle::Active,
..Default::default()
};
let response = super::super::handle_hls_stream_request(
&test_fingerprint(),
&app_state,
&user,
&target,
Some(&session),
None,
"http://origin.example.com/live/user/pass/1001.m3u8",
None,
test_hls_entry_stream_context(1001, "80510", Some(2_500_000)),
&input,
&HeaderMap::new(),
UserConnectionPermission::Allowed,
Some(ConnectionKind::Normal),
&original_hls_entry_path,
super::super::HlsRequestStage::Entry,
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK, "a running session is not switched to the maintenance clip");
}
#[tokio::test]
async fn hls_entry_with_an_expired_session_is_gated() {
// A known but no longer counted session is not a running playback: it would be admitted
// anew, so maintenance answers it.
let app_state = test_app_state();
enable_hls_cache(&app_state);
configure_default_test_server(&app_state);
let mut user = ProxyUserCredentials::default();
user.username = "hls-user".to_string();
user.password = "hls-pass".to_string();
let input = test_hls_input();
let target = test_hls_share_target(true);
let original_hls_entry_path = super::super::build_virtual_hls_entry_path(&target, &input, &user, 1001);
app_state.maintenance.add(&input.name, None, 1).await.expect("maintenance");
let session = crate::api::model::UserSession {
token: "expired-hls-session".to_string(),
virtual_id: 1001,
provider: Arc::clone(&input.name),
lifecycle: crate::api::model::PlaybackLifecycle::Expired,
..Default::default()
};
let response = super::super::handle_hls_stream_request(
&test_fingerprint(),
&app_state,
&user,
&target,
Some(&session),
None,
"http://origin.example.com/live/user/pass/1001.m3u8",
None,
test_hls_entry_stream_context(1001, "80510", Some(2_500_000)),
&input,
&HeaderMap::new(),
UserConnectionPermission::Allowed,
Some(ConnectionKind::Normal),
&original_hls_entry_path,
super::super::HlsRequestStage::Entry,
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert!(app_state.hls.proxy.access_leases().read().await.is_empty(), "no lease for a maintenance input");
}
@@ -193,6 +193,7 @@ pub(in crate::api::endpoints::hls_api::tests) fn enable_provider_exhausted_custo
user_account_expired: None,
panel_api_provisioning: None,
hls_session_or_lease_expired: None,
maintenance: None,
panel_api_provisioning_hls_segments: Vec::new(),
})));
}
@@ -215,6 +216,7 @@ pub(in crate::api::endpoints::hls_api::tests) fn enable_hls_provisioning_custom_
user_account_expired: None,
panel_api_provisioning: None,
hls_session_or_lease_expired: None,
maintenance: None,
panel_api_provisioning_hls_segments: provisioning_segments,
})));
}
@@ -88,6 +88,34 @@ async fn hls_origin_account_io_lease_blocks_other_hls_sessions_for_same_account(
wait_for_provider_connection_count(&app_state, 0).await;
}
#[tokio::test]
async fn hls_origin_account_io_frees_its_slot_before_finish_returns() {
// The next origin I/O of the session (a manifest refresh right after a segment) or
// another session (a channel switch) must find the slot free at once. A deferred
// release makes it race for a slot the provider still counts, and an account at
// its connection limit answers that race with `Exhausted`.
let input = overlap_provider_input();
let app_state = test_app_state_with_inputs(vec![Arc::new(input.clone())]);
let session = create_bound_hls_test_session(&app_state, &input, "12345", "account-a", 1_000).await;
let other_session = create_bound_hls_test_session(&app_state, &input, "67890", "account-a", 1_000).await;
let binding = session.read().await.origin_account_binding.clone().expect("binding exists");
let other_binding = other_session.read().await.origin_account_binding.clone().expect("binding exists");
let origin_io = test_hls_origin_io_context(&app_state);
let first = begin_hls_origin_account_io(&origin_io, &session, &binding).await.expect("first io acquires");
wait_for_provider_connection_count(&app_state, 1).await;
finish_hls_origin_account_io(&origin_io, &session, first, true).await;
assert_eq!(app_state.active_provider.get_provider_connections_count(), 0, "released before finish returns");
let second = begin_hls_origin_account_io(&origin_io, &session, &binding).await.expect("next io acquires at once");
finish_hls_origin_account_io(&origin_io, &session, second, true).await;
let other = begin_hls_origin_account_io(&origin_io, &other_session, &other_binding)
.await
.expect("another session acquires at once");
finish_hls_origin_account_io(&origin_io, &other_session, other, true).await;
assert_eq!(app_state.active_provider.get_provider_connections_count(), 0);
}
#[tokio::test]
async fn hls_account_overlap_selects_soft_candidate_but_not_hard_active() {
let app_state = test_app_state();
@@ -256,6 +256,7 @@ pub(in crate::api::endpoints::hls_api::tests) fn test_app_state_with_hls_proxy_a
recording_capacity: crate::api::model::recording_runtime::ProviderCapacityAdapter::new(
Arc::clone(&active_provider),
Arc::clone(&connection_manager),
crate::api::model::create_test_maintenance(""),
),
app_config,
http_clients: Arc::new(tuliprox_core::model::HttpClients::new(
@@ -283,6 +284,7 @@ pub(in crate::api::endpoints::hls_api::tests) fn test_app_state_with_hls_proxy_a
metadata_manager,
auth: crate::api::model::AuthState::for_tests(),
playlist_updates: crate::api::model::PlaylistUpdateControl::for_tests(),
maintenance: crate::api::model::create_test_maintenance(""),
})
}
@@ -26,6 +26,7 @@ pub(in crate::api::endpoints::hls_api::tests) fn enable_channel_unavailable_cust
user_account_expired: None,
panel_api_provisioning: None,
hls_session_or_lease_expired: None,
maintenance: None,
panel_api_provisioning_hls_segments: Vec::new(),
})));
}
@@ -33,6 +33,7 @@ pub(in crate::api::endpoints::hls_api::tests) fn enable_runtime_policy_custom_re
user_account_expired: None,
panel_api_provisioning: None,
hls_session_or_lease_expired: None,
maintenance: None,
panel_api_provisioning_hls_segments: Vec::new(),
})));
}
+21 -6
View File
@@ -1,14 +1,15 @@
use crate::{
api::{
api_utils::{
admission_failure_response, coalesce_byte_stream, create_m3u_catchup_session_key,
admission_failure_response, blocks_new_playback, coalesce_byte_stream, create_m3u_catchup_session_key,
create_playback_session_fingerprint, create_session_fingerprint, force_provider_stream_response,
get_session_reservation_ttl_secs, get_user_target, get_user_target_by_credentials,
get_session_reservation_ttl_secs, get_user_target, get_user_target_by_credentials, is_running_playback,
is_seekable_media_request, is_session_based_playback, is_stream_share_enabled, local_stream_response,
redirect_response, reentry_suppressed_response, resolve_initial_stalker_playback_url,
resource_proxy_response, resource_redirect_or_proxy, separate_number_and_remainder,
should_allow_exhausted_shared_reconnect, stream_response_with_provider_handle, try_option_bad_request,
try_result_bad_request, try_result_not_found, try_unwrap_body, RedirectParams,
maintenance_rejection_response, redirect_response, reentry_suppressed_response,
resolve_initial_stalker_playback_url, resource_proxy_response, resource_redirect_or_proxy,
separate_number_and_remainder, should_allow_exhausted_shared_reconnect,
stream_response_with_provider_handle, try_option_bad_request, try_result_bad_request, try_result_not_found,
try_unwrap_body, RedirectParams,
},
endpoints::{
hls_api::{
@@ -251,6 +252,7 @@ pub(in crate::api) async fn m3u_api_stream_loaded(
pli.url = match resolve_initial_stalker_playback_url(
app_state,
&input,
&user,
pli.get_provider_id().unwrap_or_default(),
cluster,
pli.item_type,
@@ -380,6 +382,19 @@ pub(in crate::api) async fn m3u_api_stream_loaded(
pli.url.clone()
};
if !is_running_playback(user_session.as_ref()) && blocks_new_playback(app_state, &input, &user, false) {
return maintenance_rejection_response(
app_state,
fingerprint,
&user,
stream_channel(),
&input,
req_headers,
extension == HLS_EXT,
)
.await;
}
let (connection_admission, grace_mode, request_class) = crate::api::api_utils::resolve_playback_request_admission(
&app_state.admission_ctx(),
&user,
@@ -0,0 +1,290 @@
//! Operator control over input maintenance.
//!
//! Every call answers with the complete state, so a client never has to merge.
use crate::api::{auth_middleware::permission_layer, model::AppState};
use axum::{
body::Bytes,
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
};
use log::error;
use shared::model::{
normalize_maintenance_reason, MaintenanceRequestDto, MaintenanceStateDto, Permission, MAINTENANCE_WILDCARD,
};
use std::sync::Arc;
use tuliprox_core::model::MaintenanceWrite;
pub fn maintenance_api_register_with_permissions(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
let read_routes = Router::new()
.route("/maintenance", get(get_maintenance))
.layer(permission_layer!(app_state, Permission::MaintenanceRead));
let write_routes = Router::new()
.route("/maintenance/{input}", axum::routing::put(put_maintenance).delete(delete_maintenance))
.layer(permission_layer!(app_state, Permission::MaintenanceWrite));
Router::new().merge(read_routes).merge(write_routes)
}
pub fn maintenance_api_register_unprotected() -> Router<Arc<AppState>> {
Router::new()
.route("/maintenance", get(get_maintenance))
.route("/maintenance/{input}", axum::routing::put(put_maintenance).delete(delete_maintenance))
}
fn current_state(app_state: &AppState) -> MaintenanceStateDto {
let sources = app_state.app_config.sources.load();
MaintenanceStateDto {
entries: (*app_state.maintenance.snapshot()).clone(),
inputs: sources.inputs.iter().map(|input| input.name.to_string()).collect(),
}
}
fn respond(app_state: &AppState, write: MaintenanceWrite) -> axum::response::Response {
if let Err(err) = app_state.publish_maintenance(write) {
error!("Could not persist maintenance state: {err}");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
Json(current_state(app_state)).into_response()
}
async fn get_maintenance(State(app_state): State<Arc<AppState>>) -> impl IntoResponse {
Json(current_state(&app_state))
}
/// The body is optional: a bare `PUT` puts the input into maintenance without a remark.
async fn put_maintenance(
State(app_state): State<Arc<AppState>>,
Path(input): Path<String>,
body: Bytes,
) -> axum::response::Response {
let request = if body.is_empty() {
MaintenanceRequestDto::default()
} else {
match serde_json::from_slice::<MaintenanceRequestDto>(&body) {
Ok(request) => request,
Err(err) => return (StatusCode::BAD_REQUEST, err.to_string()).into_response(),
}
};
let reason = match normalize_maintenance_reason(request.reason.as_deref()) {
Ok(reason) => reason,
Err(err) => return (StatusCode::BAD_REQUEST, err.to_string()).into_response(),
};
let now = chrono::Utc::now().timestamp();
let write = if input == MAINTENANCE_WILDCARD {
let sources = app_state.app_config.sources.load();
let names: Vec<&str> = sources.inputs.iter().map(|input| input.name.as_ref()).collect();
app_state.maintenance.add_all(&names, reason, now).await
} else if app_state.is_known_input_name(&input) {
app_state.maintenance.add(&input, reason, now).await
} else {
return StatusCode::NOT_FOUND.into_response();
};
respond(&app_state, write)
}
async fn delete_maintenance(
State(app_state): State<Arc<AppState>>,
Path(input): Path<String>,
) -> axum::response::Response {
let write = if input == MAINTENANCE_WILDCARD {
app_state.maintenance.remove_all().await
} else if app_state.is_known_input_name(&input) {
app_state.maintenance.remove(&input).await
} else {
return StatusCode::NOT_FOUND.into_response();
};
respond(&app_state, write)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
api::model::create_test_app_state,
auth::create_jwt_web_user,
model::{Config, SourcesConfig},
};
use axum::{body::Body, http::Request};
use http_body_util::BodyExt;
use shared::model::{
ConfigInputDto, SourcesConfigDto, UserId, WebAuthConfigDto, WebUiConfigDto, MAINTENANCE_REASON_MAX_CHARS,
};
use tempfile::TempDir;
use tower::ServiceExt;
fn app_state_with(config: Config) -> Arc<AppState> {
let state = create_test_app_state(config);
let dto = SourcesConfigDto {
inputs: ["provider_a", "provider_b"]
.iter()
.map(|name| ConfigInputDto {
name: (*name).to_string().into(),
url: "http://localhost/playlist.m3u".to_string(),
..Default::default()
})
.collect(),
..Default::default()
};
state.app_config.sources.store(Arc::new(SourcesConfig::try_from(&dto).unwrap()));
state
}
fn app_state(dir: &TempDir) -> Arc<AppState> {
app_state_with(Config { storage_dir: dir.path().to_string_lossy().to_string(), ..Config::default() })
}
async fn call(router: &Router, method: &str, uri: &str, body: Option<&str>) -> (StatusCode, Vec<u8>) {
let mut builder = Request::builder().method(method).uri(uri);
if body.is_some() {
builder = builder.header(axum::http::header::CONTENT_TYPE, "application/json");
}
let request = builder.body(body.map_or_else(Body::empty, |b| Body::from(b.to_string()))).unwrap();
let response = router.clone().oneshot(request).await.unwrap();
let status = response.status();
(status, response.into_body().collect().await.unwrap().to_bytes().to_vec())
}
fn state_of(body: &[u8]) -> MaintenanceStateDto { serde_json::from_slice(body).unwrap() }
#[tokio::test]
async fn get_lists_known_inputs_and_empty_entries() {
let dir = TempDir::new().unwrap();
let router = maintenance_api_register_unprotected().with_state(app_state(&dir));
let (status, body) = call(&router, "GET", "/maintenance", None).await;
assert_eq!(status, StatusCode::OK);
let state = state_of(&body);
assert!(state.entries.is_empty());
assert_eq!(state.inputs, vec!["provider_a".to_string(), "provider_b".to_string()]);
}
#[tokio::test]
async fn put_adds_with_trimmed_reason_and_delete_removes() {
let dir = TempDir::new().unwrap();
let state = app_state(&dir);
let router = maintenance_api_register_unprotected().with_state(Arc::clone(&state));
let (status, body) = call(&router, "PUT", "/maintenance/provider_a", Some(r#"{"reason":" down "}"#)).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(state_of(&body).entries["provider_a"].reason.as_deref(), Some("down"));
assert!(state.maintenance.is_in_maintenance("provider_a"));
let (status, body) = call(&router, "DELETE", "/maintenance/provider_a", None).await;
assert_eq!(status, StatusCode::OK);
assert!(state_of(&body).entries.is_empty());
}
#[tokio::test]
async fn put_without_body_is_accepted() {
let dir = TempDir::new().unwrap();
let router = maintenance_api_register_unprotected().with_state(app_state(&dir));
let (status, body) = call(&router, "PUT", "/maintenance/provider_a", None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(state_of(&body).entries["provider_a"].reason, None);
}
#[tokio::test]
async fn invalid_json_body_is_bad_request() {
let dir = TempDir::new().unwrap();
let router = maintenance_api_register_unprotected().with_state(app_state(&dir));
let (status, _) = call(&router, "PUT", "/maintenance/provider_a", Some("{not json")).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn wildcard_adds_and_removes_all_known_inputs() {
let dir = TempDir::new().unwrap();
let router = maintenance_api_register_unprotected().with_state(app_state(&dir));
let (_, body) = call(&router, "PUT", "/maintenance/%2A", Some(r#"{"reason":"outage"}"#)).await;
assert_eq!(state_of(&body).entries.len(), 2);
let (_, body) = call(&router, "DELETE", "/maintenance/*", None).await;
assert!(state_of(&body).entries.is_empty());
}
#[tokio::test]
async fn unknown_input_is_not_found_and_changes_nothing() {
let dir = TempDir::new().unwrap();
let state = app_state(&dir);
let router = maintenance_api_register_unprotected().with_state(Arc::clone(&state));
let (status, _) = call(&router, "PUT", "/maintenance/nope", None).await;
assert_eq!(status, StatusCode::NOT_FOUND);
let (status, _) = call(&router, "DELETE", "/maintenance/nope", None).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(state.maintenance.snapshot().is_empty());
}
#[tokio::test]
async fn too_long_reason_is_bad_request() {
let dir = TempDir::new().unwrap();
let router = maintenance_api_register_unprotected().with_state(app_state(&dir));
let body = format!(r#"{{"reason":"{}"}}"#, "x".repeat(MAINTENANCE_REASON_MAX_CHARS + 1));
let (status, _) = call(&router, "PUT", "/maintenance/provider_a", Some(&body)).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn delete_of_known_input_not_in_maintenance_is_ok() {
let dir = TempDir::new().unwrap();
let router = maintenance_api_register_unprotected().with_state(app_state(&dir));
let (status, _) = call(&router, "DELETE", "/maintenance/provider_b", None).await;
assert_eq!(status, StatusCode::OK);
}
#[tokio::test]
async fn persist_failure_is_internal_error() {
let dir = TempDir::new().unwrap();
let state = app_state(&dir);
let blocked = dir.path().join(crate::model::MAINTENANCE_FILE);
std::fs::create_dir(&blocked).unwrap();
std::fs::write(blocked.join("blocker"), b"x").unwrap();
let router = maintenance_api_register_unprotected().with_state(Arc::clone(&state));
let (status, _) = call(&router, "PUT", "/maintenance/provider_a", None).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert!(!state.maintenance.is_in_maintenance("provider_a"));
}
#[tokio::test]
async fn permissions_gate_read_and_write() -> Result<(), Box<dyn std::error::Error>> {
let dir = TempDir::new()?;
let web_ui = WebUiConfigDto {
auth: Some(WebAuthConfigDto {
enabled: true,
issuer: "maintenance-test".to_owned(),
secret: "maintenance-test-secret".to_owned(),
..Default::default()
}),
..Default::default()
};
let config = Config {
web_ui: Some((&web_ui).into()),
storage_dir: dir.path().to_string_lossy().to_string(),
..Default::default()
};
let Some(web_auth) = config.web_ui.as_ref().and_then(|web_ui| web_ui.auth.as_ref()) else {
return Err("missing web auth".into());
};
let reader =
create_jwt_web_user(web_auth, "reader", Permission::MaintenanceRead.into(), 0, UserId::from("web:reader"))?;
let system =
create_jwt_web_user(web_auth, "system", Permission::SystemRead.into(), 0, UserId::from("web:system"))?;
let state = app_state_with(config);
let router = maintenance_api_register_with_permissions(&state).with_state(Arc::clone(&state));
let request = |method: &str, uri: &str, token: &str| {
Request::builder()
.method(method)
.uri(uri)
.header("Authorization", format!("Bearer {token}"))
.body(Body::empty())
};
let response = router.clone().oneshot(request("GET", "/maintenance", &reader)?).await?;
assert_eq!(response.status(), StatusCode::OK);
let response = router.clone().oneshot(request("GET", "/maintenance", &system)?).await?;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let response = router.clone().oneshot(request("PUT", "/maintenance/provider_a", &reader)?).await?;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert!(!state.maintenance.is_in_maintenance("provider_a"));
Ok(())
}
}
+1
View File
@@ -8,6 +8,7 @@ mod hls_terminal_response;
mod library_api;
pub(in crate::api) mod log_ws_api;
pub(in crate::api) mod m3u_api;
mod maintenance_api;
pub(in crate::api) mod provider_resolve_api;
mod rbac_api;
pub(in crate::api) mod recording_api;
@@ -74,12 +74,15 @@ fn resolve_direct_provider_location(input: &ConfigInput, url: &str) -> Result<St
fn resolve_provider_playlist_item_location(
user: &ProxyUserCredentials,
force_redirect: bool,
input_in_maintenance: bool,
output_kind: ProviderResolveOutputKind,
server_info: &ApiProxyServerInfo,
input: &ConfigInput,
item: ProviderResolveItem<'_>,
) -> Result<String, TuliproxError> {
let redirect = user.proxy.is_redirect(item.item_type) || force_redirect;
// An input in maintenance is never redirected to its provider; the tuliprox stream URL
// answers with the maintenance clip.
let redirect = !input_in_maintenance && (user.proxy.is_redirect(item.item_type) || force_redirect);
if redirect {
return resolve_direct_provider_location(input, item.url);
}
@@ -219,6 +222,7 @@ async fn provider_resolve(
match resolve_provider_playlist_item_location(
user.as_ref(),
target.is_force_redirect(item.item_type),
app_state.maintenance.is_in_maintenance(&input.name),
output_kind,
&server_info,
&input,
@@ -306,6 +310,7 @@ mod tests {
let location = resolve_provider_playlist_item_location(
&user(ProxyType::Redirect),
false,
false,
ProviderResolveOutputKind::Xtream,
&server_info(),
&input(),
@@ -316,6 +321,30 @@ mod tests {
assert_eq!(location, "http://provider.example.com/live/root/pass/81356.ts");
}
#[test]
fn provider_resolve_redirect_user_gets_tuliprox_location_for_input_in_maintenance() {
// A redirect would send the client to the provider; the tuliprox URL answers with the maintenance clip.
let item = ProviderResolveItem {
virtual_id: 81_356,
item_type: PlaylistItemType::Live,
cluster: XtreamCluster::Live,
url: "provider://myprovider/live/root/pass/81356.ts",
};
let location = resolve_provider_playlist_item_location(
&user(ProxyType::Redirect),
false,
true,
ProviderResolveOutputKind::Xtream,
&server_info(),
&input(),
item,
)
.unwrap();
assert!(location.starts_with("http://proxy.example.com/"), "{location}");
}
#[test]
fn provider_resolve_redirect_user_uses_current_provider_url() {
let item = ProviderResolveItem {
@@ -332,6 +361,7 @@ mod tests {
let location = resolve_provider_playlist_item_location(
&user(ProxyType::Redirect),
false,
false,
ProviderResolveOutputKind::Xtream,
&server_info(),
&input,
@@ -354,6 +384,7 @@ mod tests {
let location = resolve_provider_playlist_item_location(
&user(ProxyType::Reverse(None)),
false,
false,
ProviderResolveOutputKind::Xtream,
&server_info(),
&input(),
@@ -376,6 +407,7 @@ mod tests {
let location = resolve_provider_playlist_item_location(
&user(ProxyType::Reverse(None)),
false,
false,
ProviderResolveOutputKind::M3u,
&server_info(),
&input(),
@@ -1207,6 +1207,8 @@ mod tests {
fn release(&self, _handle: Option<ProviderHandle>) -> BoxFuture<'_, ()> { Box::pin(async {}) }
fn capacity_changed(&self) -> Arc<Notify> { Arc::clone(&self.notify) }
fn is_input_in_maintenance(&self, _input_name: &str) -> bool { false }
}
fn edit_claims(subject_id: Option<UserId>, admin: bool) -> shared::model::Claims {
+2
View File
@@ -199,6 +199,7 @@ pub fn v1_api_register(
.merge(v1_api_playlist_register_with_permissions(axum::routing::Router::new(), app_state))
.merge(library_api_register(axum::routing::Router::new(), Some(app_state)))
.merge(target_bouquet_api_register_with_permissions(app_state))
.merge(super::maintenance_api::maintenance_api_register_with_permissions(app_state))
.merge(rbac_api_register(Arc::clone(app_state)))
.merge(recording_availability_register(axum::routing::Router::new()))
// `recording.enabled: false` turns the DVR off end to end:
@@ -219,6 +220,7 @@ pub fn v1_api_register(
.merge(v1_api_playlist_register_protected(axum::routing::Router::new()))
.merge(library_api_register(axum::routing::Router::new(), None))
.merge(target_bouquet_api_register_unprotected())
.merge(super::maintenance_api::maintenance_api_register_unprotected())
.merge(rbac_api_register_unprotected(Arc::clone(app_state)))
.merge(recording_availability_register(axum::routing::Router::new()))
.merge(
@@ -469,6 +469,7 @@ async fn initial_stalker_playback_resolution_uses_the_requested_items_own_comman
crate::api::api_utils::resolve_initial_stalker_playback_url(
&app_state,
&live_input,
&crate::model::ProxyUserCredentials::default(),
provider_id,
XtreamCluster::Live,
live,
@@ -501,6 +502,7 @@ async fn initial_stalker_playback_resolution_uses_the_requested_items_own_comman
let passthrough = crate::api::api_utils::resolve_initial_stalker_playback_url(
&app_state,
&live_input,
&crate::model::ProxyUserCredentials::default(),
101,
XtreamCluster::Live,
live,
@@ -152,6 +152,7 @@ pub(in crate::api::endpoints::v1_api_playlist::tests) fn test_app_state_with_man
recording_capacity: crate::api::model::recording_runtime::ProviderCapacityAdapter::new(
Arc::clone(&active_provider),
Arc::clone(&connection_manager),
crate::api::model::create_test_maintenance(""),
),
app_config: app_cfg,
http_clients: Arc::default(),
@@ -170,5 +171,6 @@ pub(in crate::api::endpoints::v1_api_playlist::tests) fn test_app_state_with_man
metadata_manager,
auth: crate::api::model::AuthState::for_tests(),
playlist_updates: crate::api::model::PlaylistUpdateControl::for_tests_with_sender(manual_update_sender),
maintenance: crate::api::model::create_test_maintenance(""),
})
}
@@ -498,6 +498,10 @@ fn to_protocol_message(event: EventMessage) -> Option<(ProtocolMessage, &'static
// enforces the session filter server-side, so this is a bare nudge:
// the frontend re-fetches.
EventMessage::RecordingRulesChanged => (ProtocolMessage::RecordingRulesChanged, "Recording rules changed"),
EventMessage::MaintenanceChanged(entries) => (
ProtocolMessage::MaintenanceChangedResponse(unwrap_or_clone(entries)),
"Input maintenance changed",
),
// Handled by `handle_event_message`, not translatable here:
// `RecordingChanged` needs a per-session snapshot re-fetch, and the
@@ -936,6 +940,7 @@ mod tests {
auth_audit(AuthAuditOutcome::SignInFailed),
auth_audit(AuthAuditOutcome::SignInThrottled),
auth_audit(AuthAuditOutcome::PermissionDenied),
EventMessage::MaintenanceChanged(Arc::default()),
];
assert_eq!(samples.len(), EventKind::ALL.len(), "add the new variant to this list");
samples
@@ -1131,6 +1136,22 @@ mod tests {
assert!(websocket_can_receive_runtime_events(&mem, &EventMessage::RecordingRulesChanged));
}
#[test]
fn maintenance_event_needs_maintenance_read() {
let event = EventMessage::MaintenanceChanged(Arc::default());
let mut mem = ProtocolHandlerMemory {
permissions: Permission::MaintenanceRead.into(),
..ProtocolHandlerMemory::default()
};
mem.role = UserRole::User;
assert!(websocket_can_receive_runtime_events(&mem, &event));
let mut mem =
ProtocolHandlerMemory { permissions: Permission::SystemRead.into(), ..ProtocolHandlerMemory::default() };
mem.role = UserRole::User;
assert!(!websocket_can_receive_runtime_events(&mem, &event));
}
#[test]
fn a_second_of_progress_reports_produces_one_snapshot() {
// A running capture reports growth many times a second and each
@@ -5,11 +5,12 @@ use crate::{
api::{
api_utils,
api_utils::{
admission_failure_response, coalesce_byte_stream, create_api_proxy_user, create_catchup_session_key,
create_m3u_catchup_session_key, create_playback_session_fingerprint, create_session_fingerprint,
empty_json_response_as_array, empty_json_response_as_object, force_provider_stream_response,
get_session_reservation_ttl_secs, get_user_target, get_user_target_by_credentials, internal_server_error,
is_seekable_media_request, is_session_based_playback, is_stream_share_enabled, local_stream_response,
admission_failure_response, blocks_new_playback, coalesce_byte_stream, create_api_proxy_user,
create_catchup_session_key, create_m3u_catchup_session_key, create_playback_session_fingerprint,
create_session_fingerprint, empty_json_response_as_array, empty_json_response_as_object,
force_provider_stream_response, get_session_reservation_ttl_secs, get_user_target,
get_user_target_by_credentials, internal_server_error, is_running_playback, is_seekable_media_request,
is_session_based_playback, is_stream_share_enabled, local_stream_response, maintenance_rejection_response,
redirect, redirect_response, reentry_suppressed_response, resolve_initial_stalker_playback_url,
resource_proxy_response, resource_redirect_or_proxy, separate_number_and_remainder,
should_allow_exhausted_shared_reconnect, stream_response, stream_response_with_provider_handle,
@@ -443,6 +444,7 @@ async fn xtream_player_api_stream(
pli.url = match resolve_initial_stalker_playback_url(
app_state,
&input,
&user,
pli.provider_id,
pli.xtream_cluster,
item_type,
@@ -577,6 +579,20 @@ async fn xtream_player_api_stream(
pli.url.clone()
};
if !is_running_playback(user_session.as_ref()) && blocks_new_playback(app_state, &input, &user, false) {
let stream_channel = create_stream_channel_with_type(target.id, &pli, item_type);
return maintenance_rejection_response(
app_state,
fingerprint,
&user,
stream_channel,
&input,
req_headers,
playback_ext == HLS_EXT,
)
.await;
}
let (connection_admission, grace_mode, request_class) = crate::api::api_utils::resolve_playback_request_admission(
&app_state.admission_ctx(),
&user,
@@ -978,6 +994,7 @@ pub(in crate::api) async fn xtream_player_api_stream_with_resolved_target(
pli.url = match resolve_initial_stalker_playback_url(
app_state,
&input,
&user,
pli.provider_id,
pli.xtream_cluster,
resolution_item_type,
@@ -1379,9 +1396,9 @@ pub async fn xtream_get_stream_info_response(
return axum::Json(pli.to_info_document(&options)).into_response();
}
// handle upstream provider
// handle upstream provider; an input in maintenance answers from the fallback below
if pli.provider_id > 0 {
if let Some(input) = input {
if let Some(input) = input.filter(|input| !app_state.maintenance.is_in_maintenance(&input.name)) {
if let Some(info_url) = xtream::get_xtream_player_api_info_url(&input, cluster, pli.provider_id) {
// redirect is only possible for live streams
if user.proxy == ProxyType::Redirect && cluster == XtreamCluster::Live {
@@ -1478,8 +1495,7 @@ async fn xtream_get_short_epg(
}
if pli.provider_id > 0 {
let input_name = &pli.input_name;
if let Some(input) = app_state.app_config.get_input_by_name(input_name) {
if let Some(input) = app_state.contactable_input(&pli.input_name) {
if let Some(action_url) =
xtream::get_xtream_player_api_action_url(&input, crate::model::XC_ACTION_GET_SHORT_EPG)
{
@@ -868,3 +868,153 @@ async fn xtream_token_stream_rejects_m3u_timeshift_with_bad_request() {
assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
}
/// Serves a recognisable clip for `maintenance`.
fn enable_maintenance_clip(app_state: &Arc<AppState>) {
let mut cfg = (**app_state.app_config.config.load()).clone();
cfg.custom_stream_response_enabled = true;
app_state.app_config.config.store(Arc::new(cfg));
let mut packet = vec![0xff_u8; 188];
packet[..4].copy_from_slice(&[0x47, 0x01, 0x00, 0x10]);
packet[4..9].copy_from_slice(b"MAINT");
app_state.app_config.custom_stream_response.store(Some(Arc::new(crate::model::CustomStreamResponse {
channel_unavailable: None,
user_connections_exhausted: None,
provider_connections_exhausted: None,
low_priority_preempted: None,
user_account_expired: None,
panel_api_provisioning: None,
hls_session_or_lease_expired: None,
maintenance: Some(crate::api::model::TransportStreamBuffer::new(packet)),
panel_api_provisioning_hls_segments: Vec::new(),
})));
}
/// Gives `user` (limited to one connection) a running stream on another channel.
async fn occupy_the_only_connection(app_state: &Arc<AppState>, user: &ProxyUserCredentials) {
let busy_addr = "127.0.0.1:55401".parse().expect("addr");
let busy = Fingerprint::new("fp-busy".to_string(), "127.0.0.1".to_string(), busy_addr);
app_state.connection_manager.add_connection(&busy_addr).await;
app_state
.active_users
.create_user_session(crate::api::model::CreateUserSessionParams {
user,
session_token: "busy-session",
virtual_id: 7,
provider: "provider-b",
stream_url: "http://other.example/live/7.ts",
addr: &busy_addr,
connection_permission: shared::model::UserConnectionPermission::Allowed,
connection_kind: Some(crate::api::model::ConnectionKind::Normal),
socket_bound: false,
})
.await;
let channel = shared::model::StreamChannel {
target_id: 1,
virtual_id: 7,
provider_id: 7,
input_name: "provider-b".intern(),
item_type: PlaylistItemType::Live,
cluster: XtreamCluster::Live,
group: "Live".intern(),
title: "Busy".intern(),
url: "http://other.example/live/7.ts".into(),
shared: false,
shared_joined_existing: None,
shared_stream_id: None,
technical: None,
epg_channel_id: None,
epg_reference_ts: None,
upstream_user_agent: None,
};
app_state
.connection_manager
.update_connection(crate::api::model::ConnectionParams {
meter_uid: 401,
username: "busy",
max_connections: 1,
soft_connections: 0,
connection_kind: crate::api::model::ConnectionKind::Normal,
priority: 0,
soft_priority: 0,
fingerprint: &busy,
provider: "provider-b".intern(),
stream_channel: &channel,
user_agent: std::borrow::Cow::Borrowed("player/1.0"),
session_token: Some("busy-session"),
})
.await;
}
#[tokio::test]
async fn maintenance_answers_before_admission_for_an_exhausted_user() {
// An exhausted user asking for an input in maintenance gets the maintenance clip, and the
// request never reaches admission (no connection-denied event, no eviction).
let dir = tempdir().expect("temp dir");
let config = Config {
storage_dir: dir.path().to_string_lossy().into_owned(),
user_access_control: true,
..Config::default()
};
let app_state = create_test_app_state(config);
enable_maintenance_clip(&app_state);
let target = Arc::new(short_epg_target());
let mut item = short_epg_live_item();
item.provider_id = 100;
item.input_name = "provider_a".intern();
item.url = "http://127.0.0.1:1/live/user/pass/100.ts".intern();
let input = ConfigInput {
id: 1,
name: "provider_a".intern(),
input_type: InputType::Xtream,
url: "http://127.0.0.1:1".to_string(),
username: Some("user".to_string()),
password: Some("pass".to_string()),
enabled: true,
max_connections: 5,
..ConfigInput::default()
};
cache_xtream_test_item(&app_state, &target, item, Some(input)).await;
app_state.maintenance.add("provider_a", None, 1).await.expect("maintenance");
let mut user = ProxyUserCredentials::default();
user.username = "busy".to_string();
user.password = "busy".to_string();
user.max_connections = 1;
user.soft_connections = 0;
user.proxy = shared::model::ProxyType::Reverse(None);
user.output_clusters = ClusterFlags::all();
occupy_the_only_connection(&app_state, &user).await;
let addr = "127.0.0.1:55402".parse().expect("addr");
let fingerprint = Fingerprint::new("fp-new".to_string(), "127.0.0.1".to_string(), addr);
let response = xtream_player_api_stream(
&fingerprint,
&HeaderMap::new(),
&app_state,
&UserApiRequest::default(),
ApiStreamRequest::from(ApiStreamContext::LiveAlt, "busy", "busy", "100.ts", ""),
Some((Arc::new(user), target)),
)
.await
.into_response();
assert_eq!(response.status(), axum::http::StatusCode::OK, "the maintenance clip answers");
let body = tokio::time::timeout(std::time::Duration::from_secs(2), async {
let mut body = response.into_body();
let frame = http_body_util::BodyExt::frame(&mut body).await;
frame.and_then(Result::ok).and_then(|frame| frame.into_data().ok()).unwrap_or_default()
})
.await
.unwrap_or_default();
assert!(body.windows(5).any(|window| window == b"MAINT"), "the maintenance clip is served");
let denied = app_state
.active_users
.events()
.stats()
.emitted()
.into_iter()
.find_map(|(kind, count)| (kind == shared::model::EventKind::ConnectionDenied).then_some(count))
.unwrap_or(0);
assert_eq!(denied, 0, "the request never reached admission");
}
+21 -1
View File
@@ -33,7 +33,10 @@ use crate::{
exec_config_watch, exec_interner_prune, exec_scheduler, exec_xtream_expiry_sync, spawn_ssdp_discover_task,
},
},
model::{AppConfig, Config, HdHomeRunFlags, Healthcheck, HttpClients, ProcessTargets, RateLimitConfig},
model::{
AppConfig, Config, HdHomeRunFlags, Healthcheck, HttpClients, MaintenanceRegistry, ProcessTargets,
RateLimitConfig, MAINTENANCE_FILE,
},
processing::processor::{exec_processing, next_playlist_update_run_order, ProcessingRun},
repository::{get_geoip_path, GeoIp},
utils::{exec_file_lock_prune, get_default_web_root_path},
@@ -189,6 +192,7 @@ fn spawn_metadata_trigger_update(
.with_update_guard(update_guard.clone())
.with_disabled_headers(disabled_headers.clone())
.with_provider_manager(app_state_clone.active_provider.clone())
.with_maintenance(Arc::clone(&app_state_clone.maintenance))
.with_metadata_manager(app_state_clone.metadata_manager.clone())
.with_pre_processed_inputs(pre_processed_inputs.clone())
.with_acquired_permit(lock),
@@ -279,6 +283,14 @@ async fn ready(
(status_code, axum::Json(ReadyResponse { status: status_label }))
}
/// The persisted maintenance state, already pruned against the configured inputs.
pub(crate) async fn load_maintenance(app_config: &AppConfig) -> Arc<MaintenanceRegistry> {
let sources = app_config.sources.load();
let known: Vec<&str> = sources.inputs.iter().map(|input| input.name.as_ref()).collect();
let path = std::path::PathBuf::from(&app_config.config.load().storage_dir).join(MAINTENANCE_FILE);
Arc::new(MaintenanceRegistry::load(path, &known).await)
}
async fn create_shared_data(
app_config: &Arc<AppConfig>,
forced_targets: &Arc<ProcessTargets>,
@@ -359,6 +371,8 @@ async fn create_shared_data(
TuliproxError::Server(format!("Cannot load token revocations: {err}"))
})?;
let maintenance = load_maintenance(app_config).await;
let app_state = AppState {
app_config: Arc::clone(app_config),
http_clients: Arc::new(HttpClients::new(
@@ -377,6 +391,7 @@ async fn create_shared_data(
recording_capacity: crate::api::model::recording_runtime::ProviderCapacityAdapter::new(
Arc::clone(&active_provider),
Arc::clone(&connection_manager),
Arc::clone(&maintenance),
),
active_provider,
connection_manager,
@@ -387,6 +402,7 @@ async fn create_shared_data(
metadata_manager,
auth: AuthState::new(token_revocations),
playlist_updates: PlaylistUpdateControl::new(Arc::clone(forced_targets), manual_update_sender),
maintenance,
};
Ok((app_state, manual_update_rx))
@@ -426,6 +442,7 @@ async fn run_manual_update_worker(
.with_update_guard(app_state.playlist_updates.update_guard.clone())
.with_disabled_headers(app_state.get_disabled_headers())
.with_provider_manager(Arc::clone(&app_state.active_provider))
.with_maintenance(Arc::clone(&app_state.maintenance))
.with_metadata_manager(Arc::clone(&app_state.metadata_manager))
.with_acquired_permit(permit),
)
@@ -504,6 +521,7 @@ fn exec_update_on_boot(client: &reqwest::Client, app_state: &Arc<AppState>, targ
.with_update_guard(update_guard)
.with_disabled_headers(disabled_headers)
.with_provider_manager(provider_manager)
.with_maintenance(Arc::clone(&app_state_clone.maintenance))
.with_metadata_manager(metadata_manager),
)
.await;
@@ -1140,6 +1158,7 @@ mod tests {
recording_capacity: crate::api::model::recording_runtime::ProviderCapacityAdapter::new(
Arc::clone(&active_provider),
Arc::clone(&connection_manager),
crate::api::model::create_test_maintenance(""),
),
app_config: app_cfg,
http_clients: Arc::default(),
@@ -1158,6 +1177,7 @@ mod tests {
metadata_manager,
auth: crate::api::model::AuthState::for_tests(),
playlist_updates: crate::api::model::PlaylistUpdateControl::for_tests(),
maintenance: crate::api::model::create_test_maintenance(""),
})
}
+29 -3
View File
@@ -3,12 +3,15 @@ use crate::{
ActiveProviderManager, ActiveUserManager, ConnectionManager, EventManager, PlaylistStorage,
PlaylistStorageState, RecordingQueue, SharedStreamManager, StalkerResolveCoordinator,
},
model::{AppConfig, GracePeriodOptions, HdHomeRunDeviceConfig, HttpClients, ReverseProxyDisabledHeaderConfig},
model::{
AppConfig, GracePeriodOptions, HdHomeRunDeviceConfig, HttpClients, MaintenanceRegistry, MaintenanceWrite,
ReverseProxyDisabledHeaderConfig,
},
repository::GeoIp,
utils::LRUResourceCache,
};
use arc_swap::{ArcSwap, ArcSwapOption};
use shared::model::UserConnectionPermission;
use shared::model::{EventMessage, UserConnectionPermission};
use std::sync::{atomic::AtomicI8, Arc};
use tokio::sync::RwLock;
use tuliprox_metadata::manager::MetadataUpdateManager;
@@ -28,7 +31,7 @@ mod tests;
mod view;
#[cfg(test)]
pub use self::test_support::create_test_app_state;
pub use self::test_support::{create_test_app_state, create_test_maintenance};
pub use self::{
auth_state::*, cancel_tokens::*, hls_state::*, http_clients::*, playlist_update_control::*, reload::*,
resource_cache::*, view::*,
@@ -58,9 +61,32 @@ pub struct AppState {
pub metadata_manager: Arc<MetadataUpdateManager>,
pub auth: AuthState,
pub playlist_updates: PlaylistUpdateControl,
/// Inputs taken out of service; read on every new stream request.
pub maintenance: Arc<MaintenanceRegistry>,
}
impl AppState {
/// The configured input, unless it is in maintenance: for code that would contact its provider.
pub fn contactable_input(&self, input_name: &Arc<str>) -> Option<Arc<crate::model::ConfigInput>> {
self.app_config.get_input_by_name(input_name).filter(|input| !self.maintenance.is_in_maintenance(&input.name))
}
pub fn is_known_input_name(&self, input_name: &str) -> bool {
self.app_config.sources.load().inputs.iter().any(|input| input.name.as_ref() == input_name)
}
pub fn known_input_names(&self) -> Vec<Arc<str>> {
self.app_config.sources.load().inputs.iter().map(|input| Arc::clone(&input.name)).collect()
}
/// Emits the change event for a write that changed something.
pub fn publish_maintenance(&self, write: MaintenanceWrite) -> std::io::Result<()> {
if let Some(entries) = write? {
self.event_manager.send_event(EventMessage::MaintenanceChanged(entries));
}
Ok(())
}
pub async fn get_active_connections_for_user(&self, username: &str) -> u32 {
self.active_users.user_connections(username).await
}
@@ -80,6 +80,12 @@ pub async fn update_app_state_sources(
};
app_state.playlist_updates.forced_targets.store(targets);
let updates = app_state.set_sources(sources)?;
let known = app_state.known_input_names();
let known: Vec<&str> = known.iter().map(AsRef::as_ref).collect();
if let Err(err) = app_state.publish_maintenance(app_state.maintenance.retain_known(&known).await) {
// The reload itself succeeded; a stale entry only matches an input that no longer exists.
error!("Could not persist pruned maintenance state: {err}");
}
update_target_caches(app_state, updates.targets.as_ref()).await;
restart_services(app_state, &updates);
Ok(())
@@ -63,6 +63,7 @@ pub fn create_test_app_state(config: Config) -> Arc<AppState> {
let tokens = CancelTokens::default();
let metadata_manager = Arc::new(MetadataUpdateManager::new(tokens.metadata.clone()));
let maintenance = super::create_test_maintenance(&app_config.config.load().storage_dir);
Arc::new(AppState {
app_config,
http_clients: Arc::default(),
@@ -75,6 +76,7 @@ pub fn create_test_app_state(config: Config) -> Arc<AppState> {
recording_capacity: crate::api::model::recording_runtime::ProviderCapacityAdapter::new(
Arc::clone(&active_provider),
Arc::clone(&connection_manager),
Arc::clone(&maintenance),
),
active_provider,
connection_manager,
@@ -85,5 +87,17 @@ pub fn create_test_app_state(config: Config) -> Arc<AppState> {
metadata_manager,
auth: AuthState::for_tests(),
playlist_updates: PlaylistUpdateControl::for_tests(),
maintenance,
})
}
/// A maintenance registry for tests: persisted under the configured storage dir,
/// or in memory only, so a test never writes into the working directory.
pub fn create_test_maintenance(storage_dir: &str) -> Arc<crate::model::MaintenanceRegistry> {
if storage_dir.is_empty() {
return Arc::new(crate::model::MaintenanceRegistry::in_memory());
}
Arc::new(crate::model::MaintenanceRegistry::new(
std::path::PathBuf::from(storage_dir).join(crate::model::MAINTENANCE_FILE),
))
}
@@ -219,3 +219,60 @@ fn qos_aggregation_changed_detects_compaction_interval_changes() {
assert!(qos_aggregation_changed(&old_config, &new_config));
}
#[tokio::test]
async fn source_reload_prunes_removed_inputs_from_maintenance() {
use crate::{api::model::update_app_state_sources, model::SourcesConfig};
use shared::model::{ConfigInputDto, EventMessage, SourcesConfigDto};
use std::sync::Arc;
let dir = tempfile::TempDir::new().unwrap();
let config = Config { storage_dir: dir.path().to_string_lossy().to_string(), ..Config::default() };
let app_state = super::create_test_app_state(config);
let sources = |names: &[&str]| {
let dto = SourcesConfigDto {
inputs: names
.iter()
.map(|name| ConfigInputDto {
name: (*name).to_string().into(),
url: "http://localhost/playlist.m3u".to_string(),
..Default::default()
})
.collect(),
..Default::default()
};
SourcesConfig::try_from(&dto).unwrap()
};
app_state.app_config.sources.store(Arc::new(sources(&["provider_a", "provider_b"])));
app_state.maintenance.add_all(&["provider_a", "provider_b"], None, 1).await.unwrap();
let mut events = app_state.event_manager.get_event_channel();
update_app_state_sources(&app_state, sources(&["provider_a"]), None).await.unwrap();
assert!(app_state.maintenance.is_in_maintenance("provider_a"));
assert!(!app_state.maintenance.is_in_maintenance("provider_b"));
let pruned = std::iter::from_fn(|| events.try_recv().ok())
.find_map(|event| match event {
EventMessage::MaintenanceChanged(entries) => Some(entries),
_ => None,
})
.expect("pruning emits a change event");
assert_eq!(pruned.len(), 1);
}
#[tokio::test]
async fn contactable_input_hides_inputs_in_maintenance() {
use crate::model::{ConfigInput, SourcesConfig};
use std::sync::Arc;
let dir = tempfile::TempDir::new().unwrap();
let config = Config { storage_dir: dir.path().to_string_lossy().to_string(), ..Config::default() };
let app_state = super::create_test_app_state(config);
let input = Arc::new(ConfigInput { name: Arc::from("provider_a"), ..ConfigInput::default() });
app_state.app_config.sources.store(Arc::new(SourcesConfig { inputs: vec![input], ..SourcesConfig::default() }));
let name: Arc<str> = Arc::from("provider_a");
assert!(app_state.contactable_input(&name).is_some());
app_state.maintenance.add("provider_a", None, 1).await.unwrap();
assert!(app_state.contactable_input(&name).is_none(), "an input in maintenance is not contacted");
}
+6 -2
View File
@@ -51,6 +51,9 @@ impl AppState {
/// event_manager` - or for a handle `AppState` keeps inside a container -
/// `hls_proxy <- hls.proxy`. The context names the handle by the role it
/// plays; the view still reads exactly the handle it names.
///
/// A context that holds a handle as a trait object says so with `as` -
/// `maintenance as Arc<dyn MaintenanceGate>`.
macro_rules! view_field {
($state:ident, $field:ident) => {
::core::clone::Clone::clone(&$state.$field)
@@ -63,12 +66,12 @@ macro_rules! view_field {
macro_rules! app_state_views {
($(
$(#[$meta:meta])*
$accessor:ident => $ctx:ty { $($field:ident $(<- $($source:ident).+)?),+ $(,)? }
$accessor:ident => $ctx:ty { $($field:ident $(<- $($source:ident).+)? $(as $cast:ty)?),+ $(,)? }
)+) => {
$(
impl AppStateView for $ctx {
fn from_app_state(state: &AppState) -> Self {
Self { $($field: view_field!(state, $field $(, $($source).+)?)),+ }
Self { $($field: view_field!(state, $field $(, $($source).+)?) $(as $cast)?),+ }
}
}
@@ -110,5 +113,6 @@ app_state_views! {
metadata_update_ctx => tuliprox_metadata::ctx::BoundMetadataUpdateCtx {
app_config, active_provider, connection_manager, events <- event_manager, playlists,
update_guard <- playlist_updates.update_guard, http_clients,
maintenance as std::sync::Arc<dyn tuliprox_core::model::MaintenanceGate>,
}
}
@@ -341,6 +341,7 @@ fn custom_video_asset(
CustomVideoStreamType::UserAccountExpired => response.user_account_expired.as_ref(),
CustomVideoStreamType::Provisioning => response.panel_api_provisioning.as_ref(),
CustomVideoStreamType::HlsSessionOrLeaseExpired => response.hls_session_or_lease_expired.as_ref(),
CustomVideoStreamType::Maintenance => response.maintenance.as_ref(),
}
}
+10 -3
View File
@@ -9,7 +9,7 @@
use futures::future::BoxFuture;
use std::sync::Arc;
use tokio::sync::Notify;
use tuliprox_core::model::ProviderHandle;
use tuliprox_core::model::{MaintenanceGate, ProviderHandle};
use tuliprox_dvr::recording::recording_capacity::{ProviderCapacity, RecordingCapacityPort};
use tuliprox_session::{ActiveProviderManager, ConnectionManager};
@@ -17,11 +17,16 @@ use tuliprox_session::{ActiveProviderManager, ConnectionManager};
pub struct ProviderCapacityAdapter {
active_provider: Arc<ActiveProviderManager>,
connection_manager: Arc<ConnectionManager>,
maintenance: Arc<dyn MaintenanceGate>,
}
impl ProviderCapacityAdapter {
pub fn new(active_provider: Arc<ActiveProviderManager>, connection_manager: Arc<ConnectionManager>) -> Arc<Self> {
Arc::new(Self { active_provider, connection_manager })
pub fn new<G: MaintenanceGate + 'static>(
active_provider: Arc<ActiveProviderManager>,
connection_manager: Arc<ConnectionManager>,
maintenance: Arc<G>,
) -> Arc<Self> {
Arc::new(Self { active_provider, connection_manager, maintenance })
}
}
@@ -40,4 +45,6 @@ impl RecordingCapacityPort for ProviderCapacityAdapter {
}
fn capacity_changed(&self) -> Arc<Notify> { self.connection_manager.capacity_notified() }
fn is_input_in_maintenance(&self, input_name: &str) -> bool { self.maintenance.is_in_maintenance(input_name) }
}
@@ -161,7 +161,9 @@ impl ActiveClientStreamState {
CustomVideoStreamType::ProviderConnectionsExhausted => Some(StreamMode::ProviderExhausted),
CustomVideoStreamType::LowPriorityPreempted => Some(StreamMode::LowPriorityPreempted),
CustomVideoStreamType::Provisioning => Some(StreamMode::Provisioning),
CustomVideoStreamType::UserAccountExpired | CustomVideoStreamType::HlsSessionOrLeaseExpired => None,
CustomVideoStreamType::UserAccountExpired
| CustomVideoStreamType::HlsSessionOrLeaseExpired
| CustomVideoStreamType::Maintenance => None,
}
}
@@ -70,6 +70,7 @@ pub(in crate::api::model::streams::active_client_stream::tests) fn create_test_a
recording_capacity: crate::api::model::recording_runtime::ProviderCapacityAdapter::new(
Arc::clone(&active_provider),
Arc::clone(&connection_manager),
crate::api::model::create_test_maintenance(""),
),
app_config: Arc::new(app_cfg),
http_clients: Arc::default(),
@@ -88,6 +89,7 @@ pub(in crate::api::model::streams::active_client_stream::tests) fn create_test_a
metadata_manager,
auth: crate::api::model::AuthState::for_tests(),
playlist_updates: crate::api::model::PlaylistUpdateControl::for_tests(),
maintenance: crate::api::model::create_test_maintenance(""),
})
}
@@ -102,6 +102,7 @@ pub(in crate::api::model::streams::active_client_stream::tests) fn create_test_a
recording_capacity: crate::api::model::recording_runtime::ProviderCapacityAdapter::new(
Arc::clone(&active_provider),
Arc::clone(&connection_manager),
crate::api::model::create_test_maintenance(""),
),
app_config: app_cfg,
http_clients: Arc::default(),
@@ -120,6 +121,7 @@ pub(in crate::api::model::streams::active_client_stream::tests) fn create_test_a
metadata_manager,
auth: crate::api::model::AuthState::for_tests(),
playlist_updates: crate::api::model::PlaylistUpdateControl::for_tests(),
maintenance: crate::api::model::create_test_maintenance(""),
})
}
@@ -177,6 +177,13 @@ ok_custom_stream_factory!(
"Streaming response hls session or lease expired"
);
ok_custom_stream_factory!(
create_maintenance_stream,
maintenance,
CustomVideoStreamType::Maintenance,
"Streaming response input maintenance"
);
pub fn create_panel_api_provisioning_stream_with_stop(
cfg: &AppConfig,
headers: &[(String, String)],
@@ -216,6 +223,7 @@ pub fn create_custom_video_stream_response(
CustomVideoStreamType::UserAccountExpired => create_user_account_expired_stream(config, &[]),
CustomVideoStreamType::Provisioning => create_panel_api_provisioning_stream(config, &[]),
CustomVideoStreamType::HlsSessionOrLeaseExpired => create_hls_session_or_lease_expired_stream(config, &[]),
CustomVideoStreamType::Maintenance => create_maintenance_stream(config, &[]),
} {
let mut builder = axum::response::Response::builder().status(status_code);
for (key, value) in headers {
@@ -327,6 +335,7 @@ mod tests {
user_account_expired: None,
panel_api_provisioning: None,
hls_session_or_lease_expired: None,
maintenance: None,
panel_api_provisioning_hls_segments: Vec::new(),
})));
app_cfg
@@ -368,6 +377,31 @@ mod tests {
assert!(matches!(stream_type, Some(CustomVideoStreamType::ChannelUnavailable)));
}
#[tokio::test]
async fn maintenance_factory_serves_its_own_clip() {
use super::create_maintenance_stream;
use crate::api::model::TransportStreamBuffer;
let mut ts_packet = vec![0_u8; 188];
ts_packet[0] = 0x47;
let app_cfg = create_test_app_config_with_channel_unavailable();
let mut current = (*app_cfg.custom_stream_response.load_full().expect("custom response set")).clone();
current.maintenance = Some(TransportStreamBuffer::new(ts_packet));
app_cfg.custom_stream_response.store(Some(Arc::new(current)));
let (_stream, info) = create_maintenance_stream(&app_cfg, &[]);
let (_headers, status, _url, stream_type) = info.expect("input maintenance clip should exist");
assert_eq!(status, StatusCode::OK);
assert!(matches!(stream_type, Some(CustomVideoStreamType::Maintenance)));
}
#[tokio::test]
async fn maintenance_without_clip_has_no_stream() {
let app_cfg = create_test_app_config_with_channel_unavailable();
let (stream, info) = super::create_maintenance_stream(&app_cfg, &[]);
assert!(stream.is_none() && info.is_none());
}
/// The `ok_custom_stream_factory!` macro generates a public factory for
/// each (field, stream type, description) tuple. Pick one representative
/// factory — `create_user_connections_exhausted_stream` — and verify the
@@ -512,6 +512,9 @@ pub(crate) async fn sync_panel_api_exp_dates(app_state: &Arc<AppState>) {
let sources = app_state.app_config.sources.load();
for input in &sources.inputs {
if app_state.maintenance.is_in_maintenance(&input.name) {
continue;
}
if sync_panel_api_for_input_on_boot(app_state, input, sources_path.as_path()).await {
any_change = true;
}
@@ -543,6 +546,9 @@ pub(crate) async fn sync_panel_api_alias_pool_for_target(app_state: &Arc<AppStat
let Some(input) = sources.get_input_by_name(input_name) else {
continue;
};
if app_state.maintenance.is_in_maintenance(&input.name) {
continue;
}
let Some(panel_cfg) = input.panel_api.as_ref() else {
continue;
};
@@ -99,6 +99,7 @@ const NOTIFIABLE_KINDS: EventKindMask = EventKindMask::new()
.with(EventKind::ActiveProvider)
.with(EventKind::RecordingChanged)
.with(EventKind::RecordingRulesChanged)
.with(EventKind::MaintenanceChanged)
.with(EventKind::UserCreated)
.with(EventKind::UserUpdated)
.with(EventKind::UserDeleted)
@@ -208,6 +209,15 @@ pub fn to_notification(message: &EventMessage) -> Option<NotificationEvent> {
let title = "Recording rules changed".to_string();
Some(NotificationEvent::new(id, title.clone(), title))
}
EventMessage::MaintenanceChanged(entries) => {
let title = if entries.is_empty() {
"No input in maintenance".to_string()
} else {
let names: Vec<&str> = entries.keys().map(String::as_str).collect();
format!("Inputs in maintenance: {}", names.join(", "))
};
Some(NotificationEvent::new(id, title.clone(), title))
}
// Events that already have a `MessageContent` shape reuse its
// builders: `from_content` is what built their title, body and
@@ -561,6 +571,7 @@ mod tests {
EventMessage::RecordingChanged,
EventMessage::RecordingProgress,
EventMessage::RecordingRulesChanged,
EventMessage::MaintenanceChanged(std::sync::Arc::default()),
EventMessage::InputMetadataUpdatesStarted("a".into()),
EventMessage::InputMetadataUpdatesFailed(MetadataUpdateFailure::new("a".into(), 1, false, None)),
EventMessage::InputMetadataUpdatesCompleted("a".into()),
@@ -705,6 +716,7 @@ mod tests {
EventMessage::ActiveProvider("provider-a".into(), 3),
EventMessage::RecordingChanged,
EventMessage::RecordingRulesChanged,
EventMessage::MaintenanceChanged(std::sync::Arc::default()),
];
for message in messages {
let event = to_notification(&message).expect("mapped");
@@ -726,6 +738,7 @@ mod tests {
EventMessage::ActiveProvider("a".into(), 1),
EventMessage::RecordingChanged,
EventMessage::RecordingRulesChanged,
EventMessage::MaintenanceChanged(std::sync::Arc::default()),
];
for message in messages {
let event = to_notification(&message).expect("mapped");
+1
View File
@@ -205,6 +205,7 @@ async fn run_playlist_update_inner(
.with_update_guard(app_state.playlist_updates.update_guard.clone())
.with_disabled_headers(app_state.get_disabled_headers())
.with_provider_manager(Arc::clone(&app_state.active_provider))
.with_maintenance(Arc::clone(&app_state.maintenance))
.with_metadata_manager(Arc::clone(&app_state.metadata_manager))
.with_acquired_permit(permit),
)
+24 -1
View File
@@ -64,8 +64,9 @@ pub fn exec_xtream_expiry_sync(app_state: &Arc<AppState>, cancel: &CancellationT
let mut state_dirty = false;
loop {
let now = Utc::now().timestamp();
let accounts = collect_accounts(&app_state);
let mut accounts = collect_accounts(&app_state);
state_dirty |= prune_state(&mut state, &accounts, now);
retain_queryable(&app_state, &mut accounts);
fetch_due_accounts(
&app_state,
&accounts,
@@ -255,6 +256,12 @@ fn prune_state(state: &mut ExpiryState, accounts: &[Account], now: i64) -> bool
+ state.cooldown_until.len()
}
/// An input in maintenance keeps its throttle state (pruning sees every account) but is
/// neither queried nor persisted; its accounts are picked up again afterwards.
fn retain_queryable(app_state: &AppState, accounts: &mut Vec<Account>) {
accounts.retain(|account| !app_state.maintenance.is_in_maintenance(&account.input_name));
}
fn collect_accounts(app_state: &AppState) -> Vec<Account> {
let sources = app_state.app_config.sources.load();
sources
@@ -514,6 +521,22 @@ mod tests {
}
}
#[tokio::test]
async fn accounts_of_inputs_in_maintenance_are_not_queried() -> Result<(), Box<dyn std::error::Error>> {
let (_dir, app, _path) = super::super::provider_account_state::tests::fixture(false).await?;
let mut accounts = super::collect_accounts(&app);
super::retain_queryable(&app, &mut accounts);
assert!(!accounts.is_empty(), "the fixture input is queried normally");
app.maintenance.add("root", None, 1).await?;
let mut accounts = super::collect_accounts(&app);
assert!(!accounts.is_empty(), "pruning still sees accounts of an input in maintenance");
super::retain_queryable(&app, &mut accounts);
assert!(accounts.is_empty(), "an input in maintenance is not contacted");
Ok(())
}
#[tokio::test]
async fn restored_pending_csv_expiry_updates_runtime_without_reload() -> Result<(), Box<dyn std::error::Error>> {
let (_dir, app, path) = super::super::provider_account_state::tests::fixture(true).await?;
+8 -2
View File
@@ -548,8 +548,14 @@ async fn start_in_cli_mode(cfg: Arc<AppConfig>, targets: Arc<ProcessTargets>) {
error!("Failed to build client {err}");
reqwest::Client::new()
});
// In CLI mode, we don't start background managers for events or providers
exec_processing(ProcessingRun::new(client, cfg, targets, shared::model::NoopSink)).await;
// In CLI mode, we don't start background managers for events or providers. Maintenance
// still applies: an input taken out of service is not fetched by a one-shot run either.
// Read-only: a one-shot run may share the storage directory with a running server.
let storage_dir = arc_swap::ArcSwapAny::load(cfg.config.as_ref()).storage_dir.clone();
let maintenance_path = std::path::PathBuf::from(storage_dir).join(tuliprox_core::model::MAINTENANCE_FILE);
let maintenance = Arc::new(tuliprox_core::model::MaintenanceRegistry::load_read_only(maintenance_path).await);
exec_processing(ProcessingRun::new(client, cfg, targets, shared::model::NoopSink).with_maintenance(maintenance))
.await;
}
async fn start_in_server_mode(cfg: Arc<AppConfig>, targets: Arc<ProcessTargets>) {
+3
View File
@@ -110,6 +110,8 @@ fn permission_name(p: Permission) -> &'static str {
Permission::RecordingCreate => "recording.create",
Permission::RecordingManage => "recording.manage",
Permission::RecordingDelete => "recording.delete",
Permission::MaintenanceRead => "maintenance.read",
Permission::MaintenanceWrite => "maintenance.write",
}
}
@@ -443,6 +445,7 @@ mod tests {
measured_bytes: 0,
completed_at: None,
notification_markers: Vec::new(),
provider_started: false,
group: None,
series_name: None,
deleting_previous_state: None,
+3 -1
View File
@@ -11,7 +11,7 @@ use log::{error, warn};
use rand::Rng;
use shared::{
defaults::{
CHANNEL_UNAVAILABLE, HLS_SESSION_OR_LEASE_EXPIRED, LOW_PRIORITY_PREEMPTED, PANEL_API_PROVISIONING,
CHANNEL_UNAVAILABLE, HLS_SESSION_OR_LEASE_EXPIRED, LOW_PRIORITY_PREEMPTED, MAINTENANCE, PANEL_API_PROVISIONING,
PANEL_API_PROVISIONING_HLS_SEGMENT_COUNT, PANEL_API_PROVISIONING_HLS_SEGMENT_PREFIX,
PROVIDER_CONNECTIONS_EXHAUSTED, USER_ACCOUNT_EXPIRED, USER_CONNECTIONS_EXHAUSTED,
},
@@ -509,6 +509,7 @@ impl AppConfig {
let user_account_expired = load_and_set_file(&path.join(USER_ACCOUNT_EXPIRED));
let panel_api_provisioning = load_and_set_file(&path.join(PANEL_API_PROVISIONING));
let hls_session_or_lease_expired = load_and_set_file(&path.join(HLS_SESSION_OR_LEASE_EXPIRED));
let maintenance = load_and_set_file(&path.join(MAINTENANCE));
let panel_api_provisioning_hls_segments = (0..PANEL_API_PROVISIONING_HLS_SEGMENT_COUNT)
.filter_map(|index| {
let filename = format!("{PANEL_API_PROVISIONING_HLS_SEGMENT_PREFIX}{index:03}.ts");
@@ -523,6 +524,7 @@ impl AppConfig {
user_account_expired,
panel_api_provisioning,
hls_session_or_lease_expired,
maintenance,
panel_api_provisioning_hls_segments,
})));
}
+1
View File
@@ -207,5 +207,6 @@ pub struct CustomStreamResponse {
pub user_account_expired: Option<TransportStreamBuffer>,
pub panel_api_provisioning: Option<TransportStreamBuffer>,
pub hls_session_or_lease_expired: Option<TransportStreamBuffer>,
pub maintenance: Option<TransportStreamBuffer>, // input taken out of service by an operator
pub panel_api_provisioning_hls_segments: Vec<TransportStreamBuffer>,
}
+390
View File
@@ -0,0 +1,390 @@
//! Inputs an operator has taken out of service.
//!
//! Read on every new stream request and every background provider job, written
//! a few times a day. Readers load one atomic pointer; writers serialize, persist
//! and only then publish, so a failed write never changes what readers see.
use arc_swap::ArcSwapOption;
use log::{error, warn};
use shared::model::{InputMaintenance, MaintenanceEntries};
use std::{path::PathBuf, sync::Arc};
pub const MAINTENANCE_FILE: &str = "maintenance.json";
/// The read side, for crates that must not depend on the registry itself.
pub trait MaintenanceGate: Send + Sync {
fn is_in_maintenance(&self, input_name: &str) -> bool;
}
/// `Ok(Some(state))` when the call changed something, `Ok(None)` for a no-op.
pub type MaintenanceWrite = std::io::Result<Option<Arc<MaintenanceEntries>>>;
const REFUSE_UNREADABLE: &str = "maintenance state could not be read; it is not overwritten";
const REFUSE_READ_ONLY: &str = "maintenance state was loaded read-only";
pub struct MaintenanceRegistry {
/// `None` while no input is in maintenance, which keeps the common check to one load.
entries: ArcSwapOption<MaintenanceEntries>,
write_lock: tokio::sync::Mutex<()>,
/// Where the state is persisted; `None` keeps it in memory only.
path: Option<PathBuf>,
/// Why writes are refused, if they are.
refuse_writes: Option<&'static str>,
}
impl MaintenanceRegistry {
#[must_use]
pub fn new(path: PathBuf) -> Self { Self::with(Some(path), MaintenanceEntries::new(), None) }
/// A registry that never touches the file system.
#[must_use]
pub fn in_memory() -> Self { Self::with(None, MaintenanceEntries::new(), None) }
fn with(path: Option<PathBuf>, entries: MaintenanceEntries, refuse_writes: Option<&'static str>) -> Self {
Self {
entries: ArcSwapOption::new(non_empty(entries)),
write_lock: tokio::sync::Mutex::new(()),
path,
refuse_writes,
}
}
/// Loads the persisted state and drops inputs that no longer exist.
///
/// A corrupt file must not stop the server: it is moved aside and the
/// registry starts empty, which the dashboard card makes visible. A file that
/// cannot be read at all stays where it is and is never overwritten.
pub async fn load(path: PathBuf, known: &[&str]) -> Self {
let (entries, refuse_writes) = match tokio::fs::read(&path).await {
Ok(bytes) => match serde_json::from_slice::<MaintenanceEntries>(&bytes) {
Ok(entries) => (entries, None),
Err(err) => {
error!("Unreadable maintenance state {}: {err}", path.display());
let corrupt = path.with_extension("json.corrupt");
if let Err(err) = tokio::fs::rename(&path, &corrupt).await {
error!("Could not move {} aside: {err}", path.display());
}
(MaintenanceEntries::new(), None)
}
},
Err(err) if err.kind() == std::io::ErrorKind::NotFound => (MaintenanceEntries::new(), None),
Err(err) => {
error!("Could not read maintenance state {}: {err}; changes are refused", path.display());
(MaintenanceEntries::new(), Some(REFUSE_UNREADABLE))
}
};
let registry = Self::with(Some(path), entries, refuse_writes);
if registry.refuse_writes.is_none() {
if let Err(err) = registry.retain_known(known).await {
warn!("Could not persist pruned maintenance state: {err}");
}
}
registry
}
/// Loads the persisted state without ever changing the file, for a run that may
/// share the storage directory with a running server.
pub async fn load_read_only(path: PathBuf) -> Self {
let entries = match tokio::fs::read(&path).await {
Ok(bytes) => serde_json::from_slice::<MaintenanceEntries>(&bytes).unwrap_or_else(|err| {
warn!("Ignoring unreadable maintenance state {}: {err}", path.display());
MaintenanceEntries::new()
}),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => MaintenanceEntries::new(),
Err(err) => {
warn!("Could not read maintenance state {}: {err}", path.display());
MaintenanceEntries::new()
}
};
Self::with(Some(path), entries, Some(REFUSE_READ_ONLY))
}
#[inline]
pub fn is_in_maintenance(&self, input_name: &str) -> bool {
self.entries.load().as_ref().is_some_and(|entries| entries.contains_key(input_name))
}
/// The current state; an empty map when no input is in maintenance.
pub fn snapshot(&self) -> Arc<MaintenanceEntries> { self.entries.load_full().unwrap_or_default() }
pub async fn add(&self, name: &str, reason: Option<String>, now: i64) -> MaintenanceWrite {
self.mutate(|entries| upsert(entries, name, reason, now)).await
}
pub async fn add_all(&self, names: &[&str], reason: Option<String>, now: i64) -> MaintenanceWrite {
self.mutate(|entries| {
let mut changed = false;
for name in names {
if !entries.contains_key(*name) {
entries.insert((*name).to_string(), InputMaintenance { since: now, reason: reason.clone() });
changed = true;
}
}
changed
})
.await
}
pub async fn remove(&self, name: &str) -> MaintenanceWrite {
if !self.is_in_maintenance(name) {
return Ok(None);
}
self.mutate(|entries| entries.remove(name).is_some()).await
}
pub async fn remove_all(&self) -> MaintenanceWrite {
if self.entries.load().is_none() {
return Ok(None);
}
self.mutate(|entries| {
let changed = !entries.is_empty();
entries.clear();
changed
})
.await
}
/// Drops entries whose input is gone. Checks the snapshot first, so a reload
/// that keeps every input takes no lock and does no IO.
pub async fn retain_known(&self, known: &[&str]) -> MaintenanceWrite {
let has_orphans = self
.entries
.load()
.as_ref()
.is_some_and(|entries| entries.keys().any(|name| !known.contains(&name.as_str())));
if !has_orphans {
return Ok(None);
}
self.mutate(|entries| {
let before = entries.len();
entries.retain(|name, _| known.contains(&name.as_str()));
entries.len() != before
})
.await
}
async fn mutate(&self, apply: impl FnOnce(&mut MaintenanceEntries) -> bool) -> MaintenanceWrite {
if let Some(reason) = self.refuse_writes {
return Err(std::io::Error::other(reason));
}
let _guard = self.write_lock.lock().await;
let mut entries = self.entries.load().as_deref().cloned().unwrap_or_default();
if !apply(&mut entries) {
return Ok(None);
}
if let Some(path) = &self.path {
let encoded = serde_json::to_vec(&entries).map_err(std::io::Error::other)?;
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
crate::utils::write_json_atomic(path, &encoded).await?;
}
let published = Arc::new(entries);
self.entries.store((!published.is_empty()).then(|| Arc::clone(&published)));
Ok(Some(published))
}
}
impl MaintenanceGate for MaintenanceRegistry {
#[inline]
fn is_in_maintenance(&self, input_name: &str) -> bool { Self::is_in_maintenance(self, input_name) }
}
fn non_empty(entries: MaintenanceEntries) -> Option<Arc<MaintenanceEntries>> {
(!entries.is_empty()).then(|| Arc::new(entries))
}
fn upsert(entries: &mut MaintenanceEntries, name: &str, reason: Option<String>, now: i64) -> bool {
match entries.get_mut(name) {
Some(existing) if existing.reason == reason => false,
Some(existing) => {
existing.reason = reason;
true
}
None => {
entries.insert(name.to_string(), InputMaintenance { since: now, reason });
true
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn registry_in(dir: &TempDir) -> MaintenanceRegistry { MaintenanceRegistry::new(dir.path().join(MAINTENANCE_FILE)) }
#[tokio::test]
async fn empty_registry_reports_nothing_in_maintenance() {
let dir = TempDir::new().unwrap();
let registry = registry_in(&dir);
assert!(!registry.is_in_maintenance("provider_a"));
assert!(registry.snapshot().is_empty());
}
#[tokio::test]
async fn add_persists_and_publishes() {
let dir = TempDir::new().unwrap();
let registry = registry_in(&dir);
let changed = registry.add("provider_a", Some("down".into()), 100).await.unwrap();
assert!(changed.is_some());
assert!(registry.is_in_maintenance("provider_a"));
assert!(!registry.is_in_maintenance("provider_b"));
let reloaded = MaintenanceRegistry::load(dir.path().join(MAINTENANCE_FILE), &["provider_a"]).await;
let entry = reloaded.snapshot().get("provider_a").cloned().unwrap();
assert_eq!(entry, InputMaintenance { since: 100, reason: Some("down".into()) });
}
#[tokio::test]
async fn re_add_updates_reason_and_keeps_since() {
let dir = TempDir::new().unwrap();
let registry = registry_in(&dir);
registry.add("provider_a", Some("first".into()), 100).await.unwrap();
registry.add("provider_a", Some("second".into()), 200).await.unwrap();
let entry = registry.snapshot().get("provider_a").cloned().unwrap();
assert_eq!(entry, InputMaintenance { since: 100, reason: Some("second".into()) });
}
#[tokio::test]
async fn re_add_with_same_reason_is_a_no_op() {
let dir = TempDir::new().unwrap();
let registry = registry_in(&dir);
registry.add("provider_a", None, 100).await.unwrap();
assert!(registry.add("provider_a", None, 200).await.unwrap().is_none());
}
#[tokio::test]
async fn add_all_keeps_existing_entries() {
let dir = TempDir::new().unwrap();
let registry = registry_in(&dir);
registry.add("provider_a", Some("old".into()), 100).await.unwrap();
registry.add_all(&["provider_a", "provider_b"], Some("bulk".into()), 200).await.unwrap();
let snapshot = registry.snapshot();
assert_eq!(snapshot["provider_a"], InputMaintenance { since: 100, reason: Some("old".into()) });
assert_eq!(snapshot["provider_b"], InputMaintenance { since: 200, reason: Some("bulk".into()) });
}
#[tokio::test]
async fn remove_and_remove_all_normalize_to_empty() {
let dir = TempDir::new().unwrap();
let registry = registry_in(&dir);
registry.add_all(&["provider_a", "provider_b"], None, 100).await.unwrap();
registry.remove("provider_a").await.unwrap();
assert!(!registry.is_in_maintenance("provider_a"));
assert!(registry.remove("provider_a").await.unwrap().is_none(), "second remove is a no-op");
let cleared = registry.remove_all().await.unwrap().unwrap();
assert!(cleared.is_empty());
assert!(registry.entries.load().is_none(), "empty state is stored as None");
}
#[tokio::test]
async fn retain_known_prunes_orphans_and_is_free_when_nothing_changes() {
let dir = TempDir::new().unwrap();
let registry = registry_in(&dir);
registry.add_all(&["provider_a", "provider_b"], None, 100).await.unwrap();
assert!(registry.retain_known(&["provider_a", "provider_b", "provider_c"]).await.unwrap().is_none());
let pruned = registry.retain_known(&["provider_a"]).await.unwrap().unwrap();
assert_eq!(pruned.keys().collect::<Vec<_>>(), vec!["provider_a"]);
}
#[tokio::test]
async fn failed_persist_leaves_runtime_unchanged() {
let dir = TempDir::new().unwrap();
// A non-empty directory where the file should be makes the atomic rename fail.
let path = dir.path().join(MAINTENANCE_FILE);
std::fs::create_dir(&path).unwrap();
std::fs::write(path.join("blocker"), b"x").unwrap();
let registry = MaintenanceRegistry::new(path);
assert!(registry.add("provider_a", None, 100).await.is_err());
assert!(!registry.is_in_maintenance("provider_a"));
}
#[tokio::test]
async fn load_of_missing_file_is_empty() {
let dir = TempDir::new().unwrap();
let registry = MaintenanceRegistry::load(dir.path().join(MAINTENANCE_FILE), &["provider_a"]).await;
assert!(registry.snapshot().is_empty());
}
#[tokio::test]
async fn load_moves_corrupt_file_aside() {
let dir = TempDir::new().unwrap();
let path = dir.path().join(MAINTENANCE_FILE);
std::fs::write(&path, b"{ not json").unwrap();
let registry = MaintenanceRegistry::load(path.clone(), &["provider_a"]).await;
assert!(registry.snapshot().is_empty());
assert!(!path.exists());
assert!(dir.path().join("maintenance.json.corrupt").exists());
}
#[tokio::test]
async fn load_prunes_unknown_inputs_and_persists_the_result() {
let dir = TempDir::new().unwrap();
let path = dir.path().join(MAINTENANCE_FILE);
std::fs::write(&path, br#"{"gone":{"since":1},"provider_a":{"since":2}}"#).unwrap();
let registry = MaintenanceRegistry::load(path.clone(), &["provider_a"]).await;
assert!(!registry.is_in_maintenance("gone"));
assert!(registry.is_in_maintenance("provider_a"));
let persisted: MaintenanceEntries = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(persisted.keys().collect::<Vec<_>>(), vec!["provider_a"]);
}
#[cfg(unix)]
#[tokio::test]
async fn unreadable_file_is_never_overwritten() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let path = dir.path().join(MAINTENANCE_FILE);
std::fs::write(&path, br#"{"provider_a":{"since":1}}"#).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::read(&path).is_ok() {
// Running as root: permissions do not stop reads, nothing to test.
return;
}
let registry = MaintenanceRegistry::load(path.clone(), &["provider_a"]).await;
let write = registry.add("provider_b", None, 2).await;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
assert!(write.is_err(), "state that could not be read must not be replaced");
assert_eq!(std::fs::read(&path).unwrap(), br#"{"provider_a":{"since":1}}"#);
}
#[tokio::test]
async fn read_only_load_never_touches_the_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join(MAINTENANCE_FILE);
std::fs::write(&path, br#"{"gone":{"since":1},"provider_a":{"since":2}}"#).unwrap();
let registry = MaintenanceRegistry::load_read_only(path.clone()).await;
assert!(registry.is_in_maintenance("provider_a"));
assert!(registry.add("provider_b", None, 3).await.is_err(), "a read-only registry refuses writes");
assert_eq!(std::fs::read(&path).unwrap(), br#"{"gone":{"since":1},"provider_a":{"since":2}}"#);
let corrupt = dir.path().join("corrupt.json");
std::fs::write(&corrupt, b"{ not json").unwrap();
let registry = MaintenanceRegistry::load_read_only(corrupt.clone()).await;
assert!(registry.snapshot().is_empty());
assert!(corrupt.exists(), "a read-only load never moves the file aside");
}
#[tokio::test]
async fn in_memory_registry_works_without_any_file() {
let registry = MaintenanceRegistry::in_memory();
assert!(registry.add("provider_a", None, 1).await.unwrap().is_some());
assert!(registry.is_in_maintenance("provider_a"));
assert!(registry.remove_all().await.unwrap().is_some());
}
#[tokio::test]
async fn gate_trait_delegates() {
let dir = TempDir::new().unwrap();
let registry = Arc::new(registry_in(&dir));
registry.add("provider_a", None, 1).await.unwrap();
let gate: Arc<dyn MaintenanceGate> = registry;
assert!(gate.is_in_maintenance("provider_a"));
}
}
+4 -3
View File
@@ -18,6 +18,7 @@ pub mod auth_rejection;
pub mod batch_result_collector;
pub mod custom_stream_flags;
pub mod fingerprint;
pub mod maintenance;
pub mod playback;
pub mod playlist_filter;
pub mod playlist_key;
@@ -34,8 +35,8 @@ pub mod xtream_response;
pub use self::{
auth_rejection::*, batch_result_collector::*, config::*, custom_stream_flags::*, fingerprint::*, healthcheck::*,
http_clients::*, input_source::*, mapping::*, messaging::*, notification::*, playback::*, playlist_filter::*,
playlist_key::*, provider::*, proxy_redirect::*, stalker_record::*, stream_error::*, stream_history::*,
target_bouquet::*, update_guard::*, update_quality::*, update_task::*, xmltv::*, xtream::*,
http_clients::*, input_source::*, maintenance::*, mapping::*, messaging::*, notification::*, playback::*,
playlist_filter::*, playlist_key::*, provider::*, proxy_redirect::*, stalker_record::*, stream_error::*,
stream_history::*, target_bouquet::*, update_guard::*, update_quality::*, update_task::*, xmltv::*, xtream::*,
};
pub use shared::model::xtream_const::*;
+1
View File
@@ -71,6 +71,7 @@ pub(crate) fn make_test_meta(
measured_bytes: 0,
completed_at: None,
notification_markers: vec![],
provider_started: false,
group: None,
series_name: None,
deleting_previous_state: None,
@@ -34,6 +34,9 @@ pub trait RecordingCapacityPort: Send + Sync {
/// Fires when a connection is freed anywhere, so waiters can re-check.
fn capacity_changed(&self) -> Arc<Notify>;
/// Whether the input is in maintenance; a recording on it must not start.
fn is_input_in_maintenance(&self, input_name: &str) -> bool;
}
#[cfg(test)]
@@ -53,6 +56,7 @@ pub mod stub {
/// Counts what the worker asked for, and hands out slots on request.
pub struct StubCapacity {
capacities: Mutex<Vec<ProviderCapacity>>,
maintenance: Mutex<Vec<String>>,
notify: Arc<Notify>,
pub acquires: AtomicUsize,
pub releases: AtomicUsize,
@@ -74,6 +78,7 @@ pub mod stub {
fn with_capacity(in_use: usize, limit: usize) -> Arc<Self> {
Arc::new(Self {
capacities: Mutex::new(vec![(Arc::from("provider"), in_use, limit)]),
maintenance: Mutex::new(Vec::new()),
notify: Arc::new(Notify::new()),
acquires: AtomicUsize::new(0),
releases: AtomicUsize::new(0),
@@ -96,6 +101,15 @@ pub mod stub {
pub fn acquire_count(&self) -> usize { self.acquires.load(Ordering::SeqCst) }
pub fn release_count(&self) -> usize { self.releases.load(Ordering::SeqCst) }
/// Marks an input as in maintenance (or not) for the next acquire decision.
pub fn set_maintenance(&self, input_name: &str, on: bool) {
let mut names = self.maintenance.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
names.retain(|name| name != input_name);
if on {
names.push(input_name.to_string());
}
}
}
impl RecordingCapacityPort for StubCapacity {
@@ -117,6 +131,14 @@ pub mod stub {
}
fn capacity_changed(&self) -> Arc<Notify> { Arc::clone(&self.notify) }
fn is_input_in_maintenance(&self, input_name: &str) -> bool {
self.maintenance
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.iter()
.any(|name| name == input_name)
}
}
}
@@ -247,6 +247,7 @@ mod tests {
measured_bytes: measured,
completed_at: None,
notification_markers: Vec::new(),
provider_started: false,
group: None,
series_name: None,
deleting_previous_state: None,
@@ -308,6 +308,7 @@ impl RecordingQueue {
task.state = next;
task.retry_attempts = 0;
task.next_retry_at = None;
task.recording.provider_started = false;
candidate.queue.push(task);
Ok(Some(true))
} else {
@@ -356,6 +356,7 @@ mod tests {
measured_bytes: measured,
completed_at: None,
notification_markers: Vec::new(),
provider_started: false,
group: None,
series_name: None,
deleting_previous_state: None,
@@ -284,6 +284,7 @@ mod tests {
measured_bytes: 0,
completed_at: Some(completed_at),
notification_markers: Vec::new(),
provider_started: false,
group: None,
series_name: None,
deleting_previous_state: None,
@@ -250,6 +250,7 @@ impl RecordingService {
task.state = RecordingTaskState::Queued;
task.retry_attempts = 0;
task.next_retry_at = None;
task.recording.provider_started = false;
candidate.queue.push(task);
Ok(true)
},
@@ -28,6 +28,8 @@ const RECORDING_PROGRESS_UPDATE_INTERVAL: Duration = Duration::from_secs(5);
const LIVE_CAPACITY_WINDOW_CLOSED: &str = "No provider capacity became available before the recording window closed";
const MAINTENANCE_REASON: &str = "Input is in maintenance mode";
const DOWNLOAD_PREEMPTED_REASON: &str = "Preempted by higher-priority foreground stream";
const RECORDING_PREEMPTED_REASON: &str =
@@ -22,6 +22,8 @@ pub(super) enum ProviderAcquireResult {
Preempted,
/// A live capture waited for capacity until its broadcast window closed.
WindowClosed,
/// The input is in maintenance; the recording must not contact its provider.
Maintenance,
}
pub(super) fn background_download_should_wait(
@@ -104,6 +106,7 @@ pub(super) async fn commit_acquired_download(
active.error = None;
active.paused = false;
active.finished = false;
active.recording.provider_started = true;
let notification = mark_recording_metadata_notification(&mut active.recording, LifecycleEvent::Started, None);
Ok(Some(notification))
})
@@ -1,10 +1,10 @@
use super::{
acquire_result_after_wait, acquire_result_for_control, continue_after_pause, download_file,
ensure_recording_worker_running, finalize_http_transfer, finish_active_and_promote, http_transfer_path,
recording_deadline_instant, refresh_recording_progress, requeue_active_download_for_capacity_wait,
start_recording_scheduler, wait_for_provider_slot, DownloadExecutionResult, ProviderAcquireResult, QuotaGate,
RecordingNotificationPlan, DISK_GONE_BEFORE_START, DOWNLOAD_PREEMPTED_REASON, LIVE_CAPACITY_WINDOW_CLOSED,
QUOTA_EXCEEDED_DURING_TRANSFER,
acquire_result_after_wait, acquire_result_for_control, commit_acquired_download, continue_after_pause,
download_file, ensure_recording_worker_running, finalize_http_transfer, finish_active_and_promote,
http_transfer_path, recording_deadline_instant, refresh_recording_progress,
requeue_active_download_for_capacity_wait, start_recording_scheduler, wait_for_provider_slot,
DownloadExecutionResult, ProviderAcquireResult, QuotaGate, RecordingNotificationPlan, DISK_GONE_BEFORE_START,
DOWNLOAD_PREEMPTED_REASON, LIVE_CAPACITY_WINDOW_CLOSED, MAINTENANCE_REASON, QUOTA_EXCEEDED_DURING_TRANSFER,
};
mod admission;
@@ -4,7 +4,8 @@ use super::{
app_config_with_listener, bare_app_config, counting_ffmpeg, download_file, ensure_recording_worker_running,
finish_active_and_promote, requeue_active_download_for_capacity_wait, scheduled_task, serve_range_fixture,
slot_queue, spawn_count, vod_entry, wait_for_provider_slot, DownloadExecutionResult, QuotaGate,
RecordingNotificationPlan, DOWNLOAD_PREEMPTED_REASON, LIVE_CAPACITY_WINDOW_CLOSED, QUOTA_EXCEEDED_DURING_TRANSFER,
RecordingNotificationPlan, DOWNLOAD_PREEMPTED_REASON, LIVE_CAPACITY_WINDOW_CLOSED, MAINTENANCE_REASON,
QUOTA_EXCEEDED_DURING_TRANSFER,
};
use crate::recording::{
recording_capacity::{stub::StubCapacity, RecordingCapacityPort},
@@ -425,3 +426,113 @@ async fn a_live_capture_asks_for_capacity_once_runs_the_encoder_once_and_complet
assert!(!crate::recording_partial_path(&capture.file_path).exists(), "and nothing was left staged");
assert_eq!(settled.recording.reserved_bytes, 0, "and it is not still holding disk");
}
async fn start_worker_with(stub: &Arc<StubCapacity>, queue: &Arc<RecordingQueue>) {
let capacity: Arc<dyn RecordingCapacityPort> = Arc::clone(stub) as Arc<dyn RecordingCapacityPort>;
ensure_recording_worker_running(
&bare_app_config(),
&RecordingConfig::from(&shared::model::RecordingConfigDto {
enabled: true,
..shared::model::RecordingConfigDto::default()
}),
queue,
&NoopSink,
&capacity,
Path::new(crate::recording::recording_worker::FFMPEG_BINARY),
)
.await
.expect("worker started");
}
async fn queued_live_capture(dir: &TempDir) -> Arc<RecordingQueue> { queued_live_capture_with(dir, false).await }
async fn queued_live_capture_with(dir: &TempDir, provider_started: bool) -> Arc<RecordingQueue> {
let queue = Arc::new(RecordingQueue::new_persistent(dir.path(), dir.path()).expect("open repository"));
let now = chrono::Utc::now().timestamp();
let mut capture = scheduled_task(RecordingKind::Live, now, 1_800);
capture.uuid = "live".to_string();
capture.state = RecordingTaskState::Queued;
capture.input_name = Some(Arc::from("provider"));
capture.recording.provider_started = provider_started;
let capture = RecordingQueue::to_persisted(&capture);
crate::recording::recording_queue::mutate(&queue, move |candidate| {
candidate.queue.push(capture.clone());
Ok(())
})
.await
.expect("seed");
queue
}
#[tokio::test(start_paused = true)]
async fn a_capture_on_an_input_in_maintenance_fails_without_asking_for_a_slot() {
let dir = TempDir::new().expect("tempdir");
let queue = queued_live_capture(&dir).await;
let stub = StubCapacity::with_room();
stub.set_maintenance("provider", true);
start_worker_with(&stub, &queue).await;
tokio::time::sleep(Duration::from_secs(5)).await;
let finished = queue.finished.read().await;
let settled = finished.first().expect("the capture reached a terminal state");
assert_eq!(settled.state, RecordingTaskState::Failed);
assert_eq!(settled.error.as_deref(), Some(MAINTENANCE_REASON), "and it says why");
assert_eq!(stub.acquire_count(), 0, "no provider slot was requested");
assert_eq!(stub.release_count(), 0);
}
#[tokio::test(start_paused = true)]
async fn maintenance_on_another_input_does_not_block_the_capture() {
let dir = TempDir::new().expect("tempdir");
let queue = queued_live_capture(&dir).await;
let stub = StubCapacity::with_room();
stub.set_maintenance("some-other-input", true);
start_worker_with(&stub, &queue).await;
tokio::time::sleep(Duration::from_secs(5)).await;
assert_eq!(stub.acquire_count(), 1, "the capture asked for its slot as before");
}
#[tokio::test(start_paused = true)]
async fn a_capture_that_already_ran_keeps_retrying_during_maintenance() {
// A retry or a reload requeue of a recording that already ran is not a new start:
// running recordings, including their reconnects, are not affected by maintenance.
let dir = TempDir::new().expect("tempdir");
let queue = queued_live_capture_with(&dir, true).await;
let stub = StubCapacity::with_room();
stub.set_maintenance("provider", true);
start_worker_with(&stub, &queue).await;
tokio::time::sleep(Duration::from_secs(5)).await;
assert_eq!(stub.acquire_count(), 1, "the retry asked for its slot as before");
let finished = queue.finished.read().await;
assert!(
finished.iter().all(|task| task.error.as_deref() != Some(MAINTENANCE_REASON)),
"a running recording is never failed for maintenance"
);
}
#[tokio::test(start_paused = true)]
async fn a_capture_waiting_for_capacity_fails_when_maintenance_starts() {
let dir = TempDir::new().expect("tempdir");
let queue = queued_live_capture(&dir).await;
let stub = StubCapacity::full();
start_worker_with(&stub, &queue).await;
tokio::time::sleep(Duration::from_secs(5)).await;
assert_eq!(stub.acquire_count(), 1, "waiting for a slot");
stub.set_maintenance("provider", true);
// What the capacity bridge does when a slot frees up somewhere.
for waiter in queue.slot_waiters.snapshots() {
let _ = queue.slot_waiters.signal_waiter(waiter.id);
}
tokio::time::sleep(Duration::from_secs(5)).await;
let finished = queue.finished.read().await;
let settled = finished.first().expect("the capture reached a terminal state");
assert_eq!(settled.error.as_deref(), Some(MAINTENANCE_REASON));
assert_eq!(stub.acquire_count(), 1, "no further slot was requested");
}
@@ -1,9 +1,9 @@
use super::{
acquire_result_after_wait, acquire_result_for_control, app_config_with_listener, bare_app_config,
continue_after_pause, download_file, finalize_http_transfer, finish_active_and_promote, http_transfer_path,
read_request, recording_deadline_instant, requeue_active_download_for_capacity_wait, scheduled_task,
serve_range_fixture, slot_queue, start_recording_scheduler, wait_for_provider_slot, DownloadExecutionResult,
ProviderAcquireResult, RecordingNotificationPlan,
commit_acquired_download, continue_after_pause, download_file, finalize_http_transfer, finish_active_and_promote,
http_transfer_path, read_request, recording_deadline_instant, requeue_active_download_for_capacity_wait,
scheduled_task, serve_range_fixture, slot_queue, start_recording_scheduler, wait_for_provider_slot,
DownloadExecutionResult, ProviderAcquireResult, RecordingNotificationPlan,
};
#[cfg(unix)]
use super::{counting_ffmpeg, ensure_recording_worker_running};
@@ -99,6 +99,25 @@ async fn a_restart_requeue_consumes_the_restart_signal() {
assert_eq!(*queue.worker("task").control_signal.read().await, RecordingControl::None);
}
#[tokio::test]
async fn a_preempted_capture_stays_started_across_its_requeue() {
// Maintenance refuses only new starts; a capture that already held a provider slot
// must still count as started once a preemption put it back into the queue.
let queue = RecordingQueue::new();
*queue.active.write().await = vec![scheduled_task(RecordingKind::Live, 0, 60)];
assert!(!queue.active.read().await[0].recording.provider_started);
commit_acquired_download(&queue, "task").await.expect("commit");
assert!(queue.active.read().await[0].recording.provider_started);
assert!(requeue_active_download_for_capacity_wait(&queue, "task", "preempted", false, None)
.await
.expect("requeue"));
let queued = queue.queue.lock().await;
let task = queued.iter().find(|task| task.uuid == "task").expect("requeued task");
assert!(task.recording.provider_started);
}
#[test]
fn a_slot_wait_maps_onto_the_acquisition_outcome() {
// A wait that ends without an outcome ran into the live window's end;
@@ -238,6 +238,8 @@ impl RecordingCapacityPort for LimitedCapacity {
}
fn capacity_changed(&self) -> Arc<Notify> { self.notify.clone() }
fn is_input_in_maintenance(&self, _input_name: &str) -> bool { false }
}
#[cfg(unix)]
@@ -8,7 +8,7 @@ use super::{
requeue_active_download_for_retry, set_active_download_state, should_exit_worker_after_preempt,
spawn_recording_notification_after_persist, take_active, wait_for_provider_slot, write_completion_sidecar,
ProviderAcquireResult, ProviderCapacities, QuotaGate, RetryCommit, DOWNLOAD_PREEMPTED_REASON,
LIVE_CAPACITY_WINDOW_CLOSED, RECORDING_PROGRESS_UPDATE_INTERVAL,
LIVE_CAPACITY_WINDOW_CLOSED, MAINTENANCE_REASON, RECORDING_PROGRESS_UPDATE_INTERVAL,
};
use crate::recording::{
recording_capacity::RecordingCapacityPort,
@@ -147,14 +147,14 @@ async fn start_recording_worker<E: EventSink + Clone + 'static>(
'worker: loop {
// One read: the uuid, pause flag and live window must describe
// the same task.
let active_head = dq
.active
.read()
.await
.iter()
.find(|task| task.uuid == worker_uuid)
.map(|download| (download.paused, recording_deadline_instant(download)));
if let Some((paused, window_deadline)) = active_head {
let active_head =
dq.active.read().await.iter().find(|task| task.uuid == worker_uuid).map(|download| {
// A retry or a requeue of a recording that already ran is not a new
// start; maintenance only refuses new ones.
let first_start = !download.recording.provider_started;
(download.paused, recording_deadline_instant(download), first_start)
});
if let Some((paused, window_deadline, first_start)) = active_head {
if paused {
break;
}
@@ -176,6 +176,11 @@ async fn start_recording_worker<E: EventSink + Clone + 'static>(
// concurrent recording starts respect background limits.
let _capacity = dq.capacity_guard.lock().await;
let capacities = capacity.capacities_for_input(&input_name).await;
// Checked right before every slot request, after every await
// of this attempt, so nothing has to be released.
if first_start && capacity.is_input_in_maintenance(&input_name) {
break ProviderAcquireResult::Maintenance;
}
let live_config = app_config.config.load();
let capacity_cfg = live_config.recording().unwrap_or(&download_cfg);
if background_download_should_wait(priority, &capacities, capacity_cfg) {
@@ -259,6 +264,17 @@ async fn start_recording_worker<E: EventSink + Clone + 'static>(
}
continue;
}
ProviderAcquireResult::Maintenance => {
if let Err(err) = broadcast_required_worker_mutation(
&event_manager,
fail_active_download(&dq, &worker_uuid, MAINTENANCE_REASON).await,
"input maintenance",
) {
error!("Download worker commit failed: {err}");
break 'worker;
}
continue;
}
ProviderAcquireResult::Paused => {
break;
}
@@ -342,6 +342,7 @@ mod tests {
measured_bytes: measured,
completed_at: Some(completed_at),
notification_markers: Vec::new(),
provider_started: false,
group: None,
series_name: None,
deleting_previous_state: None,
+1
View File
@@ -118,6 +118,7 @@ fn runtime_custom_responses_with_low_priority(low_priority_bytes: &[u8]) -> Arc<
user_account_expired: Some(TransportStreamBuffer::new(ACCOUNT_EXPIRED_ASSET_BYTES.to_vec())),
panel_api_provisioning: None,
hls_session_or_lease_expired: Some(TransportStreamBuffer::new(SESSION_EXPIRED_ASSET_BYTES.to_vec())),
maintenance: None,
panel_api_provisioning_hls_segments: Vec::new(),
})
}
+43 -50
View File
@@ -1040,64 +1040,57 @@ pub async fn finish_hls_origin_account_io(
refresh_reservation: bool,
) {
let binding = guard.binding().clone();
let mut provider_resource_to_release = None;
let mut session = session.write().await;
let now_ms = chrono::Utc::now().timestamp_millis().try_into().unwrap_or_default();
let confirmed_ttl_secs = session.activity.last_delivered_media_at_ms.map_or(0, |last| {
last.saturating_add(origin_io.reservation_ttl_secs.saturating_mul(1000)).saturating_sub(now_ms).div_ceil(1000)
});
// Decrement directly on guard.lease, not session.origin_account_io_lease.
// The session may have been rebound or replaced between I/O start and finish.
// Only clear the session reference if it still points to this exact lease instance.
guard.disarm();
let mut should_refresh_reservation = false;
let confirmed_ttl_secs;
{
let mut session = session.write().await;
let now_ms = chrono::Utc::now().timestamp_millis().try_into().unwrap_or_default();
confirmed_ttl_secs = session.activity.last_delivered_media_at_ms.map_or(0, |last| {
last.saturating_add(origin_io.reservation_ttl_secs.saturating_mul(1000))
.saturating_sub(now_ms)
.div_ceil(1000)
});
// Decrement directly on guard.lease, not session.origin_account_io_lease.
// The session may have been rebound or replaced between I/O start and finish.
// Only clear the session reference if it still points to this exact lease instance.
guard.disarm();
if guard.lease.active_io_count.fetch_sub(1, std::sync::atomic::Ordering::AcqRel) == 1 {
provider_resource_to_release = guard.lease.take_resource();
if session
.origin_account_io_lease
.as_ref()
.is_some_and(|session_lease| std::sync::Arc::ptr_eq(session_lease, &guard.lease))
{
session.origin_account_io_lease = None;
}
if refresh_reservation && confirmed_ttl_secs > 0 {
should_refresh_reservation = true;
}
if guard.lease.active_io_count.fetch_sub(1, std::sync::atomic::Ordering::AcqRel) == 1 {
let provider_resource_to_release = guard.lease.take_resource();
if session
.origin_account_io_lease
.as_ref()
.is_some_and(|session_lease| std::sync::Arc::ptr_eq(session_lease, &guard.lease))
{
session.origin_account_io_lease = None;
}
if let Some(current) = session.origin_account_binding.as_mut().filter(|current| {
current.is_active()
&& current.account_name == binding.account_name
&& current.session_owner == binding.session_owner
&& current.generation == binding.generation
}) {
let now_ms = chrono::Utc::now().timestamp_millis().try_into().unwrap_or_default();
current.last_origin_io_at_ms = Some(now_ms);
should_refresh_reservation = refresh_reservation && confirmed_ttl_secs > 0;
if let Some((provider_handle, cleanup_permit)) = provider_resource_to_release {
if should_refresh_reservation {
current.last_reservation_refresh_at_ms = Some(now_ms);
if let Some(request_id) = binding.playback_request_id {
origin_io.ctx.active_provider.refresh_playback_lease(
&binding.account_name,
&PlaybackLeaseRef { owner: &binding.session_owner, kind: PlaybackKind::LiveHls, request_id },
confirmed_ttl_secs,
);
}
}
// Released synchronously and still under the session lock: the next I/O of
// this session reserves its slot under the same lock, so it can never see the
// lease gone while the provider still counts the old connection. A deferred
// release lets it race for a new slot, which an account at its connection
// limit answers with `Exhausted`.
origin_io.ctx.connection_manager.release_provider_handle(Some(provider_handle));
drop(cleanup_permit);
}
}
if let Some((provider_handle, cleanup_permit)) = provider_resource_to_release {
let mut handle_guard = ManagedHandleGuard::new(provider_handle, cleanup_permit);
if let Some(current) = session.origin_account_binding.as_mut().filter(|current| {
current.is_active()
&& current.account_name == binding.account_name
&& current.session_owner == binding.session_owner
&& current.generation == binding.generation
}) {
current.last_origin_io_at_ms = Some(now_ms);
if should_refresh_reservation {
if let Some(request_id) = binding.playback_request_id {
origin_io.ctx.active_provider.refresh_playback_lease(
&binding.account_name,
&PlaybackLeaseRef { owner: &binding.session_owner, kind: PlaybackKind::LiveHls, request_id },
confirmed_ttl_secs,
);
}
}
if let Some((handle, permit)) = handle_guard.take_resource() {
permit.send(CleanupEvent::ReleaseProviderHandle { handle: Some(handle) });
current.last_reservation_refresh_at_ms = Some(now_ms);
}
}
}
+1
View File
@@ -945,6 +945,7 @@ pub(in crate::refresh::tests) fn critical_handoff_app_config() -> Arc<AppConfig>
user_account_expired: None,
panel_api_provisioning: None,
hls_session_or_lease_expired: None,
maintenance: None,
panel_api_provisioning_hls_segments: Vec::new(),
})));
app_config
+2 -1
View File
@@ -101,7 +101,8 @@ impl HlsRuntimeCustomTailReason {
CustomVideoStreamType::ProviderConnectionsExhausted => Some(Self::ProviderConnectionsExhausted),
CustomVideoStreamType::UserAccountExpired => Some(Self::UserAccountExpired),
CustomVideoStreamType::HlsSessionOrLeaseExpired => Some(Self::SessionOrLeaseExpired),
CustomVideoStreamType::Provisioning => None,
// Maintenance only answers new entries; it never ends a running session as a tail.
CustomVideoStreamType::Provisioning | CustomVideoStreamType::Maintenance => None,
}
}
}
+3 -1
View File
@@ -8,7 +8,7 @@
use shared::model::EventSink;
use std::sync::Arc;
use tuliprox_core::model::{AppConfig, HttpClients, UpdateGuard};
use tuliprox_core::model::{AppConfig, HttpClients, MaintenanceGate, UpdateGuard};
use tuliprox_repository::PlaylistStorageState;
use tuliprox_session::{ActiveProviderManager, ConnectionManager, EventManager};
@@ -29,6 +29,8 @@ pub struct MetadataUpdateCtx<E: EventSink> {
pub update_guard: UpdateGuard,
/// Outbound clients, swapped when the proxy configuration changes.
pub http_clients: Arc<HttpClients>,
/// Inputs in maintenance; their tasks wait instead of contacting the provider.
pub maintenance: Arc<dyn MaintenanceGate>,
}
/// The instantiation the long-lived worker stores.
+2
View File
@@ -17,6 +17,8 @@ const TASK_ERR_PREEMPTED: &str = "Task preempted";
const TASK_ERR_UPDATE_IN_PROGRESS: &str = "Playlist update in progress";
const TASK_ERR_MAINTENANCE: &str = "Input in maintenance";
// Per-task execution timeout. ffprobe defaults to 60 s; allow extra headroom for network
// fetches, TMDB lookups, and B+Tree writes before declaring the task stuck.
const PROBE_TASK_TIMEOUT_SECS: u64 = 30;
+6 -3
View File
@@ -1,8 +1,8 @@
use super::{
load_metadata_retry_states_from_disk, persist_metadata_retry_state_to_disk, spawn_blocking_limited, InputWorker,
MetadataUpdateManager, MetadataUpdateRuntimeSettings, ScopedTaskKey, TaskKey, METADATA_RETRY_STATE_FILE,
RETRY_STATE_MIN_TTL_SECS, RETRY_STATE_PRUNE_INTERVAL_SECS, TASK_ERR_NO_CONNECTION, TASK_ERR_PREEMPTED,
TASK_ERR_UPDATE_IN_PROGRESS,
RETRY_STATE_MIN_TTL_SECS, RETRY_STATE_PRUNE_INTERVAL_SECS, TASK_ERR_MAINTENANCE, TASK_ERR_NO_CONNECTION,
TASK_ERR_PREEMPTED, TASK_ERR_UPDATE_IN_PROGRESS,
};
use crate::ctx::MetadataUpdateCtx;
use log::warn;
@@ -451,7 +451,10 @@ impl InputWorker {
}
pub(super) fn is_transient_worker_error(message: &str) -> bool {
message == TASK_ERR_UPDATE_IN_PROGRESS || message == TASK_ERR_PREEMPTED || message == TASK_ERR_NO_CONNECTION
message == TASK_ERR_UPDATE_IN_PROGRESS
|| message == TASK_ERR_PREEMPTED
|| message == TASK_ERR_NO_CONNECTION
|| message == TASK_ERR_MAINTENANCE
}
#[inline]
@@ -1,5 +1,5 @@
use super::{
spawn_blocking_limited, DbHandle, InputWorker, RetryDomain, TaskKey, PROBE_TASK_TIMEOUT_SECS,
spawn_blocking_limited, DbHandle, InputWorker, RetryDomain, TaskKey, PROBE_TASK_TIMEOUT_SECS, TASK_ERR_MAINTENANCE,
TASK_ERR_NO_CONNECTION, TASK_ERR_PREEMPTED, TASK_ERR_UPDATE_IN_PROGRESS,
};
use crate::ctx::MetadataUpdateCtx;
@@ -117,6 +117,11 @@ impl InputWorker {
return Err(shared::error::TuliproxError::Config(format!("Input {input_name} is disabled")));
}
// Waits instead of failing: the task is requeued and resolved once maintenance ends.
if ctx.maintenance.is_in_maintenance(&input_base.name) {
return Err(shared::error::TuliproxError::Config(TASK_ERR_MAINTENANCE.to_string()));
}
// Background metadata/probe tasks are low-priority.
// Never run them while a foreground playlist update is active.
if let Some(guard) = ctx.update_guard.try_playlist() {
+21 -1
View File
@@ -1,6 +1,7 @@
use super::{
create_test_worker, InputWorker, MetadataUpdateManager, MetadataUpdateRuntimeSettings, PendingTask, RetryDomain,
ScopedTaskKey, SubmitTaskResult, TaskKey, TASK_ERR_NO_CONNECTION, TASK_ERR_PREEMPTED, TASK_ERR_UPDATE_IN_PROGRESS,
ScopedTaskKey, SubmitTaskResult, TaskKey, TASK_ERR_MAINTENANCE, TASK_ERR_NO_CONNECTION, TASK_ERR_PREEMPTED,
TASK_ERR_UPDATE_IN_PROGRESS,
};
use dashmap::DashMap;
use shared::{
@@ -824,3 +825,22 @@ fn playlist_trigger_keeps_info_changes() {
assert!(InputWorker::should_trigger_playlist_update_for_task(&task, true));
assert!(!InputWorker::should_trigger_playlist_update_for_task(&task, false));
}
#[test]
fn maintenance_is_a_transient_worker_error() {
// Transient errors are requeued without counting an attempt, so the item is
// resolved after maintenance ends instead of being marked as failed.
assert!(InputWorker::is_transient_worker_error(TASK_ERR_MAINTENANCE));
}
#[test]
fn maintenance_check_precedes_probe_connection() {
let source = include_str!("../task_processing.rs");
let start = source.find("pub(super) async fn process_task_static").expect("process_task_static");
let body = &source[start..];
// Methods are indented once, so their closing brace is `\n }\n`.
let body = &body[..body.find("\n }\n").expect("function end")];
let gate = body.find("if ctx.maintenance.is_in_maintenance(").expect("maintenance gate");
let probe = body.find("acquire_connection_for_probe(").expect("probe acquisition");
assert!(gate < probe, "no provider connection may be taken for an input in maintenance");
}
+33 -1
View File
@@ -11,7 +11,7 @@ use std::{
path::{Path, PathBuf},
};
use tuliprox_core::{
model::{ConfigInput, EpgSource, EpgSourceType, PersistedEpgSource, PersistedEpgSourceKind},
model::{ConfigInput, EpgConfig, EpgSource, EpgSourceType, PersistedEpgSource, PersistedEpgSourceKind},
utils::{add_prefix_to_filename, prepare_file_path, request},
};
use tuliprox_repository::get_input_storage_path;
@@ -191,6 +191,9 @@ pub async fn get_xmltv<E: EventSink + Clone + 'static, M: MetadataUpdateSink>(
) -> (Option<TVGuide>, Vec<TuliproxError>) {
match &input.epg {
None => (None, vec![]),
Some(epg_config) if ctx.is_input_in_maintenance(&input.name) => {
persisted_epg_for_maintenance(ctx, input, epg_config, storage_dir).await
}
Some(epg_config) => {
let mut errors = vec![];
let mut file_paths = vec![];
@@ -226,6 +229,35 @@ pub async fn get_xmltv<E: EventSink + Clone + 'static, M: MetadataUpdateSink>(
}
}
/// The EPG files fetched last, without contacting any source and without cleanup:
/// deleting "unlisted" files here would throw away exactly what maintenance keeps.
async fn persisted_epg_for_maintenance<E: EventSink + Clone + 'static, M: MetadataUpdateSink>(
ctx: &PlaylistProcessingContext<E, M>,
input: &ConfigInput,
epg_config: &EpgConfig,
storage_dir: &str,
) -> (Option<TVGuide>, Vec<TuliproxError>) {
debug!("Input '{}' is in maintenance; using its persisted epg", input.name);
let mut errors = vec![];
let mut file_paths = vec![];
for epg_source in &epg_config.sources {
let Ok(path) = get_input_raw_epg_file_path(epg_source, input, storage_dir).await else {
continue;
};
if tokio::fs::metadata(&path).await.is_ok_and(|metadata| metadata.is_file()) {
match persisted_source_from_config(epg_source, path) {
Ok(persisted) => file_paths.push(persisted),
Err(err) => errors.push(err),
}
}
}
if file_paths.is_empty() {
(None, errors)
} else {
(Some(TVGuide::new(file_paths).with_file_locks(std::sync::Arc::clone(&ctx.config.file_locks))), errors)
}
}
fn persisted_source_from_config(
epg_source: &EpgSource,
file_path: PathBuf,
@@ -114,6 +114,8 @@ pub struct PlaylistProcessingContext<E: EventSink, M: MetadataUpdateSink = NoopM
/// Optional request-local behavior for one manually selected input.
pub input_refresh: Option<InputRefreshOverride>,
pub(crate) library_update_mode: LibraryUpdateMode,
/// Inputs in maintenance: their persisted state is reused, their provider is not contacted.
pub maintenance: Option<Arc<dyn tuliprox_core::model::MaintenanceGate>>,
}
#[cfg(test)]
@@ -306,6 +306,12 @@ pub(crate) async fn download_input<E: EventSink + Clone + 'static, M: MetadataUp
if already_processed {
// Use empty results, will load from disk below
in_run_reuse_playlist_download_result()
} else if ctx.is_input_in_maintenance(&input.name) {
// Keep serving what was fetched last; the provider is out of service. This also
// covers the Stalker refresh, which only runs inside the provider download.
debug!("Input '{}' is in maintenance; using its persisted playlist", input.name);
mark_as_processed = true;
cached_playlist_download_result(input, refresh_policy)
} else if ctx.pre_processed_inputs.as_ref().is_some_and(|s| s.contains(&input.name)) {
// Input was already processed in a prior session; skip download and load from disk.
// Mark only after load succeeds (or fails) to avoid exposing a half-ready state.
@@ -335,7 +341,12 @@ pub(crate) async fn download_input<E: EventSink + Clone + 'static, M: MetadataUp
// Defensive fallback: if cache metadata says "valid" but persisted data is unreadable,
// retry once before forcing a refresh.
let must_force_refresh = cached_error.is_some();
if must_force_refresh {
if must_force_refresh && ctx.is_input_in_maintenance(&input.name) {
// A refresh would contact the provider; without a persisted playlist the input
// contributes nothing until maintenance ends.
warn!("Input '{}' is in maintenance and has no readable persisted playlist; skipping it", input.name);
preloaded_playlist = Some((MemoryPlaylistSource::default().into_source(), None));
} else if must_force_refresh {
warn!("Input '{}' cache hit produced unreadable playlist; retrying cached load once", input.name);
let (retry_playlist, retry_error) = load_cached_input_playlist(ctx, input).await;
if retry_error.is_none() {
@@ -505,6 +516,12 @@ async fn download_m3u_alias_playlists<E: EventSink + Clone + 'static, M: Metadat
input: &ConfigInput,
) -> Vec<TuliproxError> {
let Some(aliases) = input.get_enabled_aliases() else { return vec![] };
// Maintenance is keyed by the parent input; an alias carries its own name and would
// otherwise contact the provider. Its persisted playlist stays in place.
if ctx.is_input_in_maintenance(&input.name) {
debug!("Input '{}' is in maintenance; skipping its M3U alias downloads", input.name);
return vec![];
}
let mut errors = Vec::new();
for alias in aliases {
@@ -466,11 +466,16 @@ impl<E: EventSink + Clone, M: MetadataUpdateSink> Clone for PlaylistProcessingCo
had_quality_rejections: Arc::clone(&self.had_quality_rejections),
input_refresh: self.input_refresh,
library_update_mode: self.library_update_mode,
maintenance: self.maintenance.clone(),
}
}
}
impl<E: EventSink + Clone + 'static, M: MetadataUpdateSink> PlaylistProcessingContext<E, M> {
pub fn is_input_in_maintenance(&self, input_name: &str) -> bool {
self.maintenance.as_ref().is_some_and(|gate| gate.is_in_maintenance(input_name))
}
#[must_use]
pub fn refresh_policy(&self, input_id: u16) -> InputRefreshPolicy {
self.input_refresh
@@ -146,6 +146,7 @@ pub struct ProcessingRun<
acquired_permit: Option<tuliprox_core::model::UpdateGuardPermit>,
input_refresh: Option<InputRefreshOverride>,
library_update_mode: LibraryUpdateMode,
maintenance: Option<Arc<dyn tuliprox_core::model::MaintenanceGate>>,
}
impl<E: EventSink + Clone + 'static> ProcessingRun<E, NoBootstrap, NoopMetadataSink> {
@@ -178,6 +179,7 @@ impl<E: EventSink + Clone + 'static> ProcessingRun<E, NoBootstrap, NoopMetadataS
acquired_permit: None,
input_refresh: None,
library_update_mode: LibraryUpdateMode::ExistingCatalog,
maintenance: None,
}
}
}
@@ -205,6 +207,7 @@ impl<E: EventSink + Clone + 'static, B: UpdateBootstrap, M: MetadataUpdateSink>
acquired_permit: self.acquired_permit,
input_refresh: self.input_refresh,
library_update_mode: self.library_update_mode,
maintenance: self.maintenance,
}
}
@@ -255,6 +258,7 @@ impl<E: EventSink + Clone + 'static, B: UpdateBootstrap, M: MetadataUpdateSink>
acquired_permit: self.acquired_permit,
input_refresh: self.input_refresh,
library_update_mode: self.library_update_mode,
maintenance: self.maintenance,
}
}
@@ -291,6 +295,13 @@ impl<E: EventSink + Clone + 'static, B: UpdateBootstrap, M: MetadataUpdateSink>
self
}
/// Inputs taken out of service; their provider is not contacted during this run.
#[must_use]
pub fn with_maintenance<G: tuliprox_core::model::MaintenanceGate + 'static>(mut self, gate: Arc<G>) -> Self {
self.maintenance = Some(gate);
self
}
/// Applies one manual policy override without changing the input configuration.
#[must_use]
pub fn with_input_refresh(mut self, input_refresh: impl Into<Option<InputRefreshOverride>>) -> Self {
@@ -319,6 +330,7 @@ pub async fn exec_processing<E: EventSink + Clone + 'static, B: UpdateBootstrap,
acquired_permit,
input_refresh,
library_update_mode,
maintenance,
} = run;
let max_update_duration = Duration::from_secs(PLAYLIST_UPDATE_MAX_DURATION_SECS);
@@ -396,6 +408,7 @@ pub async fn exec_processing<E: EventSink + Clone + 'static, B: UpdateBootstrap,
had_quality_rejections: Arc::new(std::sync::atomic::AtomicBool::new(false)),
input_refresh,
library_update_mode,
maintenance,
};
let start_time = Instant::now();
@@ -609,6 +609,10 @@ pub(crate) async fn playlist_resolve<E: EventSink + Clone + 'static, M: Metadata
provider_fpl: &mut FetchedPlaylist<'_>,
processed_fpl: &mut FetchedPlaylist<'_>,
) {
// Resolve and probe talk to the provider; an input in maintenance keeps what it has.
if ctx.is_input_in_maintenance(&processed_fpl.input.name) {
return;
}
playlist_resolve_series(ctx, target, errors, pipe, provider_fpl, processed_fpl).await;
playlist_resolve_vod(ctx, target, errors, provider_fpl, processed_fpl).await;
playlist_probe(ctx, target, processed_fpl).await;
@@ -73,6 +73,7 @@ pub(super) fn processing_context(
stalker_refresh_mode: StalkerRefreshMode::Complete,
partial_refresh: Arc::new(std::sync::atomic::AtomicBool::new(false)),
had_quality_rejections: Arc::new(std::sync::atomic::AtomicBool::new(false)),
maintenance: None,
input_refresh: None,
library_update_mode: LibraryUpdateMode::ExistingCatalog,
}
@@ -97,6 +97,7 @@ fn processing_context() -> PlaylistProcessingContext<shared::model::NoopSink> {
stalker_refresh_mode: StalkerRefreshMode::Complete,
partial_refresh: Arc::new(std::sync::atomic::AtomicBool::new(false)),
had_quality_rejections: Arc::new(std::sync::atomic::AtomicBool::new(false)),
maintenance: None,
}
}
@@ -240,6 +240,7 @@ fn processing_context_with_events<E: EventSink>(storage_dir: &Path, events: E) -
stalker_refresh_mode: StalkerRefreshMode::Complete,
partial_refresh: Arc::new(AtomicBool::new(false)),
had_quality_rejections: Arc::new(AtomicBool::new(false)),
maintenance: None,
}
}
@@ -1617,3 +1618,104 @@ mod m3u_update_quality;
mod manual_update_integration;
mod persisted_input_status;
mod staged_completion;
struct FixedGate(&'static str);
impl tuliprox_core::model::MaintenanceGate for FixedGate {
fn is_in_maintenance(&self, input_name: &str) -> bool { input_name == self.0 }
}
fn quality_95() -> ConfigInputUpdateQualityDto { ConfigInputUpdateQualityDto { live: 95, vod: 95, series: 95 } }
#[tokio::test]
async fn maintenance_input_uses_persisted_playlist_without_download() {
let temp = tempfile::tempdir().expect("temporary storage");
let server = TestXtreamServer::start([2, 2, 2]);
let mut ctx = processing_context(temp.path());
// cache_duration_seconds = 0: without maintenance this input is downloaded.
let input = test_input(&server.base_url, quality_95(), 0, &[]);
seed_baseline(&ctx, &input).await;
ctx.maintenance = Some(Arc::new(FixedGate("quality-provider")));
let mut result = download_input(&ctx, &input, false).await;
assert!(result.errors.is_empty(), "maintenance must not fail the input: {:?}", result.errors);
let baseline_channels: usize = baseline_groups().iter().map(|group| group.channels.len()).sum();
assert_eq!(result.source.get_channel_count(), baseline_channels, "the persisted playlist is served");
assert_requested_actions(server.finish(), &[]);
}
#[tokio::test]
async fn maintenance_input_without_cache_contributes_nothing() {
let temp = tempfile::tempdir().expect("temporary storage");
let server = TestXtreamServer::start([2, 2, 2]);
let mut ctx = processing_context(temp.path());
let input = test_input(&server.base_url, quality_95(), 0, &[]);
ctx.maintenance = Some(Arc::new(FixedGate("quality-provider")));
let mut result = download_input(&ctx, &input, false).await;
assert!(result.errors.is_empty(), "a missing cache is not an error in maintenance: {:?}", result.errors);
assert_eq!(result.source.get_channel_count(), 0);
assert_requested_actions(server.finish(), &[]);
}
#[tokio::test]
async fn input_not_in_maintenance_still_downloads() {
let temp = tempfile::tempdir().expect("temporary storage");
let server = TestXtreamServer::start([2, 2, 2]);
let mut ctx = processing_context(temp.path());
let input = test_input(&server.base_url, quality_95(), 0, &[]);
seed_baseline(&ctx, &input).await;
ctx.maintenance = Some(Arc::new(FixedGate("some-other-input")));
let mut result = download_input(&ctx, &input, false).await;
assert!(result.errors.is_empty(), "{:?}", result.errors);
assert_eq!(result.source.get_channel_count(), 6);
assert!(!server.finish().is_empty(), "the provider was contacted");
}
#[tokio::test]
async fn maintenance_input_skips_epg_download_and_keeps_existing_file() {
use tuliprox_core::model::{EpgConfig, EpgSource, EpgSourceType};
let temp = tempfile::tempdir().expect("temporary storage");
let server = TestXtreamServer::start([2, 2, 2]);
let mut ctx = processing_context(temp.path());
let mut input = (*test_input(&server.base_url, quality_95(), 0, &[])).clone();
let epg_source = EpgSource {
source_type: EpgSourceType::Xmltv,
url: format!("{}/xmltv.php", server.base_url),
priority: 0,
logo_override: false,
channel_id: None,
channel_title: None,
match_names: Vec::new(),
ics: None,
};
input.epg = Some(EpgConfig { sources: vec![epg_source.clone()], smart_match: None });
let storage_dir = temp.path().to_string_lossy().into_owned();
let epg_path =
crate::epg::get_input_raw_epg_file_path(&epg_source, &input, &storage_dir).await.expect("epg file path");
tokio::fs::write(&epg_path, "<tv></tv>").await.expect("seed persisted epg");
ctx.maintenance = Some(Arc::new(FixedGate("quality-provider")));
let (guide, errors) = crate::epg::get_xmltv(&ctx, &input, None, &storage_dir).await;
assert!(errors.is_empty(), "{errors:?}");
assert!(guide.is_some(), "the persisted epg is used");
assert!(epg_path.exists(), "maintenance must not clean up the persisted epg");
assert_requested_actions(server.finish(), &[]);
}
#[test]
fn resolve_stage_checks_maintenance_before_any_provider_work() {
let source = include_str!("../target.rs");
let start = source.find("pub(crate) async fn playlist_resolve<").expect("playlist_resolve");
let body = &source[start..];
let body = &body[..body.find("\n}\n").expect("function end")];
let gate = body.find("if ctx.is_input_in_maintenance(").expect("maintenance gate in playlist_resolve");
let series = body.find("playlist_resolve_series(").expect("series resolve call");
assert!(gate < series, "the gate must run before resolve and probe");
}
+4 -1
View File
@@ -269,7 +269,8 @@ playlist_manager:playlist.read,playlist.write,source.read
Available Permissions: `config.read/write`, `source.read/write`, `user.read/write`, `playlist.read/write`,
`library.read/write`,
`system.read/write`, `epg.read/write`, `recording.read/create/manage/delete`. Note: Write does not imply Read. A group must explicitly
`system.read/write`, `epg.read/write`, `recording.read/create/manage/delete`, `maintenance.read/write`.
Note: Write does not imply Read. A group must explicitly
grant both if users need
to view
and edit content.
@@ -481,6 +482,7 @@ negative pattern does, so `["*", "!system.info"]` reads the way it looks.
| `user.connection.denied` | warn | A user was refused a connection because their limits were full. |
| `recording.queue.changed` | info | The recording queue changed. |
| `recording.rules.changed` | info | The recording rule set changed. |
| `maintenance.changed` | warn | An input was put into or taken out of maintenance mode. |
| `scheduled_task.failed` | error | A scheduled task could not complete. |
| `notification.dead_lettered` | error | A notification was permanently undeliverable and has been dropped. |
| `user.created` | info | An API-proxy user account was created. |
@@ -1197,6 +1199,7 @@ custom_stream_response_timeout_secs: 20
* `user_account_expired.ts` (User's `exp_date` reached)
* `panel_api_provisioning.ts` (Loops while a new Provider Account is generated via Panel API)
* `hls_session_or_lease_expired.ts` (Shared-HLS session or access lease expired; restart stream)
* `maintenance.ts` (The input is in maintenance mode; see [Maintenance mode](../rest-api-cookbook.md#maintenance-mode))
> **Note**: These Video files are all available in the docker image.
+3 -1
View File
@@ -37,6 +37,7 @@ That makes it usable both for IPTV players and for media-server-oriented workflo
- enforce user connection limits
- prioritize higher-value sessions over lower-priority traffic
- serve custom fallback videos for failure cases
- put inputs into maintenance mode: new streams get a maintenance video, the provider is not contacted
- persist optional stream history for per-variant connect/disconnect, startup-failure, and reconnect telemetry
- aggregate optional QoS snapshots from stream history to rank equivalent channel variants by reliability
@@ -63,7 +64,8 @@ Tuliprox also includes:
Tuliprox supports role-based access control (RBAC) for the Web UI:
- fine-grained permissions across 7 domains (config, source, user, playlist, library, system, epg)
- fine-grained permissions across 9 domains (config, source, user, playlist, library, system, epg, recording,
maintenance)
- custom groups with configurable permission sets
- user-to-group assignments with union-based permission resolution
- compact bitmask encoding in JWT claims for low-overhead permission checks
+6
View File
@@ -428,6 +428,12 @@ constant; pre-bump tokens become stale:
Operators do **not** need to manually invalidate tokens on a schema bump; the next sign-in issues a
token with the current schema and the user's derived `subject_id`.
## 10.1 Maintenance mode
A recording whose input is in maintenance when it would start is not started: it fails with
`Input is in maintenance mode` without requesting a provider slot. Running recordings, including their internal
reconnects, continue. Nothing is retried automatically when maintenance ends; use **Retry** on the failed task.
## 11. The removed `/file/record` route
`POST /file/record` and the whole `/api/v1/file/download/*` family are
+62
View File
@@ -349,6 +349,58 @@ Notes:
are `null` for semantic errors without a source position (e.g. an invalid regex value).
- The preview reads the target's already-processed playlist; it never contacts providers or triggers an update.
## Maintenance mode
An input can be taken out of service while its provider is down. While an input is in maintenance:
- every **new** stream request for it (live, VOD, series, catch-up, HLS entry, redirect mode) gets the
`maintenance.ts` custom video instead of a provider connection,
- playlist and EPG updates keep serving the last persisted playlist and EPG without contacting the provider,
- probes, metadata resolution and Stalker refreshes for the input are paused,
- DVR recordings that would start fail with `Input is in maintenance mode`; use **Retry** afterwards.
Running streams, seeks within them, and running recordings continue. The state is stored in
`<storage_dir>/maintenance.json` and survives restarts. A corrupt file is moved aside as
`maintenance.json.corrupt` and the server starts with no input in maintenance. A file that cannot be read at all
(for example because of its permissions) stays in place; changes are then refused with `500` instead of overwriting
it. A one-shot CLI update reads the file but never changes it. Entries for inputs that are removed
from `source.yml` are dropped on the next source reload.
The dashboard shows a **Maintenance** card that turns red while any input is in maintenance.
```bash
#!/bin/bash
BASE_URL="http://localhost:8901"
TOKEN="PUT_YOUR_TOKEN_HERE"
# Put one input into maintenance, with an optional remark
curl -s -X PUT "$BASE_URL/api/v1/maintenance/provider_a" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data-raw '{"reason":"Provider down"}'
# Take every input out of maintenance (`*`, URL-encoded as %2A)
curl -s -X DELETE "$BASE_URL/api/v1/maintenance/%2A" -H "Authorization: Bearer $TOKEN"
# Current state
curl -s "$BASE_URL/api/v1/maintenance" -H "Authorization: Bearer $TOKEN"
```
Every call answers with the complete state:
```json
{
"entries": { "provider_a": { "since": 1760350000, "reason": "Provider down" } },
"inputs": ["provider_a", "provider_b"]
}
```
- `*` addresses all inputs known at the time of the call; an input literally named `*` cannot be addressed alone.
- Putting an input that is already in maintenance updates its remark and keeps `since`.
- Unknown inputs answer `404`, remarks longer than 500 characters `400`.
- Without Web UI authentication the endpoints are as open as the other `/api/v1` routes.
## Available `/api/v1` Endpoints
This is a compact operator-oriented overview of the `/api/v1` REST API groups currently registered by the backend.
@@ -369,6 +421,14 @@ This is a compact operator-oriented overview of the `/api/v1` REST API groups cu
| `GET` | `/api/v1/qos-snapshots/{stream_identity_key}` | QoS detail for one stream |
| `GET` | `/api/v1/geoip/update` | Trigger GeoIP DB update |
### Maintenance mode
| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/api/v1/maintenance` | Inputs in maintenance and all known input names (`maintenance.read`) |
| `PUT` | `/api/v1/maintenance/{input}` | Put an input (or `*` for all) into maintenance, optional `{"reason": "..."}` body (`maintenance.write`) |
| `DELETE` | `/api/v1/maintenance/{input}` | Take an input (or `*` for all) out of maintenance (`maintenance.write`) |
### Recordings
Recording REST is **commands only**. There is no list, task, status or quota
@@ -659,6 +719,8 @@ With Web UI authentication enabled, many endpoints require matching permissions
- `recording.create`
- `recording.manage`
- `recording.delete`
- `maintenance.read`
- `maintenance.write`
If a request is rejected, verify the logged-in Web UI user's RBAC group assignments first.
+19
View File
@@ -869,6 +869,10 @@
"SKIP_LIVE_DIRECT_SOURCE": "إذا تم التفعيل، لا يتم ربط التدفقات المباشرة مباشرة ولكن يتم تحويلها/حلها.",
"SKIP_SERIES_DIRECT_SOURCE": "إذا تم التفعيل، لا يتم ربط حلقات المسلسلات مباشرة ولكن يتم تحويلها/حلها.",
"SKIP_VIDEO_DIRECT_SOURCE": "إذا تم التفعيل، لا يتم ربط ملفات VOD مباشرة ولكن يتم تحويلها/حلها."
},
"MAINTENANCE": {
"INPUT": "المدخل الذي يتم إيقافه عن الخدمة. أثناء الصيانة تحصل البثوث الجديدة على فيديو الصيانة ولا يتصل Tuliprox بمزوده؛ تستمر البثوث والتسجيلات الجارية.",
"REASON": "ملاحظة اختيارية تظهر في قائمة الصيانة، مثل سبب تعطل المزود. 500 حرف كحد أقصى."
}
},
"HINT": {
@@ -942,6 +946,15 @@
"ADD_PROPERTY": "إضافة خاصية",
"ADD_PROVIDER": "إضافة مزود",
"ADD_QUALITY_TO_FILENAME": "الجودة",
"MAINTENANCE": "الصيانة",
"MAINTENANCE_ADD_ALL": "الكل إلى الصيانة",
"MAINTENANCE_COUNT": "{count} مدخلات قيد الصيانة",
"MAINTENANCE_COUNT_ONE": "مدخل واحد قيد الصيانة",
"MAINTENANCE_NONE": "لا يوجد مدخل قيد الصيانة",
"MAINTENANCE_REASON": "ملاحظة",
"MAINTENANCE_REMOVE_ALL": "إنهاء كل الصيانة",
"MAINTENANCE_SINCE": "منذ",
"MANAGE": "إدارة",
"USE_METADATA": "استخدام البيانات الوصفية",
"ADD_SERVER": "إضافة خادم",
"ADD_STRIP_ENTRY": "إضافة إدخال تجريد",
@@ -1286,6 +1299,7 @@
"MPEG_TS": "MPEG-TS",
"MSG_KIND_ERROR": "خطأ",
"MSG_KIND_INFO": "معلومات",
"MSG_KIND_MAINTENANCE_CHANGED": "تغيير وضع صيانة المدخل",
"MSG_KIND_LIBRARY_SCAN_COMPLETED": "اكتمل فحص المكتبة",
"MSG_KIND_LIBRARY_SCAN_FAILED": "فشل فحص المكتبة",
"MSG_KIND_METADATA_UPDATE_COMPLETED": "اكتمل تحديث البيانات الوصفية",
@@ -1941,6 +1955,11 @@
"MESSAGE": "أدخل بيانات اعتمادك",
"FAILED": "فشل تسجيل الدخول"
},
"MAINTENANCE": {
"LOAD_FAILED": "تعذر تحميل حالة الصيانة",
"REASON_TOO_LONG": "يمكن أن تحتوي الملاحظة على 500 حرف كحد أقصى",
"SAVE_FAILED": "تعذر حفظ حالة الصيانة"
},
"SESSION": {
"EXPIRED": "انتهت جلستك. يرجى تسجيل الدخول مرة أخرى."
},
+19
View File
@@ -869,6 +869,10 @@
"SKIP_LIVE_DIRECT_SOURCE": "If enabled, live streams are not linked directly but proxied/resolved.",
"SKIP_SERIES_DIRECT_SOURCE": "If enabled, series episodes are not linked directly but proxied/resolved.",
"SKIP_VIDEO_DIRECT_SOURCE": "If enabled, VOD files are not linked directly but proxied/resolved."
},
"MAINTENANCE": {
"INPUT": "Input to take out of service. While it is in maintenance, new streams get the maintenance video and Tuliprox does not contact its provider; running streams and recordings continue.",
"REASON": "Optional remark shown in the maintenance list, for example why the provider is down. At most 500 characters."
}
},
"HINT": {
@@ -940,6 +944,15 @@
"ADD_PROPERTY": "Add Property",
"ADD_PROVIDER": "Add Provider",
"ADD_QUALITY_TO_FILENAME": "Quality",
"MAINTENANCE": "Maintenance",
"MAINTENANCE_ADD_ALL": "All into maintenance",
"MAINTENANCE_COUNT": "{count} inputs in maintenance",
"MAINTENANCE_COUNT_ONE": "1 input in maintenance",
"MAINTENANCE_NONE": "No input in maintenance",
"MAINTENANCE_REASON": "Remark",
"MAINTENANCE_REMOVE_ALL": "End all maintenance",
"MAINTENANCE_SINCE": "Since",
"MANAGE": "Manage",
"USE_METADATA": "Use metadata",
"ADD_SERVER": "Add Server",
"ADD_PLAN": "Add Plan",
@@ -1287,6 +1300,7 @@
"MPEG_TS": "MPEG-TS",
"MSG_KIND_ERROR": "Error",
"MSG_KIND_INFO": "Info",
"MSG_KIND_MAINTENANCE_CHANGED": "Maintenance Changed",
"MSG_KIND_LIBRARY_SCAN_COMPLETED": "Library Scan Completed",
"MSG_KIND_LIBRARY_SCAN_FAILED": "Library Scan Failed",
"MSG_KIND_METADATA_UPDATE_COMPLETED": "Metadata Update Completed",
@@ -1941,6 +1955,11 @@
"MESSAGE": "Enter Your Credentials",
"FAILED": "Failed to login"
},
"MAINTENANCE": {
"LOAD_FAILED": "Could not load maintenance state",
"REASON_TOO_LONG": "The remark may have at most 500 characters",
"SAVE_FAILED": "Could not save maintenance state"
},
"SESSION": {
"EXPIRED": "Your session has expired. Please log in again."
},
+19
View File
@@ -869,6 +869,10 @@
"SKIP_LIVE_DIRECT_SOURCE": "Si está habilitado, los streams en directo no se enlazan directamente sino que se proxyfican/resuelven.",
"SKIP_SERIES_DIRECT_SOURCE": "Si está habilitado, los episodios de series no se enlazan directamente sino que se proxyfican/resuelven.",
"SKIP_VIDEO_DIRECT_SOURCE": "Si está habilitado, los archivos VOD no se enlazan directamente sino que se proxyfican/resuelven."
},
"MAINTENANCE": {
"INPUT": "Entrada que se retira del servicio. Mientras está en mantenimiento, las nuevas reproducciones reciben el vídeo de mantenimiento y Tuliprox no contacta con su proveedor; las reproducciones y grabaciones en curso continúan.",
"REASON": "Observación opcional que se muestra en la lista de mantenimiento, por ejemplo por qué el proveedor no está disponible. Máximo 500 caracteres."
}
},
"HINT": {
@@ -940,6 +944,15 @@
"ADD_PROPERTY": "Añadir propiedad",
"ADD_PROVIDER": "Añadir proveedor",
"ADD_QUALITY_TO_FILENAME": "Calidad",
"MAINTENANCE": "Mantenimiento",
"MAINTENANCE_ADD_ALL": "Todas a mantenimiento",
"MAINTENANCE_COUNT": "{count} entradas en mantenimiento",
"MAINTENANCE_COUNT_ONE": "1 entrada en mantenimiento",
"MAINTENANCE_NONE": "Ninguna entrada en mantenimiento",
"MAINTENANCE_REASON": "Observación",
"MAINTENANCE_REMOVE_ALL": "Terminar todo el mantenimiento",
"MAINTENANCE_SINCE": "Desde",
"MANAGE": "Gestionar",
"USE_METADATA": "Usar metadatos",
"ADD_SERVER": "Añadir servidor",
"ADD_PLAN": "Añadir plan",
@@ -1287,6 +1300,7 @@
"MPEG_TS": "MPEG-TS",
"MSG_KIND_ERROR": "Error",
"MSG_KIND_INFO": "Info",
"MSG_KIND_MAINTENANCE_CHANGED": "Mantenimiento de entrada cambiado",
"MSG_KIND_LIBRARY_SCAN_COMPLETED": "Escaneo de biblioteca completado",
"MSG_KIND_LIBRARY_SCAN_FAILED": "Escaneo de biblioteca fallido",
"MSG_KIND_METADATA_UPDATE_COMPLETED": "Actualización de metadatos completada",
@@ -1941,6 +1955,11 @@
"MESSAGE": "Introduzca sus credenciales",
"FAILED": "No se pudo iniciar sesión"
},
"MAINTENANCE": {
"LOAD_FAILED": "No se pudo cargar el estado de mantenimiento",
"REASON_TOO_LONG": "La observación puede tener como máximo 500 caracteres",
"SAVE_FAILED": "No se pudo guardar el estado de mantenimiento"
},
"SESSION": {
"EXPIRED": "Su sesión ha expirado. Por favor, inicie sesión de nuevo."
},
+19
View File
@@ -869,6 +869,10 @@
"SKIP_LIVE_DIRECT_SOURCE": "Если включено, прямые трансляции не связаны напрямую, а проксируются/разрешаются.",
"SKIP_SERIES_DIRECT_SOURCE": "Если включено, эпизоды сериалов не связаны напрямую, а проксируются/разрешаются.",
"SKIP_VIDEO_DIRECT_SOURCE": "Если включено, VOD-файлы не связаны напрямую, а проксируются/разрешаются."
},
"MAINTENANCE": {
"INPUT": "Вход, который выводится из эксплуатации. Пока он на обслуживании, новые потоки получают видео обслуживания, а Tuliprox не обращается к его провайдеру; текущие потоки и записи продолжаются.",
"REASON": "Необязательное примечание в списке обслуживания, например почему провайдер недоступен. Не более 500 символов."
}
},
"HINT": {
@@ -942,6 +946,15 @@
"ADD_PROPERTY": "Добавить свойство",
"ADD_PROVIDER": "Добавить провайдера",
"ADD_QUALITY_TO_FILENAME": "Качество",
"MAINTENANCE": "Обслуживание",
"MAINTENANCE_ADD_ALL": "Все на обслуживание",
"MAINTENANCE_COUNT": "Входов на обслуживании: {count}",
"MAINTENANCE_COUNT_ONE": "1 вход на обслуживании",
"MAINTENANCE_NONE": "Нет входов на обслуживании",
"MAINTENANCE_REASON": "Примечание",
"MAINTENANCE_REMOVE_ALL": "Завершить всё обслуживание",
"MAINTENANCE_SINCE": "С",
"MANAGE": "Управлять",
"USE_METADATA": "Использовать метаданные",
"ADD_SERVER": "Добавить сервер",
"ADD_STRIP_ENTRY": "Добавить запись для удаления",
@@ -1286,6 +1299,7 @@
"MPEG_TS": "MPEG-TS",
"MSG_KIND_ERROR": "Ошибка",
"MSG_KIND_INFO": "Информация",
"MSG_KIND_MAINTENANCE_CHANGED": "Изменён режим обслуживания входа",
"MSG_KIND_LIBRARY_SCAN_COMPLETED": "Сканирование библиотеки завершено",
"MSG_KIND_LIBRARY_SCAN_FAILED": "Ошибка сканирования библиотеки",
"MSG_KIND_METADATA_UPDATE_COMPLETED": "Обновление метаданных завершено",
@@ -1941,6 +1955,11 @@
"MESSAGE": "Введите свои учетные данные",
"FAILED": "Не удалось войти"
},
"MAINTENANCE": {
"LOAD_FAILED": "Не удалось загрузить состояние обслуживания",
"REASON_TOO_LONG": "Примечание может содержать не более 500 символов",
"SAVE_FAILED": "Не удалось сохранить состояние обслуживания"
},
"SESSION": {
"EXPIRED": "Ваша сессия истекла. Войдите снова."
},
+1
View File
@@ -17,6 +17,7 @@
@forward "components/preferences";
@forward "components/dashboard/action_card";
@forward "components/dashboard/credits_action_card";
@forward "components/dashboard/maintenance";
@forward "components/dashboard/status_card";
@forward "components/dashboard/sparkline";
@forward "components/dashboard/dashboard_view";
@@ -0,0 +1,121 @@
.tp__card--maintenance {
background-color: color-mix(in srgb, var(--theme-brand-danger) 22%, transparent);
border-color: var(--theme-brand-danger-strong);
}
// Doubled class: `.tp__custom-dialog` sets `width: auto` and `max-width: 90%` and must not win.
.tp__custom-dialog.tp__maintenance-dialog {
width: min(64rem, 94vw);
max-width: 94vw;
height: min(44rem, 86vh);
}
.tp__maintenance-dialog {
display: flex;
flex-flow: column;
gap: var(--gap-default);
padding: var(--padding-default);
box-sizing: border-box;
h2 {
margin: 0;
}
// Bordered panel like the watch form of a target: input, remark and the add button in one row.
&__form {
display: flex;
flex-flow: row nowrap;
align-items: flex-end;
gap: var(--gap-default);
flex-shrink: 0;
padding: var(--padding-default);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
> .tp__form-field {
flex: 1 1 0;
min-width: 0;
}
> .tp__input {
flex: 2 1 0;
min-width: 0;
}
}
// Same height as the input boxes next to it, so it lines up with them and not with the labels.
&__add.tp__icon-button {
flex: 0 0 auto;
height: 2.5rem;
width: 2.5rem;
.svg-icon {
height: 1.75rem;
width: 1.75rem;
}
}
// Many inputs in maintenance scroll here; title, form and toolbar stay in place.
&__list {
flex: 1 1 auto;
min-height: 8rem;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
}
&__empty {
margin: 0;
padding: var(--padding-default);
color: var(--modest-text-color);
}
&__table {
width: 100%;
border-collapse: collapse;
th,
td {
text-align: start;
padding: var(--padding-small) var(--padding-default);
border-bottom: 1px solid var(--border-color);
}
thead th {
position: sticky;
top: 0;
z-index: 1;
background-color: var(--card-background-color);
color: var(--modest-text-color);
}
tbody tr:last-child td {
border-bottom: none;
}
}
// Draft rows that Save has not sent yet.
&__row--unsaved td {
font-style: italic;
}
&__actions {
display: flex;
gap: var(--gap-small);
justify-content: flex-end;
}
.tp__dialog__toolbar {
flex-shrink: 0;
}
}
@media (max-width: 860px) {
.tp__maintenance-dialog__form {
flex-flow: row wrap;
> .tp__form-field {
flex: 1 1 100%;
}
}
}
@@ -2,18 +2,21 @@ use crate::{
app::{
components::{
Card, CreditsActionCard, DiscordActionCard, DocumentationActionCard, GithubActionCard, IpinfoActionCard,
UserActionCard, VersionActionCard,
MaintenanceActionCard, UserActionCard, VersionActionCard,
},
context::StatusContext,
},
hooks::use_service_context,
i18n::use_translation,
};
use shared::model::Permission;
use yew::prelude::*;
#[component]
pub fn DashboardView() -> Html {
let translate = use_translation();
let status_ctx = use_context::<StatusContext>().expect("Status context not found");
let can_see_maintenance = use_service_context().auth.has_permission(Permission::MaintenanceRead);
html! {
<div class="tp__dashboard">
@@ -32,6 +35,7 @@ pub fn DashboardView() -> Html {
<Card><GithubActionCard /></Card>
<Card><IpinfoActionCard /></Card>
<Card><CreditsActionCard /></Card>
if can_see_maintenance { <Card><MaintenanceActionCard /></Card> }
</div>
</div>
</div>
@@ -0,0 +1,158 @@
use super::{
maintenance_dialog::MaintenanceDialog,
maintenance_state::{is_alert, merge_loaded, preview_names},
};
use crate::{
app::{
components::{ActionCard, TextButton},
CardContext,
},
hooks::use_service_context,
i18n::use_translation,
model::EventMessage,
};
use shared::model::{MaintenanceStateDto, Permission};
use std::rc::Rc;
use yew::prelude::*;
const ALERT_CLASS: &str = "tp__card--maintenance";
fn dialog_portal(content: Html) -> Html {
match web_sys::window().and_then(|window| window.document()).and_then(|document| document.body()) {
Some(body) => yew::create_portal(content, body.into()),
None => content,
}
}
#[component]
pub fn MaintenanceActionCard() -> Html {
let services = use_service_context();
let translate = use_translation();
let card_ctx = use_context::<CardContext>();
let state = use_state(|| Rc::new(MaintenanceStateDto::default()));
// The latest state, readable from the subscription and from a load in flight without going
// through a stale state handle.
let latest = use_mut_ref(|| Rc::new(MaintenanceStateDto::default()));
// Counts change events; a load that saw a newer event than the one it started after is stale.
let event_generation = use_mut_ref(|| 0_u64);
let dialog_open = use_state(|| false);
let can_read = services.auth.has_permission(Permission::MaintenanceRead);
let can_write = services.auth.has_permission(Permission::MaintenanceWrite);
let apply_state = {
let state = state.clone();
let latest = latest.clone();
Callback::from(move |next: MaintenanceStateDto| {
let next = Rc::new(next);
*latest.borrow_mut() = Rc::clone(&next);
state.set(next);
})
};
let reload = {
let services = services.clone();
let translate = translate.clone();
let apply_state = apply_state.clone();
let latest = latest.clone();
let event_generation = event_generation.clone();
Callback::from(move |()| {
let services = services.clone();
let translate = translate.clone();
let apply_state = apply_state.clone();
let latest = latest.clone();
let event_generation = event_generation.clone();
let started_at = *event_generation.borrow();
wasm_bindgen_futures::spawn_local(async move {
match services.maintenance.get().await {
Ok(loaded) => {
let stale = *event_generation.borrow() != started_at;
let current = Rc::clone(&latest.borrow());
apply_state.emit(merge_loaded(&current, loaded, stale));
}
Err(_) => services.toastr.error(translate.t("MESSAGES.MAINTENANCE.LOAD_FAILED")),
}
});
})
};
{
let services = services.clone();
let apply_state = apply_state.clone();
let latest = latest.clone();
let event_generation = event_generation.clone();
let reload = reload.clone();
use_effect_with(can_read, move |&can_read| {
let subscription = can_read.then(|| {
reload.emit(());
services.event.subscribe(move |message| match message {
EventMessage::MaintenanceChanged(entries) => {
*event_generation.borrow_mut() += 1;
let inputs = latest.borrow().inputs.clone();
apply_state.emit(MaintenanceStateDto { entries: (*entries).clone(), inputs });
}
// Events sent while the socket was down are lost, and a source reload can
// add inputs: read the state again.
EventMessage::WebSocketStatus(true) | EventMessage::ConfigChange(_) => reload.emit(()),
_ => {}
})
});
move || {
if let Some(subscription) = subscription {
services.event.unsubscribe(subscription);
}
}
});
}
{
let alert = is_alert(&state);
use_effect_with(alert, move |&alert| {
if let Some(card_ctx) = card_ctx {
card_ctx.custom_class.set(if alert { ALERT_CLASS.to_string() } else { String::new() });
}
|| ()
});
}
if !can_read {
return Html::default();
}
let subtitle = if is_alert(&state) {
format!(
"{}: {}",
if state.entries.len() == 1 {
translate.t("LABEL.MAINTENANCE_COUNT_ONE")
} else {
translate.t("LABEL.MAINTENANCE_COUNT").replace("{count}", &state.entries.len().to_string())
},
preview_names(&state, 3)
)
} else {
translate.t("LABEL.MAINTENANCE_NONE")
};
let open_dialog = {
let dialog_open = dialog_open.clone();
Callback::from(move |_: String| dialog_open.set(true))
};
let close_dialog = {
let dialog_open = dialog_open.clone();
Callback::from(move |()| dialog_open.set(false))
};
html! {
<>
<ActionCard icon="Warning" classname="tp__maintenance-card" title={translate.t("LABEL.MAINTENANCE")} subtitle={subtitle}>
<TextButton name="maintenance" title={translate.t("LABEL.MANAGE")} icon="Settings" onclick={open_dialog} />
</ActionCard>
if *dialog_open {
// Rendered into <body>: inside the card the backdrop would share the card's stacking
// context and let pointer events reach the dashboard behind it.
{ dialog_portal(html! {
<MaintenanceDialog state={(*state).clone()} can_write={can_write} on_state={apply_state} on_close={close_dialog} />
}) }
}
</>
}
}

Some files were not shown because too many files have changed in this diff Show More