From 69040f022bbb6e9b2d58fa2386f790b6bcdb2145 Mon Sep 17 00:00:00 2001 From: euzu <33094714+euzu@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:30:21 +0200 Subject: [PATCH] Feature/maintenance (#916) * Maintenance mode * fix(hls): release origin slot before next origin I/O --- CHANGELOG.md | 9 + README.md | 7 +- backend/app/src/api/api_utils/maintenance.rs | 69 ++++ backend/app/src/api/api_utils/mod.rs | 2 + .../app/src/api/api_utils/provider_acquire.rs | 18 +- backend/app/src/api/api_utils/response.rs | 1 + backend/app/src/api/api_utils/stalker.rs | 13 +- backend/app/src/api/api_utils/strategy.rs | 9 +- backend/app/src/api/api_utils/tests.rs | 1 + .../app/src/api/api_utils/tests/cleanup.rs | 1 + .../src/api/api_utils/tests/maintenance.rs | 203 +++++++++ .../app/src/api/api_utils/tests/stalker.rs | 33 ++ .../app/src/api/api_utils/tests/support.rs | 2 + .../api/endpoints/custom_video_stream_api.rs | 3 + .../endpoints/hls_api/manifest/publication.rs | 1 + .../api/endpoints/hls_api/segment/entry.rs | 19 +- .../endpoints/hls_api/tests/entry_identity.rs | 132 ++++++ .../endpoints/hls_api/tests/owner_handoff.rs | 2 + .../provider_admission_account_overlap.rs | 28 ++ .../hls_api/tests/support/app_state.rs | 2 + .../endpoints/hls_api/tests/support/http.rs | 1 + .../hls_api/tests/support/runtime_policy.rs | 1 + backend/app/src/api/endpoints/m3u_api.rs | 27 +- .../app/src/api/endpoints/maintenance_api.rs | 290 +++++++++++++ backend/app/src/api/endpoints/mod.rs | 1 + .../src/api/endpoints/provider_resolve_api.rs | 34 +- .../app/src/api/endpoints/recording_api.rs | 2 + backend/app/src/api/endpoints/v1_api.rs | 2 + .../tests/behavior_provider_resolution.rs | 2 + .../v1_api_playlist/tests/support.rs | 2 + .../app/src/api/endpoints/websocket_api.rs | 21 + .../app/src/api/endpoints/xtream_api/mod.rs | 34 +- .../app/src/api/endpoints/xtream_api/tests.rs | 150 +++++++ backend/app/src/api/main_api.rs | 22 +- backend/app/src/api/model/app_state/mod.rs | 32 +- backend/app/src/api/model/app_state/reload.rs | 6 + .../src/api/model/app_state/test_support.rs | 14 + backend/app/src/api/model/app_state/tests.rs | 57 +++ backend/app/src/api/model/app_state/view.rs | 8 +- backend/app/src/api/model/hls_provisioning.rs | 1 + .../app/src/api/model/recording_runtime.rs | 13 +- .../streams/active_client_stream/state.rs | 4 +- .../active_client_stream/tests/admission.rs | 2 + .../active_client_stream/tests/support.rs | 2 + .../src/api/model/streams/provider_stream.rs | 34 ++ backend/app/src/api/panel_api/alias_pool.rs | 6 + .../app/src/api/tasks/notification_bridge.rs | 13 + backend/app/src/api/tasks/scheduler.rs | 1 + backend/app/src/api/tasks/xtream_expiry.rs | 25 +- backend/app/src/main.rs | 10 +- backend/auth/src/recording_auth.rs | 3 + backend/core/src/model/config/app.rs | 4 +- backend/core/src/model/config/stream.rs | 1 + backend/core/src/model/maintenance.rs | 390 ++++++++++++++++++ backend/core/src/model/mod.rs | 7 +- backend/dvr/src/recording/mod.rs | 1 + .../dvr/src/recording/recording_capacity.rs | 22 + backend/dvr/src/recording/recording_disk.rs | 1 + .../src/recording/recording_queue/control.rs | 1 + backend/dvr/src/recording/recording_quota.rs | 1 + .../dvr/src/recording/recording_retention.rs | 1 + .../recording/recording_service/control.rs | 1 + .../dvr/src/recording/recording_transfer.rs | 2 + .../recording/recording_transfer/provider.rs | 3 + .../src/recording/recording_transfer/tests.rs | 12 +- .../recording_transfer/tests/admission.rs | 113 ++++- .../recording_transfer/tests/behavior.rs | 27 +- .../recording_transfer/tests/support.rs | 2 + .../recording/recording_transfer/worker.rs | 34 +- .../src/recording/recording_worker_runner.rs | 1 + backend/hls/src/availability/tests.rs | 1 + backend/hls/src/origin.rs | 93 ++--- backend/hls/src/refresh/tests/support.rs | 1 + backend/hls/src/runtime_custom_tail.rs | 3 +- backend/metadata/src/ctx.rs | 4 +- backend/metadata/src/manager.rs | 2 + backend/metadata/src/manager/retry.rs | 9 +- .../metadata/src/manager/task_processing.rs | 7 +- .../metadata/src/manager/tests/behavior.rs | 22 +- backend/processing/src/epg.rs | 34 +- .../src/processor/playlist/ingest.rs | 2 + .../processor/playlist/ingest/acquisition.rs | 19 +- .../src/processor/playlist/ingest/jobs.rs | 5 + .../processing/src/processor/playlist/mod.rs | 13 + .../src/processor/playlist/target.rs | 4 + .../playlist/tests/curation_effect_gate.rs | 1 + .../processor/playlist/tests/mapping_stage.rs | 1 + .../tests/quality_rejection_fallback.rs | 102 +++++ docs/src/configuration/config.md | 5 +- docs/src/features.md | 4 +- docs/src/operator/dvr.md | 6 + docs/src/rest-api-cookbook.md | 62 +++ frontend/public/assets/i18n/ar.json | 19 + frontend/public/assets/i18n/en.json | 19 + frontend/public/assets/i18n/es.json | 19 + frontend/public/assets/i18n/ru.json | 19 + frontend/scss/app/_component.scss | 1 + .../components/dashboard/_maintenance.scss | 121 ++++++ .../components/dashboard/dashboard_view.rs | 6 +- .../dashboard/maintenance_action_card.rs | 158 +++++++ .../dashboard/maintenance_dialog.rs | 258 ++++++++++++ .../components/dashboard/maintenance_state.rs | 183 ++++++++ frontend/src/app/components/dashboard/mod.rs | 9 +- frontend/src/hooks/use_service_context.rs | 3 + frontend/src/model/event_message.rs | 1 + frontend/src/services/maintenance_service.rs | 34 ++ frontend/src/services/mod.rs | 9 +- frontend/src/services/websocket_service.rs | 3 + resources/maintenance.jpg | Bin 0 -> 187647 bytes shared/src/defaults/hls.rs | 1 + shared/src/model/auth/permission.rs | 19 +- shared/src/model/auth/user.rs | 2 +- shared/src/model/custom_video_stream_type.rs | 16 + shared/src/model/event/kind.rs | 7 +- shared/src/model/event/message.rs | 7 +- shared/src/model/event/tests.rs | 9 + shared/src/model/maintenance.rs | 101 +++++ shared/src/model/mod.rs | 21 +- shared/src/model/notification/registry.rs | 6 + shared/src/model/recording/metadata.rs | 6 + shared/src/model/stream_history_record.rs | 1 + shared/src/model/web_socket.rs | 3 + 122 files changed, 3313 insertions(+), 147 deletions(-) create mode 100644 backend/app/src/api/api_utils/maintenance.rs create mode 100644 backend/app/src/api/api_utils/tests/maintenance.rs create mode 100644 backend/app/src/api/endpoints/maintenance_api.rs create mode 100644 backend/core/src/model/maintenance.rs create mode 100644 frontend/scss/app/components/dashboard/_maintenance.scss create mode 100644 frontend/src/app/components/dashboard/maintenance_action_card.rs create mode 100644 frontend/src/app/components/dashboard/maintenance_dialog.rs create mode 100644 frontend/src/app/components/dashboard/maintenance_state.rs create mode 100644 frontend/src/services/maintenance_service.rs create mode 100644 resources/maintenance.jpg create mode 100644 shared/src/model/maintenance.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a0377adc3..5381e74e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/README.md b/README.md index ea9a7fa96..4ff4e8d40 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/app/src/api/api_utils/maintenance.rs b/backend/app/src/api/api_utils/maintenance.rs new file mode 100644 index 000000000..fb6840606 --- /dev/null +++ b/backend/app/src/api/api_utils/maintenance.rs @@ -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, + 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) +} diff --git a/backend/app/src/api/api_utils/mod.rs b/backend/app/src/api/api_utils/mod.rs index 94fab5a28..d689cb798 100644 --- a/backend/app/src/api/api_utils/mod.rs +++ b/backend/app/src/api/api_utils/mod.rs @@ -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, diff --git a/backend/app/src/api/api_utils/provider_acquire.rs b/backend/app/src/api/api_utils/provider_acquire.rs index 06e3e4772..b86083b3f 100644 --- a/backend/app/src/api/api_utils/provider_acquire.rs +++ b/backend/app/src/api/api_utils/provider_acquire.rs @@ -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, preacquired_provider_handle: Option, ) -> 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) { diff --git a/backend/app/src/api/api_utils/response.rs b/backend/app/src/api/api_utils/response.rs index f538f8a11..5ec214a3f 100644 --- a/backend/app/src/api/api_utils/response.rs +++ b/backend/app/src/api/api_utils/response.rs @@ -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, } } diff --git a/backend/app/src/api/api_utils/stalker.rs b/backend/app/src/api/api_utils/stalker.rs index 21d548519..b909d4371 100644 --- a/backend/app/src/api/api_utils/stalker.rs +++ b/backend/app/src/api/api_utils/stalker.rs @@ -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, input: &ConfigInput, + user: &ProxyUserCredentials, provider_id: u32, cluster: XtreamCluster, item_type: PlaylistItemType, stream_url: &Arc, ) -> Result, 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) diff --git a/backend/app/src/api/api_utils/strategy.rs b/backend/app/src/api/api_utils/strategy.rs index 7f5514004..79be46f23 100644 --- a/backend/app/src/api/api_utils/strategy.rs +++ b/backend/app/src/api/api_utils/strategy.rs @@ -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()) { diff --git a/backend/app/src/api/api_utils/tests.rs b/backend/app/src/api/api_utils/tests.rs index 7394fd0be..77a58cc05 100644 --- a/backend/app/src/api/api_utils/tests.rs +++ b/backend/app/src/api/api_utils/tests.rs @@ -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; diff --git a/backend/app/src/api/api_utils/tests/cleanup.rs b/backend/app/src/api/api_utils/tests/cleanup.rs index daf204f95..f2aacb0a7 100644 --- a/backend/app/src/api/api_utils/tests/cleanup.rs +++ b/backend/app/src/api/api_utils/tests/cleanup.rs @@ -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))); diff --git a/backend/app/src/api/api_utils/tests/maintenance.rs b/backend/app/src/api/api_utils/tests/maintenance.rs new file mode 100644 index 000000000..81612f2c6 --- /dev/null +++ b/backend/app/src/api/api_utils/tests/maintenance.rs @@ -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, Arc) { + 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 { + 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 { + 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 { + Arc::clone(&app_state.app_config.sources.load().inputs[0]) +} diff --git a/backend/app/src/api/api_utils/tests/stalker.rs b/backend/app/src/api/api_utils/tests/stalker.rs index dde107e67..67fec2ae7 100644 --- a/backend/app/src/api/api_utils/tests/stalker.rs +++ b/backend/app/src/api/api_utils/tests/stalker.rs @@ -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 = "".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("")); +} diff --git a/backend/app/src/api/api_utils/tests/support.rs b/backend/app/src/api/api_utils/tests/support.rs index 5a262921b..387f4f5a3 100644 --- a/backend/app/src/api/api_utils/tests/support.rs +++ b/backend/app/src/api/api_utils/tests/support.rs @@ -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(""), }) } diff --git a/backend/app/src/api/endpoints/custom_video_stream_api.rs b/backend/app/src/api/endpoints/custom_video_stream_api.rs index 98dddd02c..068f0721b 100644 --- a/backend/app/src/api/endpoints/custom_video_stream_api.rs +++ b/backend/app/src/api/endpoints/custom_video_stream_api.rs @@ -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(""), }) } diff --git a/backend/app/src/api/endpoints/hls_api/manifest/publication.rs b/backend/app/src/api/endpoints/hls_api/manifest/publication.rs index 092fef3c3..e06c00a2a 100644 --- a/backend/app/src/api/endpoints/hls_api/manifest/publication.rs +++ b/backend/app/src/api/endpoints/hls_api/manifest/publication.rs @@ -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, diff --git a/backend/app/src/api/endpoints/hls_api/segment/entry.rs b/backend/app/src/api/endpoints/hls_api/segment/entry.rs index 60541c190..073eda608 100644 --- a/backend/app/src/api/endpoints/hls_api/segment/entry.rs +++ b/backend/app/src/api/endpoints/hls_api/segment/entry.rs @@ -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. diff --git a/backend/app/src/api/endpoints/hls_api/tests/entry_identity.rs b/backend/app/src/api/endpoints/hls_api/tests/entry_identity.rs index 8ececa9af..08b417e9c 100644 --- a/backend/app/src/api/endpoints/hls_api/tests/entry_identity.rs +++ b/backend/app/src/api/endpoints/hls_api/tests/entry_identity.rs @@ -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"); +} diff --git a/backend/app/src/api/endpoints/hls_api/tests/owner_handoff.rs b/backend/app/src/api/endpoints/hls_api/tests/owner_handoff.rs index 46b7b94ce..4dffc61ac 100644 --- a/backend/app/src/api/endpoints/hls_api/tests/owner_handoff.rs +++ b/backend/app/src/api/endpoints/hls_api/tests/owner_handoff.rs @@ -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, }))); } diff --git a/backend/app/src/api/endpoints/hls_api/tests/provider_admission_account_overlap.rs b/backend/app/src/api/endpoints/hls_api/tests/provider_admission_account_overlap.rs index 44df8368f..80f0951cf 100644 --- a/backend/app/src/api/endpoints/hls_api/tests/provider_admission_account_overlap.rs +++ b/backend/app/src/api/endpoints/hls_api/tests/provider_admission_account_overlap.rs @@ -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(); diff --git a/backend/app/src/api/endpoints/hls_api/tests/support/app_state.rs b/backend/app/src/api/endpoints/hls_api/tests/support/app_state.rs index ee59bdec1..b464e1b64 100644 --- a/backend/app/src/api/endpoints/hls_api/tests/support/app_state.rs +++ b/backend/app/src/api/endpoints/hls_api/tests/support/app_state.rs @@ -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(""), }) } diff --git a/backend/app/src/api/endpoints/hls_api/tests/support/http.rs b/backend/app/src/api/endpoints/hls_api/tests/support/http.rs index 5fd38a880..20ef221e0 100644 --- a/backend/app/src/api/endpoints/hls_api/tests/support/http.rs +++ b/backend/app/src/api/endpoints/hls_api/tests/support/http.rs @@ -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(), }))); } diff --git a/backend/app/src/api/endpoints/hls_api/tests/support/runtime_policy.rs b/backend/app/src/api/endpoints/hls_api/tests/support/runtime_policy.rs index 6dbaca4d7..cf1b8a190 100644 --- a/backend/app/src/api/endpoints/hls_api/tests/support/runtime_policy.rs +++ b/backend/app/src/api/endpoints/hls_api/tests/support/runtime_policy.rs @@ -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(), }))); } diff --git a/backend/app/src/api/endpoints/m3u_api.rs b/backend/app/src/api/endpoints/m3u_api.rs index 05fc50d6b..a6a7f2001 100644 --- a/backend/app/src/api/endpoints/m3u_api.rs +++ b/backend/app/src/api/endpoints/m3u_api.rs @@ -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, diff --git a/backend/app/src/api/endpoints/maintenance_api.rs b/backend/app/src/api/endpoints/maintenance_api.rs new file mode 100644 index 000000000..e25e1ebb2 --- /dev/null +++ b/backend/app/src/api/endpoints/maintenance_api.rs @@ -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) -> Router> { + 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> { + 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>) -> 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>, + Path(input): Path, + body: Bytes, +) -> axum::response::Response { + let request = if body.is_empty() { + MaintenanceRequestDto::default() + } else { + match serde_json::from_slice::(&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>, + Path(input): Path, +) -> 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 { + 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 { + 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) { + 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> { + 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(()) + } +} diff --git a/backend/app/src/api/endpoints/mod.rs b/backend/app/src/api/endpoints/mod.rs index a9e1cb2dc..0348c28e4 100644 --- a/backend/app/src/api/endpoints/mod.rs +++ b/backend/app/src/api/endpoints/mod.rs @@ -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; diff --git a/backend/app/src/api/endpoints/provider_resolve_api.rs b/backend/app/src/api/endpoints/provider_resolve_api.rs index 6a959c7a9..af9558ad2 100644 --- a/backend/app/src/api/endpoints/provider_resolve_api.rs +++ b/backend/app/src/api/endpoints/provider_resolve_api.rs @@ -74,12 +74,15 @@ fn resolve_direct_provider_location(input: &ConfigInput, url: &str) -> Result, ) -> Result { - 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(), diff --git a/backend/app/src/api/endpoints/recording_api.rs b/backend/app/src/api/endpoints/recording_api.rs index c680ea3d1..e4047bda9 100644 --- a/backend/app/src/api/endpoints/recording_api.rs +++ b/backend/app/src/api/endpoints/recording_api.rs @@ -1207,6 +1207,8 @@ mod tests { fn release(&self, _handle: Option) -> BoxFuture<'_, ()> { Box::pin(async {}) } fn capacity_changed(&self) -> Arc { Arc::clone(&self.notify) } + + fn is_input_in_maintenance(&self, _input_name: &str) -> bool { false } } fn edit_claims(subject_id: Option, admin: bool) -> shared::model::Claims { diff --git a/backend/app/src/api/endpoints/v1_api.rs b/backend/app/src/api/endpoints/v1_api.rs index 944dfe8be..b19058625 100644 --- a/backend/app/src/api/endpoints/v1_api.rs +++ b/backend/app/src/api/endpoints/v1_api.rs @@ -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( diff --git a/backend/app/src/api/endpoints/v1_api_playlist/tests/behavior_provider_resolution.rs b/backend/app/src/api/endpoints/v1_api_playlist/tests/behavior_provider_resolution.rs index dacd98980..5c62d24ef 100644 --- a/backend/app/src/api/endpoints/v1_api_playlist/tests/behavior_provider_resolution.rs +++ b/backend/app/src/api/endpoints/v1_api_playlist/tests/behavior_provider_resolution.rs @@ -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, diff --git a/backend/app/src/api/endpoints/v1_api_playlist/tests/support.rs b/backend/app/src/api/endpoints/v1_api_playlist/tests/support.rs index 448dec1f9..09125f35a 100644 --- a/backend/app/src/api/endpoints/v1_api_playlist/tests/support.rs +++ b/backend/app/src/api/endpoints/v1_api_playlist/tests/support.rs @@ -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(""), }) } diff --git a/backend/app/src/api/endpoints/websocket_api.rs b/backend/app/src/api/endpoints/websocket_api.rs index 569c8d49f..bd58467de 100644 --- a/backend/app/src/api/endpoints/websocket_api.rs +++ b/backend/app/src/api/endpoints/websocket_api.rs @@ -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 diff --git a/backend/app/src/api/endpoints/xtream_api/mod.rs b/backend/app/src/api/endpoints/xtream_api/mod.rs index 058bc4197..962cd4d2b 100644 --- a/backend/app/src/api/endpoints/xtream_api/mod.rs +++ b/backend/app/src/api/endpoints/xtream_api/mod.rs @@ -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) { diff --git a/backend/app/src/api/endpoints/xtream_api/tests.rs b/backend/app/src/api/endpoints/xtream_api/tests.rs index d21240ec8..09e43d1ca 100644 --- a/backend/app/src/api/endpoints/xtream_api/tests.rs +++ b/backend/app/src/api/endpoints/xtream_api/tests.rs @@ -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) { + 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, 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"); +} diff --git a/backend/app/src/api/main_api.rs b/backend/app/src/api/main_api.rs index 0a4fe4ca6..3a698103d 100644 --- a/backend/app/src/api/main_api.rs +++ b/backend/app/src/api/main_api.rs @@ -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 { + 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, forced_targets: &Arc, @@ -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, 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(""), }) } diff --git a/backend/app/src/api/model/app_state/mod.rs b/backend/app/src/api/model/app_state/mod.rs index 72335072f..d704af3f4 100644 --- a/backend/app/src/api/model/app_state/mod.rs +++ b/backend/app/src/api/model/app_state/mod.rs @@ -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, pub auth: AuthState, pub playlist_updates: PlaylistUpdateControl, + /// Inputs taken out of service; read on every new stream request. + pub maintenance: Arc, } 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) -> Option> { + 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> { + 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 } diff --git a/backend/app/src/api/model/app_state/reload.rs b/backend/app/src/api/model/app_state/reload.rs index a933374c4..400366f6b 100644 --- a/backend/app/src/api/model/app_state/reload.rs +++ b/backend/app/src/api/model/app_state/reload.rs @@ -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(()) diff --git a/backend/app/src/api/model/app_state/test_support.rs b/backend/app/src/api/model/app_state/test_support.rs index f80f1bcbd..5dbf3e16a 100644 --- a/backend/app/src/api/model/app_state/test_support.rs +++ b/backend/app/src/api/model/app_state/test_support.rs @@ -63,6 +63,7 @@ pub fn create_test_app_state(config: Config) -> Arc { 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 { 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 { 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 { + 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), + )) +} diff --git a/backend/app/src/api/model/app_state/tests.rs b/backend/app/src/api/model/app_state/tests.rs index e76ba134e..14180c2f8 100644 --- a/backend/app/src/api/model/app_state/tests.rs +++ b/backend/app/src/api/model/app_state/tests.rs @@ -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 = 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"); +} diff --git a/backend/app/src/api/model/app_state/view.rs b/backend/app/src/api/model/app_state/view.rs index dc0f5f6cf..765063e87 100644 --- a/backend/app/src/api/model/app_state/view.rs +++ b/backend/app/src/api/model/app_state/view.rs @@ -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`. 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, } } diff --git a/backend/app/src/api/model/hls_provisioning.rs b/backend/app/src/api/model/hls_provisioning.rs index 5fd782c84..f8448858c 100644 --- a/backend/app/src/api/model/hls_provisioning.rs +++ b/backend/app/src/api/model/hls_provisioning.rs @@ -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(), } } diff --git a/backend/app/src/api/model/recording_runtime.rs b/backend/app/src/api/model/recording_runtime.rs index bbc542b8b..2fdd264b8 100644 --- a/backend/app/src/api/model/recording_runtime.rs +++ b/backend/app/src/api/model/recording_runtime.rs @@ -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, connection_manager: Arc, + maintenance: Arc, } impl ProviderCapacityAdapter { - pub fn new(active_provider: Arc, connection_manager: Arc) -> Arc { - Arc::new(Self { active_provider, connection_manager }) + pub fn new( + active_provider: Arc, + connection_manager: Arc, + maintenance: Arc, + ) -> Arc { + Arc::new(Self { active_provider, connection_manager, maintenance }) } } @@ -40,4 +45,6 @@ impl RecordingCapacityPort for ProviderCapacityAdapter { } fn capacity_changed(&self) -> Arc { self.connection_manager.capacity_notified() } + + fn is_input_in_maintenance(&self, input_name: &str) -> bool { self.maintenance.is_in_maintenance(input_name) } } diff --git a/backend/app/src/api/model/streams/active_client_stream/state.rs b/backend/app/src/api/model/streams/active_client_stream/state.rs index 7f7f09216..ae75c4408 100644 --- a/backend/app/src/api/model/streams/active_client_stream/state.rs +++ b/backend/app/src/api/model/streams/active_client_stream/state.rs @@ -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, } } diff --git a/backend/app/src/api/model/streams/active_client_stream/tests/admission.rs b/backend/app/src/api/model/streams/active_client_stream/tests/admission.rs index e1d9bc9c6..5a71afef0 100644 --- a/backend/app/src/api/model/streams/active_client_stream/tests/admission.rs +++ b/backend/app/src/api/model/streams/active_client_stream/tests/admission.rs @@ -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(""), }) } diff --git a/backend/app/src/api/model/streams/active_client_stream/tests/support.rs b/backend/app/src/api/model/streams/active_client_stream/tests/support.rs index 8135f1764..b85a40a93 100644 --- a/backend/app/src/api/model/streams/active_client_stream/tests/support.rs +++ b/backend/app/src/api/model/streams/active_client_stream/tests/support.rs @@ -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(""), }) } diff --git a/backend/app/src/api/model/streams/provider_stream.rs b/backend/app/src/api/model/streams/provider_stream.rs index a5be0eab8..523705ff5 100644 --- a/backend/app/src/api/model/streams/provider_stream.rs +++ b/backend/app/src/api/model/streams/provider_stream.rs @@ -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 diff --git a/backend/app/src/api/panel_api/alias_pool.rs b/backend/app/src/api/panel_api/alias_pool.rs index cbdcffced..e85a0239c 100644 --- a/backend/app/src/api/panel_api/alias_pool.rs +++ b/backend/app/src/api/panel_api/alias_pool.rs @@ -512,6 +512,9 @@ pub(crate) async fn sync_panel_api_exp_dates(app_state: &Arc) { 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 Option { 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"); diff --git a/backend/app/src/api/tasks/scheduler.rs b/backend/app/src/api/tasks/scheduler.rs index f864ae25c..c25c57979 100644 --- a/backend/app/src/api/tasks/scheduler.rs +++ b/backend/app/src/api/tasks/scheduler.rs @@ -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), ) diff --git a/backend/app/src/api/tasks/xtream_expiry.rs b/backend/app/src/api/tasks/xtream_expiry.rs index 9a73cbedb..18a822203 100644 --- a/backend/app/src/api/tasks/xtream_expiry.rs +++ b/backend/app/src/api/tasks/xtream_expiry.rs @@ -64,8 +64,9 @@ pub fn exec_xtream_expiry_sync(app_state: &Arc, 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) { + accounts.retain(|account| !app_state.maintenance.is_in_maintenance(&account.input_name)); +} + fn collect_accounts(app_state: &AppState) -> Vec { 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> { + 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> { let (_dir, app, path) = super::super::provider_account_state::tests::fixture(true).await?; diff --git a/backend/app/src/main.rs b/backend/app/src/main.rs index c6457735c..7535392d7 100644 --- a/backend/app/src/main.rs +++ b/backend/app/src/main.rs @@ -548,8 +548,14 @@ async fn start_in_cli_mode(cfg: Arc, targets: Arc) { 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, targets: Arc) { diff --git a/backend/auth/src/recording_auth.rs b/backend/auth/src/recording_auth.rs index c158c151a..02e3bc7ae 100644 --- a/backend/auth/src/recording_auth.rs +++ b/backend/auth/src/recording_auth.rs @@ -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, diff --git a/backend/core/src/model/config/app.rs b/backend/core/src/model/config/app.rs index 1e86ce0c1..3800ac31e 100644 --- a/backend/core/src/model/config/app.rs +++ b/backend/core/src/model/config/app.rs @@ -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, }))); } diff --git a/backend/core/src/model/config/stream.rs b/backend/core/src/model/config/stream.rs index 8e58d6bc2..1c5a8b769 100644 --- a/backend/core/src/model/config/stream.rs +++ b/backend/core/src/model/config/stream.rs @@ -207,5 +207,6 @@ pub struct CustomStreamResponse { pub user_account_expired: Option, pub panel_api_provisioning: Option, pub hls_session_or_lease_expired: Option, + pub maintenance: Option, // input taken out of service by an operator pub panel_api_provisioning_hls_segments: Vec, } diff --git a/backend/core/src/model/maintenance.rs b/backend/core/src/model/maintenance.rs new file mode 100644 index 000000000..9fe7691bc --- /dev/null +++ b/backend/core/src/model/maintenance.rs @@ -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>>; + +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, + write_lock: tokio::sync::Mutex<()>, + /// Where the state is persisted; `None` keeps it in memory only. + path: Option, + /// 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, 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::(&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::(&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 { self.entries.load_full().unwrap_or_default() } + + pub async fn add(&self, name: &str, reason: Option, now: i64) -> MaintenanceWrite { + self.mutate(|entries| upsert(entries, name, reason, now)).await + } + + pub async fn add_all(&self, names: &[&str], reason: Option, 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> { + (!entries.is_empty()).then(|| Arc::new(entries)) +} + +fn upsert(entries: &mut MaintenanceEntries, name: &str, reason: Option, 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!["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!["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 = registry; + assert!(gate.is_in_maintenance("provider_a")); + } +} diff --git a/backend/core/src/model/mod.rs b/backend/core/src/model/mod.rs index 2f22c045b..aa0d5b422 100644 --- a/backend/core/src/model/mod.rs +++ b/backend/core/src/model/mod.rs @@ -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::*; diff --git a/backend/dvr/src/recording/mod.rs b/backend/dvr/src/recording/mod.rs index a8b7cf686..153f03d56 100644 --- a/backend/dvr/src/recording/mod.rs +++ b/backend/dvr/src/recording/mod.rs @@ -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, diff --git a/backend/dvr/src/recording/recording_capacity.rs b/backend/dvr/src/recording/recording_capacity.rs index b562ccdfa..0303ef3e8 100644 --- a/backend/dvr/src/recording/recording_capacity.rs +++ b/backend/dvr/src/recording/recording_capacity.rs @@ -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; + + /// 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>, + maintenance: Mutex>, notify: Arc, pub acquires: AtomicUsize, pub releases: AtomicUsize, @@ -74,6 +78,7 @@ pub mod stub { fn with_capacity(in_use: usize, limit: usize) -> Arc { 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 { 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) + } } } diff --git a/backend/dvr/src/recording/recording_disk.rs b/backend/dvr/src/recording/recording_disk.rs index 54f691475..38fd6be19 100644 --- a/backend/dvr/src/recording/recording_disk.rs +++ b/backend/dvr/src/recording/recording_disk.rs @@ -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, diff --git a/backend/dvr/src/recording/recording_queue/control.rs b/backend/dvr/src/recording/recording_queue/control.rs index 5bf01a7b5..e2b85eb35 100644 --- a/backend/dvr/src/recording/recording_queue/control.rs +++ b/backend/dvr/src/recording/recording_queue/control.rs @@ -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 { diff --git a/backend/dvr/src/recording/recording_quota.rs b/backend/dvr/src/recording/recording_quota.rs index 5173350c5..c755474fb 100644 --- a/backend/dvr/src/recording/recording_quota.rs +++ b/backend/dvr/src/recording/recording_quota.rs @@ -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, diff --git a/backend/dvr/src/recording/recording_retention.rs b/backend/dvr/src/recording/recording_retention.rs index 83aaf3a8b..1049833fd 100644 --- a/backend/dvr/src/recording/recording_retention.rs +++ b/backend/dvr/src/recording/recording_retention.rs @@ -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, diff --git a/backend/dvr/src/recording/recording_service/control.rs b/backend/dvr/src/recording/recording_service/control.rs index b7ddbcefd..3be2aa9c0 100644 --- a/backend/dvr/src/recording/recording_service/control.rs +++ b/backend/dvr/src/recording/recording_service/control.rs @@ -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) }, diff --git a/backend/dvr/src/recording/recording_transfer.rs b/backend/dvr/src/recording/recording_transfer.rs index f4f9fa340..92d445f51 100644 --- a/backend/dvr/src/recording/recording_transfer.rs +++ b/backend/dvr/src/recording/recording_transfer.rs @@ -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 = diff --git a/backend/dvr/src/recording/recording_transfer/provider.rs b/backend/dvr/src/recording/recording_transfer/provider.rs index 1783f551e..24824e722 100644 --- a/backend/dvr/src/recording/recording_transfer/provider.rs +++ b/backend/dvr/src/recording/recording_transfer/provider.rs @@ -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)) }) diff --git a/backend/dvr/src/recording/recording_transfer/tests.rs b/backend/dvr/src/recording/recording_transfer/tests.rs index 31a587d81..cc6a04122 100644 --- a/backend/dvr/src/recording/recording_transfer/tests.rs +++ b/backend/dvr/src/recording/recording_transfer/tests.rs @@ -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; diff --git a/backend/dvr/src/recording/recording_transfer/tests/admission.rs b/backend/dvr/src/recording/recording_transfer/tests/admission.rs index 5e095b9cb..735417791 100644 --- a/backend/dvr/src/recording/recording_transfer/tests/admission.rs +++ b/backend/dvr/src/recording/recording_transfer/tests/admission.rs @@ -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, queue: &Arc) { + let capacity: Arc = Arc::clone(stub) as Arc; + 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 { queued_live_capture_with(dir, false).await } + +async fn queued_live_capture_with(dir: &TempDir, provider_started: bool) -> Arc { + 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"); +} diff --git a/backend/dvr/src/recording/recording_transfer/tests/behavior.rs b/backend/dvr/src/recording/recording_transfer/tests/behavior.rs index a957942ab..84ae76155 100644 --- a/backend/dvr/src/recording/recording_transfer/tests/behavior.rs +++ b/backend/dvr/src/recording/recording_transfer/tests/behavior.rs @@ -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; diff --git a/backend/dvr/src/recording/recording_transfer/tests/support.rs b/backend/dvr/src/recording/recording_transfer/tests/support.rs index 407315d9a..b29fb821a 100644 --- a/backend/dvr/src/recording/recording_transfer/tests/support.rs +++ b/backend/dvr/src/recording/recording_transfer/tests/support.rs @@ -238,6 +238,8 @@ impl RecordingCapacityPort for LimitedCapacity { } fn capacity_changed(&self) -> Arc { self.notify.clone() } + + fn is_input_in_maintenance(&self, _input_name: &str) -> bool { false } } #[cfg(unix)] diff --git a/backend/dvr/src/recording/recording_transfer/worker.rs b/backend/dvr/src/recording/recording_transfer/worker.rs index 212dc1477..bf2a89422 100644 --- a/backend/dvr/src/recording/recording_transfer/worker.rs +++ b/backend/dvr/src/recording/recording_transfer/worker.rs @@ -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( '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( // 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( } 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; } diff --git a/backend/dvr/src/recording/recording_worker_runner.rs b/backend/dvr/src/recording/recording_worker_runner.rs index 05ef3916e..51d39a1a5 100644 --- a/backend/dvr/src/recording/recording_worker_runner.rs +++ b/backend/dvr/src/recording/recording_worker_runner.rs @@ -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, diff --git a/backend/hls/src/availability/tests.rs b/backend/hls/src/availability/tests.rs index 6bd1385ee..bf7f3bd80 100644 --- a/backend/hls/src/availability/tests.rs +++ b/backend/hls/src/availability/tests.rs @@ -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(), }) } diff --git a/backend/hls/src/origin.rs b/backend/hls/src/origin.rs index 1c533730f..e18872fc3 100644 --- a/backend/hls/src/origin.rs +++ b/backend/hls/src/origin.rs @@ -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); } } } diff --git a/backend/hls/src/refresh/tests/support.rs b/backend/hls/src/refresh/tests/support.rs index 07ce920f9..07323d3cc 100644 --- a/backend/hls/src/refresh/tests/support.rs +++ b/backend/hls/src/refresh/tests/support.rs @@ -945,6 +945,7 @@ pub(in crate::refresh::tests) fn critical_handoff_app_config() -> Arc 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 diff --git a/backend/hls/src/runtime_custom_tail.rs b/backend/hls/src/runtime_custom_tail.rs index 9bcd22e5d..77e9120e6 100644 --- a/backend/hls/src/runtime_custom_tail.rs +++ b/backend/hls/src/runtime_custom_tail.rs @@ -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, } } } diff --git a/backend/metadata/src/ctx.rs b/backend/metadata/src/ctx.rs index a01f6d3ae..c448f1382 100644 --- a/backend/metadata/src/ctx.rs +++ b/backend/metadata/src/ctx.rs @@ -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 { pub update_guard: UpdateGuard, /// Outbound clients, swapped when the proxy configuration changes. pub http_clients: Arc, + /// Inputs in maintenance; their tasks wait instead of contacting the provider. + pub maintenance: Arc, } /// The instantiation the long-lived worker stores. diff --git a/backend/metadata/src/manager.rs b/backend/metadata/src/manager.rs index 3f4d7a733..b81ae5975 100644 --- a/backend/metadata/src/manager.rs +++ b/backend/metadata/src/manager.rs @@ -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; diff --git a/backend/metadata/src/manager/retry.rs b/backend/metadata/src/manager/retry.rs index 12322eec1..5f69b11a8 100644 --- a/backend/metadata/src/manager/retry.rs +++ b/backend/metadata/src/manager/retry.rs @@ -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] diff --git a/backend/metadata/src/manager/task_processing.rs b/backend/metadata/src/manager/task_processing.rs index 4f3b1a701..e6673d85f 100644 --- a/backend/metadata/src/manager/task_processing.rs +++ b/backend/metadata/src/manager/task_processing.rs @@ -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() { diff --git a/backend/metadata/src/manager/tests/behavior.rs b/backend/metadata/src/manager/tests/behavior.rs index c8daed1de..2e91c80e5 100644 --- a/backend/metadata/src/manager/tests/behavior.rs +++ b/backend/metadata/src/manager/tests/behavior.rs @@ -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"); +} diff --git a/backend/processing/src/epg.rs b/backend/processing/src/epg.rs index f2493082b..57e9ec723 100644 --- a/backend/processing/src/epg.rs +++ b/backend/processing/src/epg.rs @@ -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( ) -> (Option, Vec) { 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( } } +/// 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( + ctx: &PlaylistProcessingContext, + input: &ConfigInput, + epg_config: &EpgConfig, + storage_dir: &str, +) -> (Option, Vec) { + 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, diff --git a/backend/processing/src/processor/playlist/ingest.rs b/backend/processing/src/processor/playlist/ingest.rs index 10afe8381..55081cdd1 100644 --- a/backend/processing/src/processor/playlist/ingest.rs +++ b/backend/processing/src/processor/playlist/ingest.rs @@ -114,6 +114,8 @@ pub struct PlaylistProcessingContext, pub(crate) library_update_mode: LibraryUpdateMode, + /// Inputs in maintenance: their persisted state is reused, their provider is not contacted. + pub maintenance: Option>, } #[cfg(test)] diff --git a/backend/processing/src/processor/playlist/ingest/acquisition.rs b/backend/processing/src/processor/playlist/ingest/acquisition.rs index eead204c1..f1c7d8fda 100644 --- a/backend/processing/src/processor/playlist/ingest/acquisition.rs +++ b/backend/processing/src/processor/playlist/ingest/acquisition.rs @@ -306,6 +306,12 @@ pub(crate) async fn download_input Vec { 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 { diff --git a/backend/processing/src/processor/playlist/ingest/jobs.rs b/backend/processing/src/processor/playlist/ingest/jobs.rs index a5a375ccf..9bb84e185 100644 --- a/backend/processing/src/processor/playlist/ingest/jobs.rs +++ b/backend/processing/src/processor/playlist/ingest/jobs.rs @@ -466,11 +466,16 @@ impl 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 PlaylistProcessingContext { + 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 diff --git a/backend/processing/src/processor/playlist/mod.rs b/backend/processing/src/processor/playlist/mod.rs index 914517285..7f0891f5d 100644 --- a/backend/processing/src/processor/playlist/mod.rs +++ b/backend/processing/src/processor/playlist/mod.rs @@ -146,6 +146,7 @@ pub struct ProcessingRun< acquired_permit: Option, input_refresh: Option, library_update_mode: LibraryUpdateMode, + maintenance: Option>, } impl ProcessingRun { @@ -178,6 +179,7 @@ impl ProcessingRun 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 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 self } + /// Inputs taken out of service; their provider is not contacted during this run. + #[must_use] + pub fn with_maintenance(mut self, gate: Arc) -> 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>) -> Self { @@ -319,6 +330,7 @@ pub async fn exec_processing, 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; diff --git a/backend/processing/src/processor/playlist/tests/curation_effect_gate.rs b/backend/processing/src/processor/playlist/tests/curation_effect_gate.rs index edf1f8b3e..8668e1a77 100644 --- a/backend/processing/src/processor/playlist/tests/curation_effect_gate.rs +++ b/backend/processing/src/processor/playlist/tests/curation_effect_gate.rs @@ -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, } diff --git a/backend/processing/src/processor/playlist/tests/mapping_stage.rs b/backend/processing/src/processor/playlist/tests/mapping_stage.rs index 06402ee67..395391c8b 100644 --- a/backend/processing/src/processor/playlist/tests/mapping_stage.rs +++ b/backend/processing/src/processor/playlist/tests/mapping_stage.rs @@ -97,6 +97,7 @@ fn processing_context() -> PlaylistProcessingContext { 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, } } diff --git a/backend/processing/src/processor/playlist/tests/quality_rejection_fallback.rs b/backend/processing/src/processor/playlist/tests/quality_rejection_fallback.rs index 2d8c4c923..47c302c4b 100644 --- a/backend/processing/src/processor/playlist/tests/quality_rejection_fallback.rs +++ b/backend/processing/src/processor/playlist/tests/quality_rejection_fallback.rs @@ -240,6 +240,7 @@ fn processing_context_with_events(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, "").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"); +} diff --git a/docs/src/configuration/config.md b/docs/src/configuration/config.md index a59dd6821..0a040a683 100644 --- a/docs/src/configuration/config.md +++ b/docs/src/configuration/config.md @@ -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. diff --git a/docs/src/features.md b/docs/src/features.md index c6125fce9..b58bc8722 100644 --- a/docs/src/features.md +++ b/docs/src/features.md @@ -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 diff --git a/docs/src/operator/dvr.md b/docs/src/operator/dvr.md index 570c02871..6b819944a 100644 --- a/docs/src/operator/dvr.md +++ b/docs/src/operator/dvr.md @@ -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 diff --git a/docs/src/rest-api-cookbook.md b/docs/src/rest-api-cookbook.md index c8b8f7226..9163f10aa 100644 --- a/docs/src/rest-api-cookbook.md +++ b/docs/src/rest-api-cookbook.md @@ -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 +`/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. diff --git a/frontend/public/assets/i18n/ar.json b/frontend/public/assets/i18n/ar.json index 9a2a812aa..153ec6b4a 100644 --- a/frontend/public/assets/i18n/ar.json +++ b/frontend/public/assets/i18n/ar.json @@ -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": "ุงู†ุชู‡ุช ุฌู„ุณุชูƒ. ูŠุฑุฌู‰ ุชุณุฌูŠู„ ุงู„ุฏุฎูˆู„ ู…ุฑุฉ ุฃุฎุฑู‰." }, diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index abe10f21c..9210a59a3 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -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." }, diff --git a/frontend/public/assets/i18n/es.json b/frontend/public/assets/i18n/es.json index 6354039d6..b54560abe 100644 --- a/frontend/public/assets/i18n/es.json +++ b/frontend/public/assets/i18n/es.json @@ -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." }, diff --git a/frontend/public/assets/i18n/ru.json b/frontend/public/assets/i18n/ru.json index bf64455a1..bb864e3f2 100644 --- a/frontend/public/assets/i18n/ru.json +++ b/frontend/public/assets/i18n/ru.json @@ -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": "ะ’ะฐัˆะฐ ัะตััะธั ะธัั‚ะตะบะปะฐ. ะ’ะพะนะดะธั‚ะต ัะฝะพะฒะฐ." }, diff --git a/frontend/scss/app/_component.scss b/frontend/scss/app/_component.scss index 25ca8f177..abff837d5 100644 --- a/frontend/scss/app/_component.scss +++ b/frontend/scss/app/_component.scss @@ -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"; diff --git a/frontend/scss/app/components/dashboard/_maintenance.scss b/frontend/scss/app/components/dashboard/_maintenance.scss new file mode 100644 index 000000000..48b991d5a --- /dev/null +++ b/frontend/scss/app/components/dashboard/_maintenance.scss @@ -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%; + } + } +} diff --git a/frontend/src/app/components/dashboard/dashboard_view.rs b/frontend/src/app/components/dashboard/dashboard_view.rs index 47c829dfb..30e039944 100644 --- a/frontend/src/app/components/dashboard/dashboard_view.rs +++ b/frontend/src/app/components/dashboard/dashboard_view.rs @@ -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::().expect("Status context not found"); + let can_see_maintenance = use_service_context().auth.has_permission(Permission::MaintenanceRead); html! {
@@ -32,6 +35,7 @@ pub fn DashboardView() -> Html { + if can_see_maintenance { }
diff --git a/frontend/src/app/components/dashboard/maintenance_action_card.rs b/frontend/src/app/components/dashboard/maintenance_action_card.rs new file mode 100644 index 000000000..24a023552 --- /dev/null +++ b/frontend/src/app/components/dashboard/maintenance_action_card.rs @@ -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::(); + 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! { + <> + + + + if *dialog_open { + // Rendered into : inside the card the backdrop would share the card's stacking + // context and let pointer events reach the dashboard behind it. + { dialog_portal(html! { + + }) } + } + + } +} diff --git a/frontend/src/app/components/dashboard/maintenance_dialog.rs b/frontend/src/app/components/dashboard/maintenance_dialog.rs new file mode 100644 index 000000000..a6ca25787 --- /dev/null +++ b/frontend/src/app/components/dashboard/maintenance_dialog.rs @@ -0,0 +1,258 @@ +use super::maintenance_state::{ + add_all_to_draft, add_to_draft, pending_changes, reason_too_long, relative_duration, PendingChanges, +}; +use crate::{ + app::components::{ + input::Input, selection_first_owned, CustomDialog, DropDownOption, DropDownSelection, IconButton, Select, + TextButton, + }, + config_field_child, + hooks::use_service_context, + i18n::use_translation, + services::MaintenanceService, +}; +use shared::model::{MaintenanceEntries, MaintenanceStateDto}; +use std::rc::Rc; +use wasm_bindgen::JsValue; +use yew::prelude::*; + +#[derive(Properties, Clone, PartialEq)] +pub struct MaintenanceDialogProps { + pub state: Rc, + pub can_write: bool, + pub on_state: Callback, + pub on_close: Callback<()>, +} + +#[allow(clippy::cast_precision_loss)] +fn local_time(since: i64) -> String { + let millis = since.saturating_mul(1000) as f64; + String::from(js_sys::Date::new(&JsValue::from_f64(millis)).to_locale_string("default", &JsValue::UNDEFINED)) +} + +#[allow(clippy::cast_possible_truncation)] +fn now_secs() -> i64 { (js_sys::Date::now() / 1000.0) as i64 } + +/// Sends the pending changes one by one and reads the resulting state back. +async fn apply_changes( + service: &MaintenanceService, + changes: PendingChanges, +) -> Result { + for name in &changes.deletes { + service.remove(name).await?; + } + for (name, reason) in changes.puts { + service.add(&name, reason).await?; + } + service.get().await +} + +/// Edits a draft of the inputs in maintenance. Adding, removing and the bulk actions only change +/// the draft; Save sends the difference to the server, Close discards it. +#[component] +pub fn MaintenanceDialog(props: &MaintenanceDialogProps) -> Html { + let services = use_service_context(); + let translate = use_translation(); + let draft = { + let entries = props.state.entries.clone(); + use_state(move || entries) + }; + let selected = use_state(|| None::); + let reason = use_state(String::new); + let pending = use_state(|| false); + + let too_long = reason_too_long(&reason); + let changes = pending_changes(&props.state.entries, &draft); + + let on_add = { + let draft = draft.clone(); + let selected = selected.clone(); + let reason = reason.clone(); + Callback::from(move |(_, event): (String, MouseEvent)| { + event.prevent_default(); + let Some(name) = (*selected).clone() else { return }; + if reason_too_long(&reason) { + return; + } + draft.set(add_to_draft(&draft, &name, &reason, now_secs())); + selected.set(None); + reason.set(String::new()); + }) + }; + + let on_add_all = { + let draft = draft.clone(); + let reason = reason.clone(); + let inputs = props.state.inputs.clone(); + Callback::from(move |_: String| { + if !reason_too_long(&reason) { + draft.set(add_all_to_draft(&draft, &inputs, &reason, now_secs())); + } + }) + }; + + let on_remove_all = { + let draft = draft.clone(); + Callback::from(move |_: String| draft.set(MaintenanceEntries::new())) + }; + + let on_save = { + let services = services.clone(); + let translate = translate.clone(); + let on_state = props.on_state.clone(); + let on_close = props.on_close.clone(); + let pending = pending.clone(); + let saved = props.state.entries.clone(); + let draft = draft.clone(); + Callback::from(move |_: String| { + let changes = pending_changes(&saved, &draft); + if changes.is_empty() { + on_close.emit(()); + return; + } + let services = services.clone(); + let translate = translate.clone(); + let on_state = on_state.clone(); + let on_close = on_close.clone(); + let pending = pending.clone(); + pending.set(true); + wasm_bindgen_futures::spawn_local(async move { + match apply_changes(&services.maintenance, changes).await { + Ok(next) => { + on_state.emit(next); + on_close.emit(()); + } + // A partial save is visible through the change event; the draft stays for a retry. + Err(_) => services.toastr.error(translate.t("MESSAGES.MAINTENANCE.SAVE_FAILED")), + } + pending.set(false); + }); + }) + }; + + let now = now_secs(); + let rows = draft.iter().map(|(name, entry)| { + let unsaved = props.state.entries.get(name) != Some(entry); + let on_remove = { + let draft = draft.clone(); + let name = name.clone(); + Callback::from(move |_: String| { + let mut next = (*draft).clone(); + next.remove(&name); + draft.set(next); + }) + }; + let on_edit = { + let selected = selected.clone(); + let reason = reason.clone(); + let name = name.clone(); + let remark = entry.reason.clone().unwrap_or_default(); + Callback::from(move |_: String| { + selected.set(Some(name.clone())); + reason.set(remark.clone()); + }) + }; + html! { + + {name} + {relative_duration(entry.since, now)} + {entry.reason.clone().unwrap_or_default()} + if props.can_write { + + + + + } + + } + }); + + // Inputs not in the draft yet, plus the one being edited. + let options = { + let mut names: Vec<&str> = + props.state.inputs.iter().map(String::as_str).filter(|name| !draft.contains_key(*name)).collect(); + if let Some(editing) = selected.as_deref() { + if !names.contains(&editing) { + names.insert(0, editing); + } + } + Rc::new( + names + .into_iter() + .map(|name| DropDownOption::new(name, html! { name }, selected.as_deref() == Some(name))) + .collect::>(), + ) + }; + let on_select = { + let selected = selected.clone(); + Callback::from(move |(_, selection): (String, DropDownSelection)| { + selected.set(selection_first_owned(selection)); + }) + }; + let on_reason = { + let reason = reason.clone(); + Callback::from(move |value: String| reason.set(value)) + }; + let close = props.on_close.clone(); + let close_button = { + let close = props.on_close.clone(); + Callback::from(move |_: String| close.emit(())) + }; + let busy = *pending; + + html! { + +

{translate.t("LABEL.MAINTENANCE")}

+ if props.can_write { +
+ { config_field_child!(translate.t("LABEL.INPUT"), "MAINTENANCE.INPUT", { + html! { + + +
+ } +
+ if draft.is_empty() { +

{translate.t("LABEL.MAINTENANCE_NONE")}

+ } else { + + + + + + if props.can_write { } + + {for rows} +
{translate.t("LABEL.INPUT")}{translate.t("LABEL.MAINTENANCE_SINCE")}{translate.t("LABEL.MAINTENANCE_REASON")}
+ } +
+
+
+ if props.can_write { + + + } +
+
+ + if props.can_write { + + } +
+
+
+ } +} diff --git a/frontend/src/app/components/dashboard/maintenance_state.rs b/frontend/src/app/components/dashboard/maintenance_state.rs new file mode 100644 index 000000000..8a24f9a6b --- /dev/null +++ b/frontend/src/app/components/dashboard/maintenance_state.rs @@ -0,0 +1,183 @@ +//! What the maintenance card and dialog derive from the server state. + +use shared::model::{InputMaintenance, MaintenanceEntries, MaintenanceStateDto, MAINTENANCE_REASON_MAX_CHARS}; + +pub fn is_alert(state: &MaintenanceStateDto) -> bool { !state.entries.is_empty() } + +/// The first `max` input names, with an ellipsis when there are more. +pub fn preview_names(state: &MaintenanceStateDto, max: usize) -> String { + let mut names: Vec<&str> = state.entries.keys().map(String::as_str).take(max).collect(); + if state.entries.len() > max { + names.push("โ€ฆ"); + } + names.join(", ") +} + +/// Applies a loaded state. A load that raced with a newer change event keeps the event's +/// entries and only takes the input list. +pub fn merge_loaded(current: &MaintenanceStateDto, loaded: MaintenanceStateDto, stale: bool) -> MaintenanceStateDto { + if stale { + MaintenanceStateDto { entries: current.entries.clone(), inputs: loaded.inputs } + } else { + loaded + } +} + +/// The server rejects longer remarks; the dialog says so before sending. +pub fn reason_too_long(reason: &str) -> bool { reason.trim().chars().count() > MAINTENANCE_REASON_MAX_CHARS } + +fn normalized(reason: &str) -> Option { + let reason = reason.trim(); + (!reason.is_empty()).then(|| reason.to_string()) +} + +/// Puts `name` into the draft, or updates its remark; an existing entry keeps its `since`. +pub fn add_to_draft(draft: &MaintenanceEntries, name: &str, reason: &str, now: i64) -> MaintenanceEntries { + let mut next = draft.clone(); + let reason = normalized(reason); + next.entry(name.to_string()) + .and_modify(|entry| entry.reason.clone_from(&reason)) + .or_insert(InputMaintenance { since: now, reason }); + next +} + +/// Puts every known input that is not in the draft yet into it. +pub fn add_all_to_draft(draft: &MaintenanceEntries, inputs: &[String], reason: &str, now: i64) -> MaintenanceEntries { + let mut next = draft.clone(); + let reason = normalized(reason); + for name in inputs { + next.entry(name.clone()).or_insert_with(|| InputMaintenance { since: now, reason: reason.clone() }); + } + next +} + +/// What Save has to send to turn the saved state into the draft. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct PendingChanges { + /// New inputs and changed remarks. + pub puts: Vec<(String, Option)>, + pub deletes: Vec, +} + +impl PendingChanges { + pub fn is_empty(&self) -> bool { self.puts.is_empty() && self.deletes.is_empty() } +} + +pub fn pending_changes(saved: &MaintenanceEntries, draft: &MaintenanceEntries) -> PendingChanges { + let puts = draft + .iter() + .filter(|(name, entry)| saved.get(*name).is_none_or(|current| current.reason != entry.reason)) + .map(|(name, entry)| (name.clone(), entry.reason.clone())) + .collect(); + let deletes = saved.keys().filter(|name| !draft.contains_key(*name)).cloned().collect(); + PendingChanges { puts, deletes } +} + +/// How long an input has been in maintenance, in the two largest units. +pub fn relative_duration(since: i64, now: i64) -> String { + let secs = (now - since).max(0); + let (days, hours, minutes) = (secs / 86_400, secs % 86_400 / 3600, secs % 3600 / 60); + match (days, hours, minutes) { + (0, 0, 0) => "<1m".to_string(), + (0, 0, m) => format!("{m}m"), + (0, h, m) => format!("{h}h {m}m"), + (d, h, _) => format!("{d}d {h}h"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use shared::model::{InputMaintenance, MaintenanceEntries, MaintenanceStateDto}; + + fn state(in_maintenance: &[&str], inputs: &[&str]) -> MaintenanceStateDto { + MaintenanceStateDto { + entries: in_maintenance + .iter() + .map(|name| ((*name).to_string(), InputMaintenance { since: 1, reason: None })) + .collect(), + inputs: inputs.iter().map(ToString::to_string).collect(), + } + } + + #[test] + fn card_is_alert_only_with_entries() { + assert!(!is_alert(&state(&[], &["a"]))); + assert!(is_alert(&state(&["a"], &["a"]))); + } + + #[test] + fn preview_names_are_truncated() { + let s = state(&["a", "b", "c", "d"], &["a", "b", "c", "d"]); + assert_eq!(preview_names(&s, 3), "a, b, c, โ€ฆ"); + assert_eq!(preview_names(&state(&["a"], &["a"]), 3), "a"); + } + + #[test] + fn a_stale_load_keeps_newer_entries_but_takes_the_input_list() { + let current = state(&["a"], &["a"]); + let loaded = state(&[], &["a", "b"]); + let merged = merge_loaded(¤t, loaded.clone(), true); + assert_eq!(merged.entries, current.entries, "entries from a newer event win"); + assert_eq!(merged.inputs, loaded.inputs); + assert_eq!(merge_loaded(¤t, loaded.clone(), false), loaded); + } + + #[test] + fn reason_limit_is_checked_in_characters() { + let limit = shared::model::MAINTENANCE_REASON_MAX_CHARS; + assert!(!reason_too_long(&"รค".repeat(limit))); + assert!(reason_too_long(&"x".repeat(limit + 1))); + assert!(!reason_too_long(&format!(" {} ", "x".repeat(limit))), "surrounding whitespace is trimmed"); + } + + fn entries(items: &[(&str, i64, Option<&str>)]) -> MaintenanceEntries { + items + .iter() + .map(|(name, since, reason)| { + ((*name).to_string(), InputMaintenance { since: *since, reason: reason.map(ToString::to_string) }) + }) + .collect() + } + + #[test] + fn adding_to_the_draft_keeps_since_of_an_existing_entry() { + let draft = entries(&[("a", 10, Some("old"))]); + let updated = add_to_draft(&draft, "a", " new ", 99); + assert_eq!(updated, entries(&[("a", 10, Some("new"))])); + let added = add_to_draft(&draft, "b", " ", 99); + assert_eq!(added, entries(&[("a", 10, Some("old")), ("b", 99, None)])); + } + + #[test] + fn pending_changes_lists_puts_for_new_or_changed_and_deletes_for_removed() { + let saved = entries(&[("a", 1, None), ("b", 1, Some("x")), ("c", 1, None)]); + let draft = entries(&[("a", 1, None), ("b", 5, Some("y")), ("d", 9, Some("z"))]); + let changes = pending_changes(&saved, &draft); + assert_eq!( + changes.puts, + vec![("b".to_string(), Some("y".to_string())), ("d".to_string(), Some("z".to_string()))] + ); + assert_eq!(changes.deletes, vec!["c".to_string()]); + assert!(!changes.is_empty()); + assert!(pending_changes(&saved, &saved).is_empty()); + } + + #[test] + fn draft_bulk_helpers_cover_all_inputs() { + let s = state(&["a"], &["a", "b"]); + let all = add_all_to_draft(&s.entries, &s.inputs, "outage", 7); + assert_eq!(all.len(), 2); + assert_eq!(all["a"].since, 1, "existing entries keep their since"); + assert_eq!(all["b"], InputMaintenance { since: 7, reason: Some("outage".into()) }); + } + + #[test] + fn relative_duration_is_compact() { + assert_eq!(relative_duration(1_000, 1_000 + 59), "<1m"); + assert_eq!(relative_duration(1_000, 1_000 + 300), "5m"); + assert_eq!(relative_duration(1_000, 1_000 + 3 * 3600 + 120), "3h 2m"); + assert_eq!(relative_duration(1_000, 1_000 + 2 * 86_400 + 3600), "2d 1h"); + assert_eq!(relative_duration(1_000, 500), "<1m", "clock skew never shows a negative duration"); + } +} diff --git a/frontend/src/app/components/dashboard/mod.rs b/frontend/src/app/components/dashboard/mod.rs index ed695f5f1..f336152ef 100644 --- a/frontend/src/app/components/dashboard/mod.rs +++ b/frontend/src/app/components/dashboard/mod.rs @@ -6,6 +6,9 @@ mod documentation_action_card; mod github_action_card; mod ipinfo_action_card; mod log_console; +mod maintenance_action_card; +mod maintenance_dialog; +mod maintenance_state; mod metrics_history; mod playlist_progress_status_card; mod sparkline; @@ -20,7 +23,7 @@ mod streams_view; pub use self::{ action_card::*, credits_action_card::*, dashboard_view::*, discord_action_card::*, documentation_action_card::*, - github_action_card::*, ipinfo_action_card::*, log_console::*, metrics_history::*, playlist_progress_status_card::*, - sparkline::*, stats_view::*, status_card::*, stream_display::*, stream_history_view::*, streams_view::*, - user_action_card::*, version_action_card::*, + github_action_card::*, ipinfo_action_card::*, log_console::*, maintenance_action_card::*, metrics_history::*, + playlist_progress_status_card::*, sparkline::*, stats_view::*, status_card::*, stream_display::*, + stream_history_view::*, streams_view::*, user_action_card::*, version_action_card::*, }; diff --git a/frontend/src/hooks/use_service_context.rs b/frontend/src/hooks/use_service_context.rs index 6dc280031..31e85ba03 100644 --- a/frontend/src/hooks/use_service_context.rs +++ b/frontend/src/hooks/use_service_context.rs @@ -23,6 +23,7 @@ pub struct Services { pub websocket: Rc, pub rbac: Rc, pub flags: Rc, + pub maintenance: Rc, } impl Services { @@ -41,6 +42,7 @@ impl Services { let rbac = Rc::new(RbacService::new()); let flags = Rc::new(flags_service); let user_settings = Rc::new(crate::services::UserSettingsService::new()); + let maintenance = Rc::new(crate::services::MaintenanceService::new()); Self { auth, user_settings, @@ -56,6 +58,7 @@ impl Services { websocket, rbac, flags, + maintenance, } } } diff --git a/frontend/src/model/event_message.rs b/frontend/src/model/event_message.rs index f2bc9d30a..86b23d2e9 100644 --- a/frontend/src/model/event_message.rs +++ b/frontend/src/model/event_message.rs @@ -29,6 +29,7 @@ pub enum EventMessage { tasks: Rc>, }, RecordingRulesChanged, + MaintenanceChanged(Rc), /// The socket refused to serve recordings for an actionable reason. /// Carries the same stable code the REST routes use, so views map /// both through one i18n table. diff --git a/frontend/src/services/maintenance_service.rs b/frontend/src/services/maintenance_service.rs new file mode 100644 index 000000000..6fc3eedeb --- /dev/null +++ b/frontend/src/services/maintenance_service.rs @@ -0,0 +1,34 @@ +use crate::{ + error::Error, + services::{get_base_href, request_delete, request_get, request_put}, +}; +use shared::{ + model::{MaintenanceRequestDto, MaintenanceStateDto}, + utils::concat_path_leading_slash, +}; + +pub struct MaintenanceService { + path: String, +} +impl Default for MaintenanceService { + fn default() -> Self { Self::new() } +} +impl MaintenanceService { + pub fn new() -> Self { Self { path: concat_path_leading_slash(&get_base_href(), "api/v1/maintenance") } } + fn input_path(&self, input: &str) -> String { + format!("{}/{}", self.path, String::from(js_sys::encode_uri_component(input))) + } + pub async fn get(&self) -> Result { + request_get(&self.path, None, None).await?.ok_or(Error::DeserializeError) + } + /// `input` may be `*` for all inputs. Re-adding an input updates its remark. + pub async fn add(&self, input: &str, reason: Option) -> Result { + request_put(&self.input_path(input), MaintenanceRequestDto { reason }, None, None) + .await? + .ok_or(Error::DeserializeError) + } + /// `input` may be `*` for all inputs. + pub async fn remove(&self, input: &str) -> Result { + request_delete(&self.input_path(input), None, None).await?.ok_or(Error::DeserializeError) + } +} diff --git a/frontend/src/services/mod.rs b/frontend/src/services/mod.rs index 22736095f..e86aaf7d6 100644 --- a/frontend/src/services/mod.rs +++ b/frontend/src/services/mod.rs @@ -3,6 +3,7 @@ mod config_service; mod dialog_service; mod event_service; mod flags_service; +mod maintenance_service; mod playlist_service; mod rbac_service; mod recording_service; @@ -17,10 +18,10 @@ mod user_service; mod websocket_service; pub use self::{ - auth_service::*, config_service::*, dialog_service::*, event_service::*, flags_service::*, playlist_service::*, - rbac_service::*, recording_service::*, requests::*, status_service::*, stream_history_service::*, - streams_service::*, target_bouquet_service::*, toastr_service::*, user_api_service::*, user_service::*, - websocket_service::*, + auth_service::*, config_service::*, dialog_service::*, event_service::*, flags_service::*, maintenance_service::*, + playlist_service::*, rbac_service::*, recording_service::*, requests::*, status_service::*, + stream_history_service::*, streams_service::*, target_bouquet_service::*, toastr_service::*, user_api_service::*, + user_service::*, websocket_service::*, }; mod user_settings_service; diff --git a/frontend/src/services/websocket_service.rs b/frontend/src/services/websocket_service.rs index d978941cb..ac8f951e6 100644 --- a/frontend/src/services/websocket_service.rs +++ b/frontend/src/services/websocket_service.rs @@ -482,6 +482,9 @@ impl WebSocketService { ProtocolMessage::RecordingRulesChanged => { event_service.broadcast(EventMessage::RecordingRulesChanged); } + ProtocolMessage::MaintenanceChangedResponse(entries) => { + event_service.broadcast(EventMessage::MaintenanceChanged(Rc::new(entries))); + } ProtocolMessage::RecordingWsError { code } => { event_service.broadcast(EventMessage::RecordingUnavailable { code }); } diff --git a/resources/maintenance.jpg b/resources/maintenance.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b8cfed0e9f2116224099574f29faed3bcbeff35f GIT binary patch literal 187647 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 }x$#U)U)H}X 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 literal 0 HcmV?d00001 diff --git a/shared/src/defaults/hls.rs b/shared/src/defaults/hls.rs index f6644ba88..e365f610c 100644 --- a/shared/src/defaults/hls.rs +++ b/shared/src/defaults/hls.rs @@ -47,5 +47,6 @@ pub const LOW_PRIORITY_PREEMPTED: &str = "low_priority_preempted.ts"; pub const USER_ACCOUNT_EXPIRED: &str = "user_account_expired.ts"; pub const PANEL_API_PROVISIONING: &str = "panel_api_provisioning.ts"; pub const HLS_SESSION_OR_LEASE_EXPIRED: &str = "hls_session_or_lease_expired.ts"; +pub const MAINTENANCE: &str = "maintenance.ts"; pub const PANEL_API_PROVISIONING_HLS_SEGMENT_COUNT: usize = 6; pub const PANEL_API_PROVISIONING_HLS_SEGMENT_PREFIX: &str = "panel_api_provisioning_hls_"; diff --git a/shared/src/model/auth/permission.rs b/shared/src/model/auth/permission.rs index b7827d550..ef2838bb4 100644 --- a/shared/src/model/auth/permission.rs +++ b/shared/src/model/auth/permission.rs @@ -20,7 +20,9 @@ create_bitset!( RecordingRead, RecordingCreate, RecordingManage, - RecordingDelete + RecordingDelete, + MaintenanceRead, + MaintenanceWrite ); pub const PERM_ALL: PermissionSet = PermissionSet::ALL; @@ -44,6 +46,8 @@ pub const PERMISSION_NAMES: &[(&str, Permission)] = &[ ("recording.create", Permission::RecordingCreate), ("recording.manage", Permission::RecordingManage), ("recording.delete", Permission::RecordingDelete), + ("maintenance.read", Permission::MaintenanceRead), + ("maintenance.write", Permission::MaintenanceWrite), ]; pub fn permission_from_name(name: &str) -> Option { @@ -58,6 +62,15 @@ pub fn permission_to_name(perm: Permission) -> Option<&'static str> { mod tests { use super::*; + #[test] + fn maintenance_permissions_have_stable_names() { + assert_eq!(permission_from_name("maintenance.read"), Some(Permission::MaintenanceRead)); + assert_eq!(permission_from_name("maintenance.write"), Some(Permission::MaintenanceWrite)); + assert_eq!(permission_to_name(Permission::MaintenanceWrite), Some("maintenance.write")); + assert!(PERM_ALL.contains(Permission::MaintenanceRead)); + assert!(PERM_ALL.contains(Permission::MaintenanceWrite)); + } + #[test] fn test_permission_set_new_is_empty() { let set = PermissionSet::new(); @@ -173,13 +186,15 @@ mod tests { ("recording.create", 1 << 15), ("recording.manage", 1 << 16), ("recording.delete", 1 << 17), + ("maintenance.read", 1 << 18), + ("maintenance.write", 1 << 19), ]; // The table is the layout of this schema version. Changing a bit means // editing the table, and the version beside it has to move with it so // tokens minted under the old layout fail closed. assert_eq!( crate::model::CURRENT_PERMISSION_SCHEMA_VERSION, - 4, + 5, "the bit table changed: bump CURRENT_PERMISSION_SCHEMA_VERSION and this expected value together" ); assert_eq!(PermissionSet::VARIANT_COUNT, expected.len()); diff --git a/shared/src/model/auth/user.rs b/shared/src/model/auth/user.rs index 2efff9c73..f5daf6eb9 100644 --- a/shared/src/model/auth/user.rs +++ b/shared/src/model/auth/user.rs @@ -16,7 +16,7 @@ pub const TOKEN_NO_AUTH: &str = "authorized"; /// /// The layout this version stands for is pinned in the permission bit table /// test, next to the expected value of this constant. -pub const CURRENT_PERMISSION_SCHEMA_VERSION: u16 = 4; +pub const CURRENT_PERMISSION_SCHEMA_VERSION: u16 = 5; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Claims { diff --git a/shared/src/model/custom_video_stream_type.rs b/shared/src/model/custom_video_stream_type.rs index 7a34140ea..2c787a27a 100644 --- a/shared/src/model/custom_video_stream_type.rs +++ b/shared/src/model/custom_video_stream_type.rs @@ -17,6 +17,7 @@ pub enum CustomVideoStreamType { UserAccountExpired, Provisioning, HlsSessionOrLeaseExpired, + Maintenance, } impl fmt::Display for CustomVideoStreamType { @@ -29,6 +30,7 @@ impl fmt::Display for CustomVideoStreamType { CustomVideoStreamType::UserAccountExpired => "user_account_expired", CustomVideoStreamType::Provisioning => "provisioning", CustomVideoStreamType::HlsSessionOrLeaseExpired => "hls_session_or_lease_expired", + CustomVideoStreamType::Maintenance => "maintenance", }; write!(f, "{s}") } @@ -46,6 +48,7 @@ impl FromStr for CustomVideoStreamType { "user_account_expired" => Ok(Self::UserAccountExpired), "provisioning" => Ok(Self::Provisioning), "hls_session_or_lease_expired" => Ok(Self::HlsSessionOrLeaseExpired), + "maintenance" => Ok(Self::Maintenance), _ => Err(TuliproxError::Config(format!("Unknown stream type: {s}"))), } } @@ -68,3 +71,16 @@ impl<'de> Deserialize<'de> for CustomVideoStreamType { Self::from_str(&s).map_err(serde::de::Error::custom) } } + +#[cfg(test)] +mod tests { + use super::CustomVideoStreamType; + use std::str::FromStr; + + #[test] + fn maintenance_round_trips() { + let parsed = CustomVideoStreamType::from_str("maintenance").unwrap(); + assert_eq!(parsed, CustomVideoStreamType::Maintenance); + assert_eq!(parsed.to_string(), "maintenance"); + } +} diff --git a/shared/src/model/event/kind.rs b/shared/src/model/event/kind.rs index fc60c9c7e..519b284fc 100644 --- a/shared/src/model/event/kind.rs +++ b/shared/src/model/event/kind.rs @@ -60,6 +60,8 @@ pub enum EventKind { /// Payload-free progress nudge, appended last so no existing kind's bit /// position shifts. RecordingProgress, + /// Appended last for the same reason. + MaintenanceChanged, } impl EventKind { @@ -67,7 +69,7 @@ impl EventKind { /// /// The mask type below indexes into this, so the order is load-bearing: /// it is the bit order, not just a listing. - pub const ALL: [Self; 42] = [ + pub const ALL: [Self; 43] = [ Self::ServerError, Self::ServerStarted, Self::ServerShutdown, @@ -110,6 +112,7 @@ impl EventKind { Self::AuthSignInThrottled, Self::AuthPermissionDenied, Self::RecordingProgress, + Self::MaintenanceChanged, ]; /// This kind's bit position. @@ -175,6 +178,7 @@ impl EventKind { | Self::StreamProbeFailed | Self::NotificationDeadLettered | Self::ScheduledTaskFailed => Permission::SystemRead, + Self::MaintenanceChanged => Permission::MaintenanceRead, } } @@ -273,6 +277,7 @@ impl EventKind { Self::AuthSignInFailed => "auth.sign_in.failed", Self::AuthSignInThrottled => "auth.sign_in.throttled", Self::AuthPermissionDenied => "auth.permission.denied", + Self::MaintenanceChanged => "maintenance.changed", } } diff --git a/shared/src/model/event/message.rs b/shared/src/model/event/message.rs index deece278a..f584d06a1 100644 --- a/shared/src/model/event/message.rs +++ b/shared/src/model/event/message.rs @@ -4,7 +4,7 @@ use crate::model::{ notification::{registry, EventId, Severity}, stats::SourceStats, ActiveUserConnectionChange, ConfigReloadFailure, ConfigType, ConnectionDenied, DiskAlert, LibraryScanProgressEvent, - LibraryScanSummaryStatus, MetadataUpdateFailure, MsgKind, NotificationDeadLetter, Permission, + LibraryScanSummaryStatus, MaintenanceEntries, MetadataUpdateFailure, MsgKind, NotificationDeadLetter, Permission, PlaylistUpdateProgressEvent, PlaylistUpdateRunId, PlaylistUpdateRunOrder, PlaylistUpdateState, ProviderAccountEvent, ProviderAccountState, ProviderFetchFailure, ProviderPoolExhausted, ProviderPriorityFallback, RecordingLifecycleMessage, ScheduledTaskFailure, ServerLifecycleEvent, ServerLifecycleState, StreamProbeFailure, @@ -34,6 +34,8 @@ pub enum EventMessage { /// session can rate limit it without also delaying a state transition. RecordingProgress, RecordingRulesChanged, + /// Inputs in maintenance after a change; the whole map, it is small. + MaintenanceChanged(Arc), InputMetadataUpdatesCompleted(Arc), InputMetadataUpdatesStarted(Arc), /// A metadata update cycle ended with tasks it could not finish. @@ -126,6 +128,7 @@ impl EventMessage { Self::RecordingChanged => EventKind::RecordingChanged, Self::RecordingProgress => EventKind::RecordingProgress, Self::RecordingRulesChanged => EventKind::RecordingRulesChanged, + Self::MaintenanceChanged(_) => EventKind::MaintenanceChanged, Self::InputMetadataUpdatesCompleted(_) => EventKind::InputMetadataUpdatesCompleted, Self::InputMetadataUpdatesStarted(_) => EventKind::InputMetadataUpdatesStarted, Self::InputMetadataUpdatesFailed(_) => EventKind::InputMetadataUpdatesFailed, @@ -263,6 +266,7 @@ impl EventMessage { Self::ActiveProvider(_, _) => registry::PROVIDER_CONNECTIONS_CHANGED, Self::RecordingChanged => registry::RECORDING_QUEUE_CHANGED, Self::RecordingRulesChanged => registry::RECORDING_RULES_CHANGED, + Self::MaintenanceChanged(_) => registry::MAINTENANCE_CHANGED, Self::PlaylistUpdateProgress(_) | Self::SystemInfoUpdate(_) | Self::RecordingProgress => return None, }) } @@ -298,6 +302,7 @@ impl EventMessage { Self::SystemInfoUpdate(info) => encode(info.as_ref()), Self::LibraryScanProgress(progress) => encode(progress), Self::RecordingChanged | Self::RecordingProgress | Self::RecordingRulesChanged => serde_json::Value::Null, + Self::MaintenanceChanged(entries) => encode(entries.as_ref()), Self::InputMetadataUpdatesStarted(input) | Self::InputMetadataUpdatesCompleted(input) => { serde_json::json!({ "input": input.as_ref() }) } diff --git a/shared/src/model/event/tests.rs b/shared/src/model/event/tests.rs index db9bdfbe0..d36856c3f 100644 --- a/shared/src/model/event/tests.rs +++ b/shared/src/model/event/tests.rs @@ -62,3 +62,12 @@ fn groups_changed_alias_resolves_for_kinds() { // to the old name keep receiving group changes. assert_eq!(EventKind::from_wire_name("playlist.groups.changed"), Some(EventKind::PlaylistWatchChanged)); } + +#[test] +fn maintenance_changed_requires_maintenance_read() { + assert_eq!( + EventKind::MaintenanceChanged.required_permission(), + crate::model::permission::Permission::MaintenanceRead + ); + assert_eq!(EventKind::MaintenanceChanged.as_wire_name(), "maintenance.changed"); +} diff --git a/shared/src/model/maintenance.rs b/shared/src/model/maintenance.rs new file mode 100644 index 000000000..d83503e01 --- /dev/null +++ b/shared/src/model/maintenance.rs @@ -0,0 +1,101 @@ +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// Path segment that addresses every known input at once. +pub const MAINTENANCE_WILDCARD: &str = "*"; +/// Upper bound for a maintenance remark, in characters. +pub const MAINTENANCE_REASON_MAX_CHARS: usize = 500; + +/// Why and since when an input is in maintenance. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InputMaintenance { + /// Unix timestamp in seconds; kept when the remark is updated. + pub since: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +/// Inputs in maintenance, keyed by canonical input name. +pub type MaintenanceEntries = BTreeMap; + +/// What the maintenance API returns after every call. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct MaintenanceStateDto { + pub entries: MaintenanceEntries, + /// Canonical names of all configured inputs, for the selection in the dialog. + pub inputs: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct MaintenanceRequestDto { + #[serde(default)] + pub reason: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MaintenanceReasonTooLong; + +impl std::fmt::Display for MaintenanceReasonTooLong { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "maintenance reason exceeds {MAINTENANCE_REASON_MAX_CHARS} characters") + } +} + +impl std::error::Error for MaintenanceReasonTooLong {} + +/// Trims the remark; an empty remark is no remark. +pub fn normalize_maintenance_reason(reason: Option<&str>) -> Result, MaintenanceReasonTooLong> { + let Some(reason) = reason.map(str::trim).filter(|reason| !reason.is_empty()) else { + return Ok(None); + }; + if reason.chars().count() > MAINTENANCE_REASON_MAX_CHARS { + return Err(MaintenanceReasonTooLong); + } + Ok(Some(reason.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reason_is_trimmed_and_empty_becomes_none() { + assert_eq!(normalize_maintenance_reason(None), Ok(None)); + assert_eq!(normalize_maintenance_reason(Some(" ")), Ok(None)); + assert_eq!(normalize_maintenance_reason(Some(" down ")), Ok(Some("down".to_string()))); + } + + #[test] + fn reason_longer_than_limit_is_rejected() { + let ok = "x".repeat(MAINTENANCE_REASON_MAX_CHARS); + let too_long = "x".repeat(MAINTENANCE_REASON_MAX_CHARS + 1); + assert_eq!(normalize_maintenance_reason(Some(&ok)), Ok(Some(ok.clone()))); + assert_eq!(normalize_maintenance_reason(Some(&too_long)), Err(MaintenanceReasonTooLong)); + } + + #[test] + fn reason_limit_counts_characters_not_bytes() { + let umlauts = "รค".repeat(MAINTENANCE_REASON_MAX_CHARS); + assert!(normalize_maintenance_reason(Some(¨auts)).is_ok()); + } + + #[test] + fn state_dto_round_trips_as_json() { + let mut entries = MaintenanceEntries::new(); + entries + .insert("provider_a".to_string(), InputMaintenance { since: 1_760_350_000, reason: Some("down".into()) }); + let dto = MaintenanceStateDto { entries, inputs: vec!["provider_a".into(), "provider_b".into()] }; + let json = serde_json::to_string(&dto).unwrap(); + assert_eq!( + json, + r#"{"entries":{"provider_a":{"since":1760350000,"reason":"down"}},"inputs":["provider_a","provider_b"]}"# + ); + assert_eq!(serde_json::from_str::(&json).unwrap(), dto); + } + + #[test] + fn missing_reason_deserializes_as_none() { + let entry: InputMaintenance = serde_json::from_str(r#"{"since":1}"#).unwrap(); + assert_eq!(entry.reason, None); + } +} diff --git a/shared/src/model/mod.rs b/shared/src/model/mod.rs index f3dd2c192..ccc549d1e 100644 --- a/shared/src/model/mod.rs +++ b/shared/src/model/mod.rs @@ -18,6 +18,7 @@ mod ip_check; mod item_field; mod library_request; mod log; +mod maintenance; mod mapping; mod media_properties; mod messaging; @@ -79,16 +80,16 @@ pub mod xtream_const; pub use self::{ active_user_connection_change::*, auth::*, auth_audit::*, cluster_flags::*, config::*, connection_denied::*, custom_video_stream_type::*, epg::*, epg_grid::*, epg_request::*, event::*, input_refresh_policy::*, - input_update_action::*, ip_check::*, item_field::*, library_request::*, log::*, mapping::*, media_properties::*, - messaging::*, metadata_update_failure::*, notification::*, notification_dead_letter::*, pagination::*, playlist::*, - playlist_categories::*, playlist_info_document::*, playlist_request::*, playlist_update_run::*, - playlist_update_status::*, processing_order::*, progress::*, provider_fetch_failure::*, provider_pool::*, - recording::*, recording_math::*, regex_cache::*, resource::*, scheduled_task_failure::*, search_fields::*, - search_request::*, server_lifecycle::*, short_epg::*, stalker::*, stalker_item::*, stats::*, status_check::*, - stream_history::*, stream_history_record::*, stream_info::*, stream_meter::*, stream_probe_failure::*, - stream_properties::*, strm_export_style::*, system_info::*, target_bouquet::*, target_type::*, transfer::*, - ui_playlist_item::*, user_command::*, user_id::*, user_lifecycle::*, uuidtype::*, watch_health::*, web_socket::*, - xtream::*, + input_update_action::*, ip_check::*, item_field::*, library_request::*, log::*, maintenance::*, mapping::*, + media_properties::*, messaging::*, metadata_update_failure::*, notification::*, notification_dead_letter::*, + pagination::*, playlist::*, playlist_categories::*, playlist_info_document::*, playlist_request::*, + playlist_update_run::*, playlist_update_status::*, processing_order::*, progress::*, provider_fetch_failure::*, + provider_pool::*, recording::*, recording_math::*, regex_cache::*, resource::*, scheduled_task_failure::*, + search_fields::*, search_request::*, server_lifecycle::*, short_epg::*, stalker::*, stalker_item::*, stats::*, + status_check::*, stream_history::*, stream_history_record::*, stream_info::*, stream_meter::*, + stream_probe_failure::*, stream_properties::*, strm_export_style::*, system_info::*, target_bouquet::*, + target_type::*, transfer::*, ui_playlist_item::*, user_command::*, user_id::*, user_lifecycle::*, uuidtype::*, + watch_health::*, web_socket::*, xtream::*, }; pub use ids::*; pub use prepare::*; diff --git a/shared/src/model/notification/registry.rs b/shared/src/model/notification/registry.rs index 3a490de7a..9ab3f4eab 100644 --- a/shared/src/model/notification/registry.rs +++ b/shared/src/model/notification/registry.rs @@ -71,6 +71,7 @@ pub const STREAM_PROBE_FAILED: EventId = EventId::new("stream.probe.failed"); // ---- dvr ------------------------------------------------------------ pub const RECORDING_QUEUE_CHANGED: EventId = EventId::new("recording.queue.changed"); pub const RECORDING_RULES_CHANGED: EventId = EventId::new("recording.rules.changed"); +pub const MAINTENANCE_CHANGED: EventId = EventId::new("maintenance.changed"); // ---- scheduler ------------------------------------------------------- pub const SCHEDULED_TASK_FAILED: EventId = EventId::new("scheduled_task.failed"); @@ -257,6 +258,11 @@ pub const ALL: &[EventDescriptor] = &[ severity: Severity::Info, description: "The recording rule set changed.", }, + EventDescriptor { + id: MAINTENANCE_CHANGED, + severity: Severity::Warn, + description: "An input was put into or taken out of maintenance mode.", + }, EventDescriptor { id: SCHEDULED_TASK_FAILED, severity: Severity::Error, diff --git a/shared/src/model/recording/metadata.rs b/shared/src/model/recording/metadata.rs index 71dc3e9fb..8aa3f4040 100644 --- a/shared/src/model/recording/metadata.rs +++ b/shared/src/model/recording/metadata.rs @@ -126,6 +126,11 @@ pub struct RecordingMetadata { /// Persisted at-most-once notification markers. #[serde(default)] pub notification_markers: Vec, + /// Set once the first provider connection was acquired. A later acquisition + /// (retry, requeue after preemption, restart) resumes the recording rather + /// than starting a new one. Internal; never projected into a DTO. + #[serde(default)] + pub provider_started: bool, /// Terminal-only previous state while a deletion is in progress. `None` /// outside the `Deleting` transitional state. pub deleting_previous_state: Option, @@ -167,6 +172,7 @@ impl RecordingMetadata { measured_bytes: 0, completed_at: None, notification_markers: Vec::new(), + provider_started: false, group: None, series_name: None, deleting_previous_state: None, diff --git a/shared/src/model/stream_history_record.rs b/shared/src/model/stream_history_record.rs index 759a130e0..96f76f356 100644 --- a/shared/src/model/stream_history_record.rs +++ b/shared/src/model/stream_history_record.rs @@ -87,6 +87,7 @@ pub enum ConnectFailureReason { Preempted, SessionExpired, Provisioning, + Maintenance, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/shared/src/model/web_socket.rs b/shared/src/model/web_socket.rs index 58eb886ba..3761c4d7c 100644 --- a/shared/src/model/web_socket.rs +++ b/shared/src/model/web_socket.rs @@ -117,6 +117,9 @@ pub enum ProtocolMessage { /// re-fetches `/api/v1/recording/rules` on receipt. No payload /// โ€” the rule list is small and the GET is cheap. RecordingRulesChanged, + /// Inputs in maintenance after a change. Carries the whole map: it is small, + /// and a client never has to merge. + MaintenanceChangedResponse(crate::model::MaintenanceEntries), /// The socket cannot serve recordings to this session, and the reason /// is actionable. ///