mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-17 13:22:19 +02:00
Feature/maintenance (#916)
* Maintenance mode * fix(hls): release origin slot before next origin I/O
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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(),
|
||||
})));
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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(""),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})));
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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": "انتهت جلستك. يرجى تسجيل الدخول مرة أخرى."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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": "Ваша сессия истекла. Войдите снова."
|
||||
},
|
||||
|
||||
@@ -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(¤t, 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
Reference in New Issue
Block a user