Feature/connection handling (#869)

* **New Features**
  * Added an opt-in runtime liveness watchdog with diagnostics, health status, and optional automatic restart.
  * Added configurable stream cleanup capacity and eviction re-entry protection.
  * Expanded configuration and UI access for users with playlist or user permissions, with safer data sanitization.
  * Added testkit scenarios and reporting for playback, admission, provider limits, sharing, and VOD behavior.
  * Added support for experimental-branch Docker builds.

* **Bug Fixes**
  * Improved provider prioritization, shared playback capacity, cleanup, reconnect handling, and concurrent client behavior.
  * Fixed playlist parsing for declared media types.
  * Release links now use the configured destination.
This commit is contained in:
euzu
2026-09-19 10:17:23 +02:00
committed by GitHub
parent c2a636282c
commit 6a567d264e
148 changed files with 27213 additions and 3847 deletions
@@ -1,38 +1,22 @@
use std::sync::Arc;
use tuliprox_core::model::ProviderHandle;
use tuliprox_session::ActiveProviderManager;
use tuliprox_session::{ActiveProviderManager, ManagedProviderHandle};
pub struct ProbeHandleGuard {
manager: Arc<ActiveProviderManager>,
handle: Option<ProviderHandle>,
}
/// Thin wrapper around `ManagedProviderHandle` for probe allocations. It preserves the
/// same synchronous release contract without duplicating the release/drop logic.
pub struct ProbeHandleGuard(ManagedProviderHandle);
impl ProbeHandleGuard {
pub fn new(manager: &Arc<ActiveProviderManager>, handle: ProviderHandle) -> Self {
Self { manager: Arc::clone(manager), handle: Some(handle) }
Self(ManagedProviderHandle::new(Arc::clone(manager), handle))
}
#[inline]
pub fn handle(&self) -> Option<&ProviderHandle> { self.handle.as_ref() }
pub fn handle(&self) -> Option<&ProviderHandle> { self.0.handle() }
pub async fn release(mut self) {
if let Some(handle) = self.handle.take() {
self.manager.release_handle(&handle).await;
}
}
}
impl Drop for ProbeHandleGuard {
fn drop(&mut self) {
let Some(handle) = self.handle.take() else {
return;
};
let manager = Arc::clone(&self.manager);
if let Ok(runtime_handle) = tokio::runtime::Handle::try_current() {
runtime_handle.spawn(async move {
manager.release_handle(&handle).await;
});
}
pub fn release(self) {
// The inner managed handle releases the slot synchronously on drop.
drop(self);
}
}
@@ -105,15 +89,37 @@ mod tests {
let handle = manager
.acquire_connection_for_probe(&input_name, default_probe_user_priority())
.await
.expect("probe allocation should succeed");
assert_eq!(manager.get_provider_connections_count().await, 1);
assert_eq!(manager.get_provider_connections_count(), 1);
let guard = ProbeHandleGuard::new(&manager, handle);
drop(guard);
tokio::task::yield_now().await;
tokio::task::yield_now().await;
assert_eq!(manager.get_provider_connections_count().await, 0);
assert_eq!(manager.get_provider_connections_count(), 0);
}
#[test]
fn probe_handle_guard_releases_provider_slot_outside_tokio_runtime() {
let rt =
tokio::runtime::Builder::new_current_thread().enable_all().build().expect("tokio runtime should build");
let (manager, guard) = rt.block_on(async {
let app_cfg = create_test_app_config();
let event_manager = Arc::new(EventManager::new());
let manager = Arc::new(ActiveProviderManager::new(&app_cfg, &event_manager));
let input_name = "provider_1".intern();
let handle = manager
.acquire_connection_for_probe(&input_name, default_probe_user_priority())
.expect("probe allocation should succeed");
assert_eq!(manager.get_provider_connections_count(), 1);
let guard = ProbeHandleGuard::new(&manager, handle);
(manager, guard)
});
// Drop outside of any tokio runtime context:
drop(guard);
assert_eq!(manager.get_provider_connections_count(), 0);
}
}
@@ -1213,7 +1213,7 @@ mod tests {
finish_completed_refresh_checked(&app_config, temp.path(), identity, &checkpoint, update_quality(90, 0, 0))
.await?;
let StalkerRefreshOutcome::Failed { quality_acceptances, error: _, .. } = reports.into_outcome() else {
let StalkerRefreshOutcome::Failed { quality_acceptances, .. } = reports.into_outcome() else {
return Err("expected failed Stalker completion with retained reports".into());
};
assert_eq!(
@@ -1258,7 +1258,7 @@ mod tests {
finish_completed_refresh_checked(&app_config, temp.path(), identity, &checkpoint, update_quality(90, 0, 0))
.await?;
let StalkerRefreshOutcome::Failed { quality_acceptances, error: _, .. } = reports.into_outcome() else {
let StalkerRefreshOutcome::Failed { quality_acceptances, .. } = reports.into_outcome() else {
return Err("expected failed Stalker completion with retained reports".into());
};
assert_eq!(quality_acceptances.len(), 1);
@@ -239,7 +239,6 @@ async fn prepare_generic_stream_metadata<E: EventSink>(
} else {
active_provider
.acquire_connection_for_probe(&input.name, probe_priority)
.await
.map(|handle| ProbeHandleGuard::new(active_provider, handle))
};
@@ -286,7 +285,7 @@ async fn prepare_generic_stream_metadata<E: EventSink>(
};
if let Some(handle) = acquired_handle {
handle.release().await;
handle.release();
}
let (raw_video, raw_audio, stats) = match probe_data {
@@ -120,10 +120,7 @@ async fn playlist_resolve_series_info<E: EventSink + Clone + 'static, M: Metadat
target: &ConfigTarget,
) {
let filter = |pli: &PlaylistItem| {
if pli.header.xtream_cluster != XtreamCluster::Series || pli.header.item_type != PlaylistItemType::SeriesInfo {
return false;
}
true
pli.header.xtream_cluster == XtreamCluster::Series && pli.header.item_type == PlaylistItemType::SeriesInfo
};
// Skip if nothing to do
@@ -928,7 +925,6 @@ pub async fn update_series_metadata(
} else {
active_provider
.acquire_connection_for_probe(&input.name, probe_priority)
.await
.map(|handle| ProbeHandleGuard::new(active_provider, handle))
};
@@ -957,7 +953,7 @@ pub async fn update_series_metadata(
}
}
if let Some(guard) = temp_handle {
guard.release().await;
guard.release();
}
continue;
}
@@ -971,7 +967,7 @@ pub async fn update_series_metadata(
probe_url.as_ref()
);
if let Some(guard) = temp_handle {
guard.release().await;
guard.release();
}
continue;
}
@@ -1045,7 +1041,7 @@ pub async fn update_series_metadata(
}
if let Some(guard) = temp_handle {
guard.release().await;
guard.release();
}
}
}
@@ -118,10 +118,7 @@ async fn playlist_resolve_vod_info<E: EventSink + Clone + 'static, M: MetadataUp
do_probe: bool,
) {
let filter = |pli: &PlaylistItem| {
if pli.header.xtream_cluster != XtreamCluster::Video || pli.header.item_type != PlaylistItemType::Video {
return false;
}
true
pli.header.xtream_cluster == XtreamCluster::Video && pli.header.item_type == PlaylistItemType::Video
};
let resolve_tmdb_enabled = fpl.input.has_flag(ConfigInputFlags::ResolveTmdb);
@@ -858,7 +855,6 @@ pub async fn update_vod_metadata(
} else {
active_provider
.acquire_connection_for_probe(&input.name, probe_priority)
.await
.map(|handle| ProbeHandleGuard::new(active_provider, handle))
};
@@ -927,7 +923,7 @@ pub async fn update_vod_metadata(
}
if let Some(guard) = temp_handle {
guard.release().await;
guard.release();
}
} else {
debug_if_enabled!(