use super::providers::{LibraryProvider, PlexProvider, StalkerProvider, XmltvEpgProvider}; use crate::{ fetched_playlist::FetchedPlaylist, input_cache, input_cache::ClusterState, metadata_sink::{MetadataUpdateSink, NoopMetadataSink}, parser::xmltv::{flatten_tvguide, merge_epg_trees, EpgMergeAccumulator, TVGuide}, playlist_watch::{process_group_watch, process_target_groups_watch}, processor::{ epg::{clear_invalid_live_epg_ids, process_playlist_epg, retain_epg_referenced_by_groups}, sort::sort_playlist, xtream_series::playlist_resolve_series, xtream_vod::playlist_resolve_vod, StalkerRefreshMode, }, }; use futures::{FutureExt, StreamExt}; use indexmap::IndexMap; use log::{debug, error, info, log_enabled, trace, warn, Level}; use path_clean::PathClean; use shared::{ concat_string, defaults::{default_as_default, default_probe_delay_secs, default_probe_live_interval}, error::{get_errors_notify_message, TuliproxError}, foundation::{get_field_value, set_field_value, Filter, ValueAccessor, ValueProvider}, model::{ ClusterFlags, ConfigTargetOptions, CounterModifier, EventMessage, EventSink, FieldGet, FieldSet, InputStats, InputType, MappingStage, PipelineStats, PlaylistGroup, PlaylistItem, PlaylistItemType, PlaylistStats, PlaylistUpdateProgressEvent, PlaylistUpdateSummary, ProviderFetchFailure, SourceStats, StreamProperties, TargetStats, UUIDType, WatchDisabled, WatchDisabledReason, WatchUnmatched, XtreamCluster, }, utils::{create_alias_uuid, interner_gc, sanitize_sensitive_info, Internable}, }; use std::{ collections::{HashMap, HashSet}, future::Future, path::PathBuf, sync::{Arc, Weak}, time::{Duration, Instant}, }; use tokio::{ sync::{watch, Mutex, OwnedRwLockWriteGuard, RwLock}, task::JoinSet, }; use tuliprox_core::{ model::{ is_valid, retain_filtered_playlist, AppConfig, CompiledMapping, ConfigFavourites, ConfigInput, ConfigInputFlags, ConfigInputOptions, ConfigRename, ConfigTarget, Epg, FilterOutcome, MappingProgram, ProcessTargets, ProviderIdType, ResolveReason, ReverseProxyDisabledHeaderConfig, TransformStage, UpdateGuard, UpdateTask, }, utils::{debug_if_enabled, log_memory_snapshot, trace_if_enabled, StepMeasure, StepMeasureCallback}, }; use tuliprox_curation::curate_trakt_categories; use tuliprox_iptv::{ epg::{CountingEpgSink, EpgFetchRequest, EpgProvider}, error::ProviderErrorKind, provider::{ BatchContainerProvider, M3uProvider, PlaylistFetch, PlaylistFetchRequest, PlaylistProvider, UnsupportedProvider, XtreamProvider, }, xtream, }; use tuliprox_repository::{ load_input_playlist, persist_input_playlist, persist_playlist, CategoryKey, MemoryPlaylistSource, PlaylistSource, PlaylistStorageState, }; use tuliprox_session::ActiveProviderManager; const PLAYLIST_UPDATE_MAX_DURATION_SECS: u64 = 3600; const MAX_CONCURRENT_TARGET_FINALIZERS: usize = 2; mod ingest; mod target; mod transform; pub use self::{ingest::*, target::*, transform::*}; /// Work the composition root runs once the playlist lock is held, before the /// update proper starts. /// /// This was an `Option>` used for exactly one call. Passing the /// call instead of the state keeps `processing` from naming the server state. /// /// It was then an /// `Arc Pin + Send>> + Send + Sync>`: /// two layers of erasure and a heap allocation for a future that is awaited /// exactly once per update, and every call site had to spell out both /// coercions. As a trait it is one type parameter, monomorphised, with the /// future returned by value. pub trait UpdateBootstrap: Send + Sync + 'static { fn run(&self) -> impl Future + Send; } impl UpdateBootstrap for F where F: Fn() -> Fut + Send + Sync + 'static, Fut: Future + Send, { fn run(&self) -> impl Future + Send { self() } } /// The bootstrap type parameter of a run that has no bootstrap. /// /// A function pointer rather than a unit struct: it satisfies the blanket /// `Fn` impl above, so no second impl - and no coherence problem - is needed. /// A value of this type is never constructed; the field is always `None`. pub type NoBootstrap = fn() -> std::future::Ready<()>; /// Everything one playlist update run needs. /// /// `exec_processing` took twelve positional arguments, seven of them /// `Option<_>`, so a call site was a wall of `None`s and `Some(..)`s in which /// the reader had to count commas to work out which knob was being set - and /// the compiler could not catch two same-typed arguments swapped. /// /// Four of the twelve are always present, so they are constructor arguments. /// The rest are optional in fact as well as in type, and a call site names the /// ones it actually sets. pub struct ProcessingRun< E: EventSink + Clone + 'static, B: UpdateBootstrap = NoBootstrap, M: MetadataUpdateSink = NoopMetadataSink, > { client: reqwest::Client, app_config: Arc, targets: Arc, events: E, bootstrap: Option, playlist_state: Option>, update_guard: Option, disabled_headers: Option, provider_manager: Option>, metadata_manager: Option>, pre_processed_inputs: Option>>, acquired_permit: Option, } impl ProcessingRun { pub fn new(client: reqwest::Client, app_config: Arc, targets: Arc, events: E) -> Self { Self { client, app_config, targets, events, bootstrap: None, playlist_state: None, update_guard: None, disabled_headers: None, provider_manager: None, metadata_manager: None, pre_processed_inputs: None, acquired_permit: None, } } } impl ProcessingRun { /// Work the composition root runs once the lock is held, before the update /// proper starts. /// /// Changes the run's bootstrap type, so it rebuilds rather than mutates. #[must_use] pub fn with_bootstrap(self, bootstrap: B2) -> ProcessingRun { ProcessingRun { client: self.client, app_config: self.app_config, targets: self.targets, events: self.events, bootstrap: Some(bootstrap), playlist_state: self.playlist_state, update_guard: self.update_guard, disabled_headers: self.disabled_headers, provider_manager: self.provider_manager, metadata_manager: self.metadata_manager, pre_processed_inputs: self.pre_processed_inputs, acquired_permit: self.acquired_permit, } } #[must_use] pub fn with_playlist_state(mut self, state: impl Into>>) -> Self { self.playlist_state = state.into(); self } /// The lock this run acquires. Ignored when an already-acquired permit is /// supplied via [`Self::with_acquired_permit`]. #[must_use] pub fn with_update_guard(mut self, guard: impl Into>) -> Self { self.update_guard = guard.into(); self } #[must_use] pub fn with_disabled_headers(mut self, headers: impl Into>) -> Self { self.disabled_headers = headers.into(); self } #[must_use] pub fn with_provider_manager(mut self, manager: impl Into>>) -> Self { self.provider_manager = manager.into(); self } /// The background metadata worker. /// /// Changes the run's sink type, so it rebuilds rather than mutates. #[must_use] pub fn with_metadata_manager(self, manager: Arc) -> ProcessingRun { ProcessingRun { client: self.client, app_config: self.app_config, targets: self.targets, events: self.events, bootstrap: self.bootstrap, playlist_state: self.playlist_state, update_guard: self.update_guard, disabled_headers: self.disabled_headers, provider_manager: self.provider_manager, metadata_manager: Some(manager), pre_processed_inputs: self.pre_processed_inputs, acquired_permit: self.acquired_permit, } } // Always built with the default hasher here; generalising would buy nothing. #[allow(clippy::implicit_hasher)] #[must_use] pub fn with_pre_processed_inputs(mut self, inputs: impl Into>>>) -> Self { self.pre_processed_inputs = inputs.into(); self } /// A playlist lock the caller already holds. Takes precedence over /// [`Self::with_update_guard`], which would otherwise acquire a second one. #[must_use] pub fn with_acquired_permit(mut self, permit: impl Into>) -> Self { self.acquired_permit = permit.into(); self } } #[allow(clippy::too_many_lines)] pub async fn exec_processing( run: ProcessingRun, ) { let ProcessingRun { client, app_config, targets, events, bootstrap, playlist_state, update_guard, disabled_headers, provider_manager, metadata_manager, pre_processed_inputs, acquired_permit, } = run; let max_update_duration = Duration::from_secs(PLAYLIST_UPDATE_MAX_DURATION_SECS); let playlist_guard = if let Some(permit) = acquired_permit { Some(permit) } else if let Some(guard) = &update_guard { if let Some(permit) = guard.acquire_playlist_lock().await { Some(permit) } else { warn!("Playlist update lock is closed; update skipped."); events.emit(EventMessage::PlaylistUpdate(PlaylistUpdateSummary::state_only( shared::model::PlaylistUpdateState::Failure, ))); return; } } else { None }; if playlist_guard.is_some() { if let Some(bootstrap) = bootstrap.as_ref() { if tokio::time::timeout(max_update_duration, bootstrap.run()).await.is_err() { error!( "Playlist update bootstrap timed out after {PLAYLIST_UPDATE_MAX_DURATION_SECS} secs while holding playlist lock", ); events.emit(EventMessage::PlaylistUpdate(PlaylistUpdateSummary::state_only( shared::model::PlaylistUpdateState::Failure, ))); return; } } } // Pause background metadata/probe tasks for the full update lifecycle. let _background_pause_guard = if let Some(manager) = metadata_manager.as_ref() { Some(manager.acquire_update_pause_guard().await) } else { None }; info!("🌷 Update process started."); log_memory_snapshot("exec_processing start"); // Initialize Context let ctx = PlaylistProcessingContext { client, config: app_config.clone(), user_targets: targets.clone(), events: events.clone(), playlist_state: playlist_state.clone(), processed_inputs: Arc::new(Mutex::new(HashSet::new())), input_locks: Arc::new(Mutex::new(HashMap::new())), disabled_headers, provider_manager, metadata_manager, pre_processed_inputs: pre_processed_inputs.map(Arc::new), stalker_refresh_mode: if app_config.config.load().process_parallel { StalkerRefreshMode::Parallel } else if update_guard.is_some() { StalkerRefreshMode::ServerSlice } else { StalkerRefreshMode::Complete }, partial_refresh: Arc::new(std::sync::atomic::AtomicBool::new(false)), }; let start_time = Instant::now(); let process_result = tokio::time::timeout(max_update_duration, std::panic::AssertUnwindSafe(process_sources(&ctx)).catch_unwind()) .await; let (stats, errors) = match process_result { Ok(Ok((stats, errors))) => (stats, errors), Ok(Err(_)) => { error!("Playlist processing panicked"); events.emit(EventMessage::PlaylistUpdate(PlaylistUpdateSummary::state_only( shared::model::PlaylistUpdateState::Failure, ))); return; } Err(_) => { error!( "Playlist processing timed out after {PLAYLIST_UPDATE_MAX_DURATION_SECS} secs while holding playlist lock", ); events.emit(EventMessage::PlaylistUpdate(PlaylistUpdateSummary::state_only( shared::model::PlaylistUpdateState::Failure, ))); return; } }; log_memory_snapshot("exec_processing after_process_sources"); // Keep the update lock only for the critical processing section. drop(playlist_guard); debug!("Released playlist update lock; dispatching notifications and events"); // log errors for err in &errors { error!("{}", err.message()); } if !stats.is_empty() { if let Ok(stats_msg) = serde_json::to_string(&stats) { info!("stats: {stats_msg}"); } } // One event for the whole run, carrying both the outcome and what it // did. These used to be two independent messages - the statistics went // straight to the notification layer, the outcome went to the bus - and // because both resolve to `playlist.update.completed`, a successful // refresh notified twice. Subscribers now get one event with everything, // and the bridge renders the single message from it. let error = get_errors_notify_message!(errors, 255); let outcome = if error.is_some() { shared::model::PlaylistUpdateState::Failure } else if ctx.partial_refresh.load(std::sync::atomic::Ordering::Acquire) { shared::model::PlaylistUpdateState::Partial } else { shared::model::PlaylistUpdateState::Success }; events.emit(EventMessage::PlaylistUpdate(PlaylistUpdateSummary { state: outcome, stats, error })); let elapsed = start_time.elapsed().as_secs(); let update_finished_message = format!("🌷 Update process finished! Took {elapsed} secs."); events.emit(EventMessage::PlaylistUpdateProgress(PlaylistUpdateProgressEvent { target: "Playlist Update".to_string(), message: update_finished_message.clone(), })); log_memory_snapshot("exec_processing before_interner_gc"); debug!("StringInterner GC removed {} strings", interner_gc()); log_memory_snapshot("exec_processing after_interner_gc"); //trim_allocator_after_update(); info!("{update_finished_message}"); } #[cfg(test)] mod tests;