mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-24 08:42:23 +02:00
Feature/stalker (#780)
New Features - Added first-class Stalker/Ministra portal support, including live, VOD, series, playback-link refresh, and bulk EPG ingestion. - Added Stalker configuration and batch/CSV support in the Web UI. - Added playlist preview and playback handling for Stalker inputs, with support for partial refreshes.
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
mod playlist;
|
||||
mod stalker;
|
||||
mod stalker_refresh;
|
||||
mod xtream;
|
||||
// mod affix;
|
||||
mod xtream_vod;
|
||||
@@ -11,6 +13,10 @@ mod stream_probe;
|
||||
mod probe_handle_guard;
|
||||
mod resolve_options;
|
||||
pub use self::playlist::*;
|
||||
pub(crate) use self::stalker::{
|
||||
download_stalker_playlist, re_resolve_stalker_url, StalkerCluster,
|
||||
};
|
||||
pub(crate) use self::stalker_refresh::StalkerRefreshMode;
|
||||
pub use self::epg::*;
|
||||
pub use self::xtream::*;
|
||||
pub use self::xtream_vod::*;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,607 @@
|
||||
//! Stalker/Ministra playlist processor.
|
||||
//!
|
||||
//! Orchestrates Stalker authentication, resumable catalog refresh and B+Tree persistence. The
|
||||
//! processor is the single entry point the playlist dispatcher uses for `InputType::Stalker`
|
||||
//! — mirror of `xtream::download_xtream_playlist`.
|
||||
//!
|
||||
//! Reverse-proxy re-resolve (when a 4xx upstream error is observed) is implemented in the
|
||||
//! HLS/Xtream endpoints and reaches back into the API client via the helper
|
||||
//! [`crate::utils::network::stalker::client::StalkerApiClient::create_link`].
|
||||
|
||||
#![allow(clippy::too_many_lines, clippy::needless_pass_by_value)]
|
||||
|
||||
use log::{debug, info, warn};
|
||||
use parking_lot::Mutex;
|
||||
use lru::LruCache;
|
||||
use shared::error::TuliproxError;
|
||||
use shared::model::stalker::StalkerStreamKind;
|
||||
use shared::model::stalker_item::StalkerPlaylistItem;
|
||||
use shared::model::{PlaylistGroup, PlaylistItem};
|
||||
use shared::utils::Internable;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::num::NonZeroUsize;
|
||||
use std::sync::{Arc, LazyLock, Weak};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::model::{AppConfig, ConfigInput, ConfigInputFlags, StalkerInputConfig};
|
||||
use crate::processing::parser::stalker as parser;
|
||||
use crate::repository::stalker_repository::{
|
||||
ensure_stalker_storage_path, load_stalker_items_at, read_stalker_item_at,
|
||||
};
|
||||
use crate::repository::stalker_generation_repository::{load_active_manifest, load_checkpoint};
|
||||
use super::stalker_refresh::{
|
||||
advance_stalker_refresh, StalkerClusterSelection, StalkerRefreshMode, StalkerRefreshOutcome,
|
||||
};
|
||||
use crate::utils::network::stalker::client::StalkerApiClient;
|
||||
use crate::utils::network::stalker::error::StalkerError;
|
||||
|
||||
|
||||
/// Cluster selector used by the Stalker processor. Mirrors the Xtream cluster split
|
||||
/// (Live/Video/Series) but uses Stalker-native kind names.
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
|
||||
pub enum StalkerCluster {
|
||||
Live,
|
||||
Vod,
|
||||
Series,
|
||||
}
|
||||
|
||||
const DEFAULT_STALKER_CLUSTERS: [StalkerCluster; 3] =
|
||||
[StalkerCluster::Live, StalkerCluster::Vod, StalkerCluster::Series];
|
||||
|
||||
static RUNTIME_STALKER_CLIENTS: LazyLock<Mutex<LruCache<String, Arc<StalkerApiClient>>>> = LazyLock::new(|| {
|
||||
Mutex::new(LruCache::new(NonZeroUsize::new(64).unwrap_or(NonZeroUsize::MIN)))
|
||||
});
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
struct RuntimeLinkKey {
|
||||
fingerprint: u64,
|
||||
generation: u64,
|
||||
provider_id: u32,
|
||||
kind: StalkerStreamKind,
|
||||
}
|
||||
|
||||
struct RuntimeLink {
|
||||
url: Arc<str>,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
static RUNTIME_STALKER_LINKS: LazyLock<Mutex<LruCache<RuntimeLinkKey, RuntimeLink>>> = LazyLock::new(|| {
|
||||
Mutex::new(LruCache::new(NonZeroUsize::new(4096).unwrap_or(NonZeroUsize::MIN)))
|
||||
});
|
||||
|
||||
static STALKER_REFRESH_LOCKS: LazyLock<tokio::sync::Mutex<HashMap<Arc<str>, Weak<tokio::sync::Semaphore>>>> =
|
||||
LazyLock::new(|| tokio::sync::Mutex::new(HashMap::new()));
|
||||
|
||||
async fn try_acquire_stalker_refresh(input_name: &Arc<str>) -> Option<tokio::sync::OwnedSemaphorePermit> {
|
||||
let semaphore = {
|
||||
let mut locks = STALKER_REFRESH_LOCKS.lock().await;
|
||||
let semaphore = locks.get(input_name).and_then(Weak::upgrade).unwrap_or_else(|| {
|
||||
let semaphore = Arc::new(tokio::sync::Semaphore::new(1));
|
||||
locks.insert(Arc::clone(input_name), Arc::downgrade(&semaphore));
|
||||
semaphore
|
||||
});
|
||||
locks.retain(|_, lock| lock.strong_count() > 0);
|
||||
semaphore
|
||||
};
|
||||
semaphore.try_acquire_owned().ok()
|
||||
}
|
||||
|
||||
fn cached_resolved_link(key: RuntimeLinkKey, force_refresh: bool) -> Option<Arc<str>> {
|
||||
let mut cache = RUNTIME_STALKER_LINKS.lock();
|
||||
let expired = cache.peek(&key).is_some_and(|entry| entry.expires_at <= Instant::now());
|
||||
if force_refresh || expired {
|
||||
cache.pop(&key);
|
||||
return None;
|
||||
}
|
||||
cache.get(&key).map(|entry| Arc::clone(&entry.url))
|
||||
}
|
||||
|
||||
fn cache_resolved_link(key: RuntimeLinkKey, url: Arc<str>) {
|
||||
RUNTIME_STALKER_LINKS.lock().put(
|
||||
key,
|
||||
RuntimeLink { url, expires_at: Instant::now() + Duration::from_secs(45) },
|
||||
);
|
||||
}
|
||||
|
||||
/// Top-level orchestrator. Mirrors the `download_xtream_playlist` signature so the
|
||||
/// dispatcher can swap the call in place.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub async fn download_stalker_playlist(
|
||||
app_config: &Arc<AppConfig>,
|
||||
client: &reqwest::Client,
|
||||
input: &ConfigInput,
|
||||
clusters: Option<&[StalkerCluster]>,
|
||||
refresh_mode: StalkerRefreshMode,
|
||||
materialize_active: bool,
|
||||
) -> (Vec<PlaylistGroup>, Vec<TuliproxError>, bool, bool) {
|
||||
let stalker_cfg = match input.stalker.as_ref() {
|
||||
Some(cfg) => cfg.clone(),
|
||||
None => {
|
||||
return (
|
||||
vec![],
|
||||
vec![TuliproxError::ConfigInput(format!(
|
||||
"Stalker input '{}' has no stalker configuration block",
|
||||
input.name
|
||||
))],
|
||||
false,
|
||||
false,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let skip_clusters = skip_clusters_for(input);
|
||||
let resolved_clusters: Vec<StalkerCluster> = clusters
|
||||
.map_or_else(
|
||||
|| DEFAULT_STALKER_CLUSTERS.to_vec(),
|
||||
<[StalkerCluster]>::to_vec,
|
||||
)
|
||||
.into_iter()
|
||||
.filter(|c| !skip_clusters.contains(c))
|
||||
.collect();
|
||||
|
||||
if resolved_clusters.is_empty() {
|
||||
info!("Stalker input '{}' has all clusters skipped", input.name);
|
||||
return (vec![], vec![], false, false);
|
||||
}
|
||||
let refresh_selection = StalkerClusterSelection::requested(input, &resolved_clusters);
|
||||
|
||||
let portal_url = match resolve_stalker_portal_url(input) {
|
||||
Ok(url) => url,
|
||||
Err(err) => return (vec![], vec![err], false, false),
|
||||
};
|
||||
|
||||
let identity_fingerprint = stalker_identity_fingerprint(&portal_url, &stalker_cfg);
|
||||
let api_client = match cached_runtime_stalker_client(client, portal_url, &stalker_cfg) {
|
||||
Ok(client) => client,
|
||||
Err(err) => {
|
||||
return (
|
||||
vec![],
|
||||
vec![TuliproxError::ConfigInput(format!(
|
||||
"failed to build Stalker client for input '{}': {err}",
|
||||
input.name
|
||||
))],
|
||||
false,
|
||||
false,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let storage_path = match ensure_stalker_storage_path(app_config, &input.name).await {
|
||||
Ok(p) => p,
|
||||
Err(err) => {
|
||||
return (
|
||||
vec![],
|
||||
vec![TuliproxError::Io(format!(
|
||||
"could not prepare Stalker storage for input '{}': {err}",
|
||||
input.name
|
||||
))],
|
||||
false,
|
||||
false,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let outcome = if let Some(_refresh_permit) = try_acquire_stalker_refresh(&input.name).await {
|
||||
let handshake = match api_client.handshake().await {
|
||||
Ok(handshake) => handshake,
|
||||
Err(err) => {
|
||||
return (
|
||||
vec![],
|
||||
vec![TuliproxError::ProviderConnection(format!(
|
||||
"Stalker handshake for input '{}' failed: {err}",
|
||||
input.name
|
||||
))],
|
||||
false,
|
||||
false,
|
||||
);
|
||||
}
|
||||
};
|
||||
loop {
|
||||
let refresh = advance_stalker_refresh(
|
||||
app_config,
|
||||
api_client.as_ref(),
|
||||
&handshake,
|
||||
refresh_selection,
|
||||
&storage_path,
|
||||
identity_fingerprint,
|
||||
refresh_mode.budget(),
|
||||
);
|
||||
let result = if refresh_mode == StalkerRefreshMode::ServerSlice {
|
||||
if let Ok(result) = tokio::time::timeout(Duration::from_mins(45), refresh).await {
|
||||
result
|
||||
} else {
|
||||
let checkpoint = match load_checkpoint(&storage_path, identity_fingerprint).await {
|
||||
Ok(checkpoint) => checkpoint,
|
||||
Err(err) => return (Vec::new(), vec![err], false, false),
|
||||
};
|
||||
break StalkerRefreshOutcome::Yielded {
|
||||
phase: checkpoint.as_ref().map_or(
|
||||
crate::repository::stalker_generation_repository::StalkerRefreshPhase::LiveBulk,
|
||||
|state| state.phase.clone(),
|
||||
),
|
||||
processed: checkpoint.as_ref().map_or(0, |state| state.processed),
|
||||
skipped: checkpoint.as_ref().map_or(0, |state| state.skipped_count),
|
||||
error: None,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
refresh.await
|
||||
};
|
||||
match result {
|
||||
Ok(StalkerRefreshOutcome::Yielded { error: None, .. })
|
||||
if refresh_mode == StalkerRefreshMode::Complete => {}
|
||||
Ok(StalkerRefreshOutcome::Yielded { .. }) if refresh_mode == StalkerRefreshMode::Parallel => {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
Ok(outcome) => break outcome,
|
||||
Err(err) => return (Vec::new(), vec![err], false, false),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return (
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
app_config.config.load().disk_based_processing,
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
let yielded = matches!(&outcome, StalkerRefreshOutcome::Yielded { .. });
|
||||
let terminal = matches!(&outcome, StalkerRefreshOutcome::Terminal(_));
|
||||
let mut errors = Vec::new();
|
||||
match outcome {
|
||||
StalkerRefreshOutcome::Complete => {}
|
||||
StalkerRefreshOutcome::Yielded { phase, processed, skipped, error } => {
|
||||
info!(
|
||||
"Stalker input '{}' yielded in phase {phase:?} after {processed} records ({skipped} skipped)",
|
||||
input.name
|
||||
);
|
||||
if let Some(error) = error {
|
||||
warn!("Stalker input '{}': resumable refresh paused after error: {error}", input.name);
|
||||
}
|
||||
}
|
||||
StalkerRefreshOutcome::Terminal(error) => errors.push(error),
|
||||
}
|
||||
|
||||
let manifest = match load_active_manifest(&storage_path, identity_fingerprint).await {
|
||||
Ok(manifest) => manifest,
|
||||
Err(err) => {
|
||||
errors.push(err);
|
||||
return (Vec::new(), errors, false, yielded);
|
||||
}
|
||||
};
|
||||
if terminal {
|
||||
if let Err(err) = crate::repository::stalker_generation_repository::cleanup_obsolete_generations(
|
||||
&storage_path,
|
||||
&manifest,
|
||||
)
|
||||
.await
|
||||
{
|
||||
errors.push(err);
|
||||
}
|
||||
}
|
||||
let use_disk_based_processing = app_config.config.load().disk_based_processing;
|
||||
if !materialize_active {
|
||||
return (Vec::new(), errors, use_disk_based_processing, yielded);
|
||||
}
|
||||
let mut groups = Vec::new();
|
||||
let mut counts = [0_usize; 3];
|
||||
for cluster in resolved_clusters {
|
||||
let items_result = match cluster {
|
||||
StalkerCluster::Live => match manifest.live.as_ref() {
|
||||
Some(files) => load_stalker_items_at(app_config, &files.data).await,
|
||||
None => Ok(Vec::new()),
|
||||
},
|
||||
StalkerCluster::Vod => match manifest.vod.as_ref() {
|
||||
Some(files) => load_stalker_items_at(app_config, &files.data).await,
|
||||
None => Ok(Vec::new()),
|
||||
},
|
||||
StalkerCluster::Series => match manifest.series.as_ref() {
|
||||
Some(files) => {
|
||||
match (
|
||||
load_stalker_items_at(app_config, &files.roots).await,
|
||||
load_stalker_items_at(app_config, &files.episodes).await,
|
||||
) {
|
||||
(Ok(mut roots), Ok(episodes)) => {
|
||||
roots.extend(episodes);
|
||||
Ok(roots)
|
||||
}
|
||||
(Err(err), _) | (_, Err(err)) => Err(err),
|
||||
}
|
||||
}
|
||||
None => Ok(Vec::new()),
|
||||
},
|
||||
};
|
||||
match items_result {
|
||||
Ok(items) => {
|
||||
counts[cluster as usize] = items.len();
|
||||
let cluster_groups = groups_for_cluster(items, cluster, &input.name);
|
||||
groups.extend(cluster_groups);
|
||||
}
|
||||
Err(err) => errors.push(err),
|
||||
}
|
||||
}
|
||||
parser::log_stalker_download_summary(&input.name, counts[0], counts[1], counts[2]);
|
||||
(groups, errors, use_disk_based_processing, yielded)
|
||||
}
|
||||
|
||||
/// Resolve a per-input portal URL using the standard `provider://` resolution pipeline.
|
||||
fn resolve_stalker_portal_url(input: &ConfigInput) -> Result<String, TuliproxError> {
|
||||
let url = input.url.as_str();
|
||||
if url.trim().is_empty() {
|
||||
return Err(TuliproxError::ConfigInput(format!(
|
||||
"Stalker input '{}' has no URL configured",
|
||||
input.name
|
||||
)));
|
||||
}
|
||||
input.resolve_url(url).map(Cow::into_owned)
|
||||
}
|
||||
|
||||
fn skip_clusters_for(input: &ConfigInput) -> Vec<StalkerCluster> {
|
||||
let mut out = Vec::new();
|
||||
if input.has_flag(ConfigInputFlags::SkipLive) {
|
||||
out.push(StalkerCluster::Live);
|
||||
}
|
||||
if input.has_flag(ConfigInputFlags::SkipVod) {
|
||||
out.push(StalkerCluster::Vod);
|
||||
}
|
||||
if input.has_flag(ConfigInputFlags::SkipSeries) {
|
||||
out.push(StalkerCluster::Series);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Convert the items of one cluster into per-category `PlaylistGroup`s,
|
||||
/// mirroring the Xtream pipeline and the disk-based source: items are grouped
|
||||
/// by their portal category id (falling back to a cluster default title for
|
||||
/// items without a category) instead of collapsing the whole cluster into a
|
||||
/// single group.
|
||||
fn groups_for_cluster(
|
||||
items: Vec<StalkerPlaylistItem>,
|
||||
cluster: StalkerCluster,
|
||||
input_name: &str,
|
||||
) -> Vec<PlaylistGroup> {
|
||||
use shared::model::XtreamCluster;
|
||||
let xtream_cluster = match cluster {
|
||||
StalkerCluster::Live => XtreamCluster::Live,
|
||||
StalkerCluster::Vod => XtreamCluster::Video,
|
||||
StalkerCluster::Series => XtreamCluster::Series,
|
||||
};
|
||||
let mut groups_map: indexmap::IndexMap<u32, PlaylistGroup> = indexmap::IndexMap::new();
|
||||
for item in items {
|
||||
let category_id = item.category_id;
|
||||
let pli = PlaylistItem::from_stalker(&item, input_name);
|
||||
let group = groups_map.entry(category_id).or_insert_with(|| PlaylistGroup {
|
||||
id: category_id,
|
||||
title: Arc::clone(&pli.header.group),
|
||||
channels: Vec::new(),
|
||||
xtream_cluster,
|
||||
});
|
||||
group.channels.push(pli);
|
||||
}
|
||||
groups_map.into_values().collect()
|
||||
}
|
||||
|
||||
fn stalker_err_to_repo(err: StalkerError) -> TuliproxError {
|
||||
TuliproxError::ProviderConnection(format!("Stalker client error: {err}"))
|
||||
}
|
||||
|
||||
/// Cache key for the runtime client map. Built from explicit, non-secret
|
||||
/// fields — `StalkerInputConfig` carries the portal account credentials, so
|
||||
/// formatting the whole config with `{:?}` would leak username and password
|
||||
/// into a long-lived in-memory map key. Credentials still differentiate the
|
||||
/// key, but only as a non-reversible short hash.
|
||||
fn runtime_client_cache_key(portal_url: &str, cfg: &StalkerInputConfig) -> String {
|
||||
format!("{portal_url}|{:016x}", cfg.identity_fingerprint(portal_url))
|
||||
}
|
||||
|
||||
pub(crate) fn stalker_identity_fingerprint(portal_url: &str, cfg: &StalkerInputConfig) -> u64 {
|
||||
cfg.identity_fingerprint(portal_url)
|
||||
}
|
||||
|
||||
fn cached_runtime_stalker_client(
|
||||
http_client: &reqwest::Client,
|
||||
portal_url: String,
|
||||
cfg: &StalkerInputConfig,
|
||||
) -> Result<Arc<StalkerApiClient>, TuliproxError> {
|
||||
let key = runtime_client_cache_key(&portal_url, cfg);
|
||||
if let Some(client) = RUNTIME_STALKER_CLIENTS.lock().get(&key).cloned() {
|
||||
return Ok(client);
|
||||
}
|
||||
|
||||
let client = Arc::new(
|
||||
StalkerApiClient::new(http_client.clone(), portal_url, cfg.clone()).map_err(stalker_err_to_repo)?,
|
||||
);
|
||||
RUNTIME_STALKER_CLIENTS.lock().put(key, Arc::clone(&client));
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
pub async fn re_resolve_stalker_url(
|
||||
app_config: &Arc<AppConfig>,
|
||||
http_client: &reqwest::Client,
|
||||
input: &ConfigInput,
|
||||
provider_id: u32,
|
||||
kind: StalkerStreamKind,
|
||||
force_refresh: bool,
|
||||
) -> Result<Option<Arc<str>>, TuliproxError> {
|
||||
if provider_id == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let stalker_cfg = input.stalker.as_ref().ok_or_else(|| {
|
||||
TuliproxError::ConfigInput(format!("Stalker input '{}' has no stalker configuration block", input.name))
|
||||
})?;
|
||||
let portal_url = resolve_stalker_portal_url(input)?;
|
||||
let identity_fingerprint = stalker_cfg.identity_fingerprint(&portal_url);
|
||||
let storage_path = ensure_stalker_storage_path(app_config, &input.name).await?;
|
||||
let manifest = load_active_manifest(&storage_path, identity_fingerprint).await?;
|
||||
let generation_and_path = match kind {
|
||||
StalkerStreamKind::Live | StalkerStreamKind::Archive => {
|
||||
manifest.live.as_ref().map(|files| (files.generation, &files.data))
|
||||
}
|
||||
StalkerStreamKind::Movie => manifest.vod.as_ref().map(|files| (files.generation, &files.data)),
|
||||
StalkerStreamKind::Episode => manifest.series.as_ref().map(|files| (files.generation, &files.episodes)),
|
||||
};
|
||||
let Some((generation, item_path)) = generation_and_path else { return Ok(None) };
|
||||
let link_key = RuntimeLinkKey { fingerprint: identity_fingerprint, generation, provider_id, kind };
|
||||
if let Some(url) = cached_resolved_link(link_key, force_refresh) {
|
||||
return Ok(Some(url));
|
||||
}
|
||||
let Some(item) = read_stalker_item_at(app_config, item_path, provider_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(descriptor) = item.playback_descriptor.as_ref() else {
|
||||
debug!("Stalker re-resolve skipped: stream_id={} has no playback_descriptor", item.stream_id);
|
||||
return Ok(None);
|
||||
};
|
||||
if descriptor.candidates.is_empty() {
|
||||
debug!("Stalker re-resolve skipped: stream_id={} descriptor has no candidate", item.stream_id);
|
||||
return Ok(None);
|
||||
}
|
||||
if descriptor
|
||||
.candidates
|
||||
.iter()
|
||||
.all(|candidate| candidate.cmd.trim().is_empty())
|
||||
{
|
||||
debug!(
|
||||
"Stalker re-resolve skipped: stream_id={} descriptor candidates are empty",
|
||||
item.stream_id
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
let api_client = cached_runtime_stalker_client(
|
||||
http_client,
|
||||
portal_url,
|
||||
stalker_cfg,
|
||||
)?;
|
||||
let handshake = api_client.handshake().await.map_err(stalker_err_to_repo)?;
|
||||
let series_number = (kind == StalkerStreamKind::Episode).then_some(item.number);
|
||||
|
||||
for candidate in &descriptor.candidates {
|
||||
if candidate.cmd.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
match api_client
|
||||
.create_link(
|
||||
&handshake,
|
||||
kind,
|
||||
candidate.playback_mode,
|
||||
&candidate.cmd,
|
||||
series_number,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resolved) => {
|
||||
let url = Internable::intern(resolved.stream_url);
|
||||
cache_resolved_link(link_key, Arc::clone(&url));
|
||||
return Ok(Some(url));
|
||||
}
|
||||
Err(err) => {
|
||||
debug!(
|
||||
"Stalker runtime re-resolve candidate failed for stream_id={}, mode={:?}: {err}",
|
||||
item.stream_id, candidate.playback_mode
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
warn!(
|
||||
"Stalker runtime re-resolve failed for stream_id={}, invalidating stale stream_url",
|
||||
item.stream_id
|
||||
);
|
||||
RUNTIME_STALKER_LINKS.lock().pop(&link_key);
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn runtime_cfg() -> StalkerInputConfig {
|
||||
StalkerInputConfig::default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_client_cache_key_changes_with_endpoint_preference() {
|
||||
let mut cfg = runtime_cfg();
|
||||
let key_a = runtime_client_cache_key("http://portal.example", &cfg);
|
||||
cfg.endpoint_preference = shared::model::stalker::StalkerEndpointPreference::Portal;
|
||||
let key_b = runtime_client_cache_key("http://portal.example", &cfg);
|
||||
assert_ne!(key_a, key_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_client_cache_key_changes_with_size_caps() {
|
||||
let mut cfg = runtime_cfg();
|
||||
let key_a = runtime_client_cache_key("http://portal.example", &cfg);
|
||||
cfg.size_caps = Some(crate::model::StalkerSizeCaps {
|
||||
create_link_kb: 128,
|
||||
ordered_list_mb: 8,
|
||||
get_epg_mb: 64,
|
||||
});
|
||||
let key_b = runtime_client_cache_key("http://portal.example", &cfg);
|
||||
assert_ne!(key_a, key_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_client_cache_key_does_not_leak_credentials() {
|
||||
let mut cfg = runtime_cfg();
|
||||
cfg.username = Some("secret_user".to_string());
|
||||
cfg.password = Some("secret_pass".to_string());
|
||||
let key = runtime_client_cache_key("http://portal.example", &cfg);
|
||||
assert!(!key.contains("secret_user"));
|
||||
assert!(!key.contains("secret_pass"));
|
||||
// Different credentials must still produce a different cache key so two
|
||||
// inputs against the same portal never share a client.
|
||||
let mut other = cfg.clone();
|
||||
other.password = Some("other_pass".to_string());
|
||||
let other_key = runtime_client_cache_key("http://portal.example", &other);
|
||||
assert_ne!(key, other_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_client_cache_keeps_session_client_alive() -> Result<(), TuliproxError> {
|
||||
let http = reqwest::Client::new();
|
||||
let cfg = runtime_cfg();
|
||||
let first = cached_runtime_stalker_client(&http, "http://portal.example".to_string(), &cfg)?;
|
||||
let weak = Arc::downgrade(&first);
|
||||
drop(first);
|
||||
|
||||
let second = cached_runtime_stalker_client(&http, "http://portal.example".to_string(), &cfg)?;
|
||||
|
||||
assert!(weak.upgrade().is_some());
|
||||
let upgraded = weak
|
||||
.upgrade()
|
||||
.ok_or_else(|| TuliproxError::ProviderConnection("client dropped".to_string()))?;
|
||||
assert!(Arc::ptr_eq(&upgraded, &second));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_link_cache_can_be_forced_stale() {
|
||||
let key = RuntimeLinkKey { fingerprint: 8, generation: 1, provider_id: 43, kind: StalkerStreamKind::Live };
|
||||
cache_resolved_link(key, "http://stream.example/live".into());
|
||||
assert_eq!(cached_resolved_link(key, false).as_deref(), Some("http://stream.example/live"));
|
||||
assert!(cached_resolved_link(key, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_link_cache_is_scoped_to_the_published_generation() {
|
||||
let old = RuntimeLinkKey { fingerprint: 7, generation: 1, provider_id: 42, kind: StalkerStreamKind::Live };
|
||||
let current = RuntimeLinkKey { generation: 2, ..old };
|
||||
cache_resolved_link(old, "http://stream.example/old".into());
|
||||
assert!(cached_resolved_link(current, false).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_ownership_is_non_blocking_across_invocations() {
|
||||
let name: Arc<str> = "portal-a".into();
|
||||
let first = try_acquire_stalker_refresh(&name).await;
|
||||
assert!(first.is_some());
|
||||
assert!(try_acquire_stalker_refresh(&name).await.is_none());
|
||||
drop(first);
|
||||
assert!(try_acquire_stalker_refresh(&name).await.is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,687 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use shared::error::TuliproxError;
|
||||
use shared::model::stalker::StalkerStreamKind;
|
||||
use shared::model::stalker_item::StalkerPlaylistItem;
|
||||
|
||||
use crate::model::{AppConfig, ConfigInput, ConfigInputFlags};
|
||||
use crate::processing::parser::stalker as parser;
|
||||
use crate::repository::stalker_generation_repository::{
|
||||
cleanup_obsolete_generations, clear_checkpoint, generation_data_path, load_checkpoint,
|
||||
publish_selection, save_checkpoint, StalkerCheckpoint, StalkerGenerationData, StalkerRefreshPhase,
|
||||
};
|
||||
use crate::repository::stalker_repository::{
|
||||
load_stalker_items_after, prepare_stalker_episode_series_at, promote_stalker_file,
|
||||
remove_stalker_file, snapshot_stalker_epg_at, snapshot_stalker_items_at,
|
||||
upsert_stalker_epg_at, upsert_stalker_items_at,
|
||||
};
|
||||
use crate::utils::network::stalker::catalog::{StalkerCategory, StalkerRawItem};
|
||||
use crate::utils::network::stalker::client::StalkerApiClient;
|
||||
use crate::utils::network::stalker::error::StalkerError;
|
||||
use crate::utils::network::stalker::profile::StalkerHandshake;
|
||||
use super::stalker::StalkerCluster;
|
||||
|
||||
const MAX_RETRIES: u8 = 3;
|
||||
const SKIPPED_SAMPLE_LIMIT: usize = 32;
|
||||
|
||||
pub enum StalkerRefreshLimit {
|
||||
Deadline(Instant),
|
||||
Units { remaining: usize },
|
||||
Unlimited,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum StalkerRefreshMode {
|
||||
ServerSlice,
|
||||
Parallel,
|
||||
Complete,
|
||||
}
|
||||
|
||||
impl StalkerRefreshMode {
|
||||
pub fn budget(self) -> StalkerRefreshBudget {
|
||||
match self {
|
||||
Self::ServerSlice => {
|
||||
StalkerRefreshBudget::deadline(Instant::now() + std::time::Duration::from_mins(45))
|
||||
}
|
||||
Self::Parallel => StalkerRefreshBudget::units(8),
|
||||
Self::Complete => StalkerRefreshBudget::unlimited(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StalkerRefreshBudget {
|
||||
limit: StalkerRefreshLimit,
|
||||
}
|
||||
|
||||
impl StalkerRefreshBudget {
|
||||
pub fn deadline(deadline: Instant) -> Self { Self { limit: StalkerRefreshLimit::Deadline(deadline) } }
|
||||
|
||||
pub fn unlimited() -> Self { Self { limit: StalkerRefreshLimit::Unlimited } }
|
||||
|
||||
fn units(remaining: usize) -> Self { Self { limit: StalkerRefreshLimit::Units { remaining } } }
|
||||
|
||||
fn completed_unit(&mut self) {
|
||||
if let StalkerRefreshLimit::Units { remaining } = &mut self.limit {
|
||||
*remaining = remaining.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn should_yield(&self) -> bool {
|
||||
match self.limit {
|
||||
StalkerRefreshLimit::Deadline(deadline) => Instant::now() >= deadline,
|
||||
StalkerRefreshLimit::Units { remaining } => remaining == 0,
|
||||
StalkerRefreshLimit::Unlimited => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
pub struct StalkerClusterSelection {
|
||||
live: bool,
|
||||
vod: bool,
|
||||
series: bool,
|
||||
epg: bool,
|
||||
}
|
||||
|
||||
impl From<&ConfigInput> for StalkerClusterSelection {
|
||||
fn from(input: &ConfigInput) -> Self {
|
||||
let live = !input.has_flag(ConfigInputFlags::SkipLive);
|
||||
Self {
|
||||
live,
|
||||
vod: !input.has_flag(ConfigInputFlags::SkipVod),
|
||||
series: !input.has_flag(ConfigInputFlags::SkipSeries),
|
||||
epg: live && input.has_flag(ConfigInputFlags::StalkerBulkEpg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StalkerClusterSelection {
|
||||
pub fn requested(input: &ConfigInput, requested: &[StalkerCluster]) -> Self {
|
||||
let configured = Self::from(input);
|
||||
let live = configured.live && requested.contains(&StalkerCluster::Live);
|
||||
Self {
|
||||
live,
|
||||
vod: configured.vod && requested.contains(&StalkerCluster::Vod),
|
||||
series: configured.series && requested.contains(&StalkerCluster::Series),
|
||||
epg: configured.epg && live,
|
||||
}
|
||||
}
|
||||
|
||||
fn mask(self) -> u8 {
|
||||
u8::from(self.live)
|
||||
| (u8::from(self.vod) << 1)
|
||||
| (u8::from(self.series) << 2)
|
||||
| (u8::from(self.epg) << 3)
|
||||
}
|
||||
}
|
||||
|
||||
fn first_phase(selection: StalkerClusterSelection) -> StalkerRefreshPhase {
|
||||
if selection.live {
|
||||
StalkerRefreshPhase::LiveBulk
|
||||
} else {
|
||||
next_phase_after_live(selection)
|
||||
}
|
||||
}
|
||||
|
||||
fn next_phase_after_live(selection: StalkerClusterSelection) -> StalkerRefreshPhase {
|
||||
if selection.vod {
|
||||
StalkerRefreshPhase::Vod { page: 1 }
|
||||
} else {
|
||||
next_phase_after_vod(selection)
|
||||
}
|
||||
}
|
||||
|
||||
fn next_phase_after_vod(selection: StalkerClusterSelection) -> StalkerRefreshPhase {
|
||||
if selection.series {
|
||||
StalkerRefreshPhase::SeriesRoots { page: 1 }
|
||||
} else if selection.epg {
|
||||
StalkerRefreshPhase::Epg
|
||||
} else {
|
||||
StalkerRefreshPhase::Complete
|
||||
}
|
||||
}
|
||||
|
||||
fn next_phase_after_series(selection: StalkerClusterSelection) -> StalkerRefreshPhase {
|
||||
if selection.epg { StalkerRefreshPhase::Epg } else { StalkerRefreshPhase::Complete }
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum StalkerRefreshOutcome {
|
||||
Complete,
|
||||
Yielded {
|
||||
phase: StalkerRefreshPhase,
|
||||
processed: u64,
|
||||
skipped: u64,
|
||||
error: Option<TuliproxError>,
|
||||
},
|
||||
Terminal(TuliproxError),
|
||||
}
|
||||
|
||||
fn generation_id() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |duration| u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX))
|
||||
}
|
||||
|
||||
async fn load_or_start_checkpoint(
|
||||
storage_path: &Path,
|
||||
identity_fingerprint: u64,
|
||||
selection: StalkerClusterSelection,
|
||||
) -> Result<StalkerCheckpoint, TuliproxError> {
|
||||
if let Some(state) = load_checkpoint(storage_path, identity_fingerprint).await? {
|
||||
if state.selection_mask == selection.mask() {
|
||||
return Ok(state);
|
||||
}
|
||||
}
|
||||
|
||||
let mut state = StalkerCheckpoint::new(
|
||||
identity_fingerprint,
|
||||
generation_id(),
|
||||
selection.mask(),
|
||||
chrono::Utc::now().timestamp(),
|
||||
);
|
||||
state.phase = first_phase(selection);
|
||||
save_checkpoint(storage_path, &state).await?;
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
async fn finish_completed_refresh(
|
||||
storage_path: &Path,
|
||||
identity_fingerprint: u64,
|
||||
checkpoint: &StalkerCheckpoint,
|
||||
) -> Result<(), TuliproxError> {
|
||||
let manifest = publish_selection(
|
||||
storage_path,
|
||||
identity_fingerprint,
|
||||
checkpoint.generation,
|
||||
checkpoint.selection_mask,
|
||||
)
|
||||
.await?;
|
||||
clear_checkpoint(storage_path).await?;
|
||||
cleanup_obsolete_generations(storage_path, &manifest).await
|
||||
}
|
||||
|
||||
fn category_map(categories: Vec<StalkerCategory>) -> HashMap<u32, StalkerCategory> {
|
||||
categories
|
||||
.into_iter()
|
||||
.filter_map(|category| category.id.parse().ok().map(|id| (id, category)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn category_map_result(
|
||||
result: Result<Vec<StalkerCategory>, StalkerError>,
|
||||
) -> Result<HashMap<u32, StalkerCategory>, TuliproxError> {
|
||||
result.map(category_map).map_err(|err| provider_error(&err))
|
||||
}
|
||||
|
||||
fn map_items(
|
||||
raw_items: &[StalkerRawItem],
|
||||
categories: &HashMap<u32, StalkerCategory>,
|
||||
kind: StalkerStreamKind,
|
||||
added_at: i64,
|
||||
) -> Vec<StalkerPlaylistItem> {
|
||||
raw_items
|
||||
.iter()
|
||||
.map(|raw| {
|
||||
let category = raw
|
||||
.category_id()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.and_then(|id| categories.get(&id));
|
||||
parser::map_stalker_to_playlist_item(raw, category, kind, added_at)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn catalog_page_signature<'a>(ids: impl Iterator<Item = Option<&'a str>>) -> u64 {
|
||||
ids.fold(14_695_981_039_346_656_037_u64, |hash, id| {
|
||||
id.unwrap_or_default().bytes().chain([0]).fold(hash, |hash, byte| {
|
||||
(hash ^ u64::from(byte)).wrapping_mul(1_099_511_628_211)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_page_advanced(
|
||||
next_page: Option<u32>,
|
||||
previous_signature: Option<u64>,
|
||||
signature: u64,
|
||||
page: u32,
|
||||
portal_type: &'static str,
|
||||
) -> Result<(), TuliproxError> {
|
||||
if next_page.is_some() && previous_signature == Some(signature) {
|
||||
return Err(provider_error(&StalkerError::CatalogIncomplete {
|
||||
portal_type,
|
||||
reason: format!("page {page} repeated"),
|
||||
}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn provider_error(err: &StalkerError) -> TuliproxError {
|
||||
TuliproxError::ProviderConnection(format!("Stalker client error: {err}"))
|
||||
}
|
||||
|
||||
async fn finish_terminal_refresh(storage_path: &Path) -> Result<StalkerRefreshOutcome, TuliproxError> {
|
||||
clear_checkpoint(storage_path).await?;
|
||||
Ok(StalkerRefreshOutcome::Terminal(TuliproxError::ProviderConnection(
|
||||
"Stalker refresh reached a terminal state".to_string(),
|
||||
)))
|
||||
}
|
||||
|
||||
async fn yield_after_error(
|
||||
storage_path: &Path,
|
||||
mut checkpoint: StalkerCheckpoint,
|
||||
error: TuliproxError,
|
||||
) -> Result<StalkerRefreshOutcome, TuliproxError> {
|
||||
checkpoint.retry_count = checkpoint.retry_count.saturating_add(1);
|
||||
if checkpoint.retry_count >= MAX_RETRIES {
|
||||
checkpoint.phase = StalkerRefreshPhase::Terminal;
|
||||
save_checkpoint(storage_path, &checkpoint).await?;
|
||||
return Ok(StalkerRefreshOutcome::Terminal(error));
|
||||
}
|
||||
save_checkpoint(storage_path, &checkpoint).await?;
|
||||
Ok(StalkerRefreshOutcome::Yielded {
|
||||
phase: checkpoint.phase,
|
||||
processed: checkpoint.processed,
|
||||
skipped: checkpoint.skipped_count,
|
||||
error: Some(error),
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub async fn advance_stalker_refresh(
|
||||
app_config: &Arc<AppConfig>,
|
||||
api_client: &StalkerApiClient,
|
||||
handshake: &StalkerHandshake,
|
||||
selection: StalkerClusterSelection,
|
||||
storage_path: &Path,
|
||||
identity_fingerprint: u64,
|
||||
mut budget: StalkerRefreshBudget,
|
||||
) -> Result<StalkerRefreshOutcome, TuliproxError> {
|
||||
let mut checkpoint = load_or_start_checkpoint(storage_path, identity_fingerprint, selection).await?;
|
||||
if checkpoint.phase == StalkerRefreshPhase::Terminal {
|
||||
return finish_terminal_refresh(storage_path).await;
|
||||
}
|
||||
let added_at = checkpoint.started_at;
|
||||
let mut live_categories = None;
|
||||
let mut vod_categories = None;
|
||||
let mut series_categories = None;
|
||||
let mut used_episode_ids = None;
|
||||
|
||||
loop {
|
||||
if budget.should_yield() {
|
||||
return Ok(StalkerRefreshOutcome::Yielded {
|
||||
phase: checkpoint.phase,
|
||||
processed: checkpoint.processed,
|
||||
skipped: checkpoint.skipped_count,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
match checkpoint.phase.clone() {
|
||||
StalkerRefreshPhase::LiveBulk => {
|
||||
let categories = if let Some(categories) = &live_categories {
|
||||
categories
|
||||
} else {
|
||||
match category_map_result(api_client.get_live_categories(handshake).await) {
|
||||
Ok(categories) => live_categories.insert(categories),
|
||||
Err(err) => return yield_after_error(storage_path, checkpoint, err).await,
|
||||
}
|
||||
};
|
||||
match api_client.get_all_channels(handshake).await {
|
||||
Ok(raw) if raw.is_empty() => {
|
||||
checkpoint.phase = StalkerRefreshPhase::Live { page: 1 };
|
||||
checkpoint.retry_count = 0;
|
||||
}
|
||||
Ok(raw) => {
|
||||
let items = map_items(&raw, categories, StalkerStreamKind::Live, added_at);
|
||||
let path = generation_data_path(storage_path, checkpoint.generation, StalkerGenerationData::Live);
|
||||
snapshot_stalker_items_at(app_config, path.clone(), &items).await?;
|
||||
checkpoint.processed = checkpoint.processed.saturating_add(items.len() as u64);
|
||||
checkpoint.phase = next_phase_after_live(selection);
|
||||
checkpoint.retry_count = 0;
|
||||
}
|
||||
Err(err) if err.is_unsupported_catalog_action() => {
|
||||
checkpoint.phase = StalkerRefreshPhase::Live { page: 1 };
|
||||
checkpoint.retry_count = 0;
|
||||
}
|
||||
Err(err) => return yield_after_error(storage_path, checkpoint, provider_error(&err)).await,
|
||||
}
|
||||
}
|
||||
StalkerRefreshPhase::Live { page } => {
|
||||
let categories = if let Some(categories) = &live_categories {
|
||||
categories
|
||||
} else {
|
||||
match category_map_result(api_client.get_live_categories(handshake).await) {
|
||||
Ok(categories) => live_categories.insert(categories),
|
||||
Err(err) => return yield_after_error(storage_path, checkpoint, err).await,
|
||||
}
|
||||
};
|
||||
let response = match api_client.get_live_streams_page(handshake, page).await {
|
||||
Ok(response) => response,
|
||||
Err(err) => return yield_after_error(storage_path, checkpoint, provider_error(&err)).await,
|
||||
};
|
||||
let signature = catalog_page_signature(response.items.iter().map(|item| item.id.as_deref()));
|
||||
if let Err(err) = ensure_page_advanced(
|
||||
response.next_page,
|
||||
checkpoint.page_signature,
|
||||
signature,
|
||||
page,
|
||||
"itv",
|
||||
) {
|
||||
return yield_after_error(storage_path, checkpoint, err).await;
|
||||
}
|
||||
let items = map_items(&response.items, categories, StalkerStreamKind::Live, added_at);
|
||||
let path = generation_data_path(storage_path, checkpoint.generation, StalkerGenerationData::Live);
|
||||
upsert_stalker_items_at(app_config, &path, &items).await?;
|
||||
checkpoint.processed = checkpoint.processed.saturating_add(items.len() as u64);
|
||||
checkpoint.phase = if let Some(next_page) = response.next_page {
|
||||
checkpoint.page_signature = Some(signature);
|
||||
StalkerRefreshPhase::Live { page: next_page }
|
||||
} else {
|
||||
checkpoint.page_signature = None;
|
||||
next_phase_after_live(selection)
|
||||
};
|
||||
checkpoint.retry_count = 0;
|
||||
}
|
||||
StalkerRefreshPhase::Vod { page } => {
|
||||
let categories = if let Some(categories) = &vod_categories {
|
||||
categories
|
||||
} else {
|
||||
match category_map_result(api_client.get_vod_categories(handshake).await) {
|
||||
Ok(categories) => vod_categories.insert(categories),
|
||||
Err(err) => return yield_after_error(storage_path, checkpoint, err).await,
|
||||
}
|
||||
};
|
||||
let response = match api_client.get_vod_streams_page(handshake, page).await {
|
||||
Ok(response) => response,
|
||||
Err(err) => return yield_after_error(storage_path, checkpoint, provider_error(&err)).await,
|
||||
};
|
||||
let signature = catalog_page_signature(response.items.iter().map(|item| item.id.as_deref()));
|
||||
if let Err(err) = ensure_page_advanced(
|
||||
response.next_page,
|
||||
checkpoint.page_signature,
|
||||
signature,
|
||||
page,
|
||||
"vod",
|
||||
) {
|
||||
return yield_after_error(storage_path, checkpoint, err).await;
|
||||
}
|
||||
let items = map_items(&response.items, categories, StalkerStreamKind::Movie, added_at);
|
||||
let path = generation_data_path(storage_path, checkpoint.generation, StalkerGenerationData::Vod);
|
||||
upsert_stalker_items_at(app_config, &path, &items).await?;
|
||||
checkpoint.processed = checkpoint.processed.saturating_add(items.len() as u64);
|
||||
checkpoint.phase = if let Some(next_page) = response.next_page {
|
||||
checkpoint.page_signature = Some(signature);
|
||||
StalkerRefreshPhase::Vod { page: next_page }
|
||||
} else {
|
||||
checkpoint.page_signature = None;
|
||||
next_phase_after_vod(selection)
|
||||
};
|
||||
checkpoint.retry_count = 0;
|
||||
}
|
||||
StalkerRefreshPhase::SeriesRoots { page } => {
|
||||
let categories = if let Some(categories) = &series_categories {
|
||||
categories
|
||||
} else {
|
||||
match category_map_result(api_client.get_series_categories(handshake).await) {
|
||||
Ok(categories) => series_categories.insert(categories),
|
||||
Err(err) => return yield_after_error(storage_path, checkpoint, err).await,
|
||||
}
|
||||
};
|
||||
let response = match api_client.get_series_list_page(handshake, page).await {
|
||||
Ok(response) => response,
|
||||
Err(err) => return yield_after_error(storage_path, checkpoint, provider_error(&err)).await,
|
||||
};
|
||||
let signature = catalog_page_signature(response.items.iter().map(|item| item.id.as_deref()));
|
||||
if let Err(err) = ensure_page_advanced(
|
||||
response.next_page,
|
||||
checkpoint.page_signature,
|
||||
signature,
|
||||
page,
|
||||
"series",
|
||||
) {
|
||||
return yield_after_error(storage_path, checkpoint, err).await;
|
||||
}
|
||||
let roots: Vec<_> = response
|
||||
.items
|
||||
.iter()
|
||||
.map(|raw| {
|
||||
let category = raw
|
||||
.category_id
|
||||
.as_deref()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.and_then(|id| categories.get(&id));
|
||||
let mut root = parser::map_stalker_series_root(raw, category, added_at);
|
||||
let capabilities = &handshake.profile.portal_capabilities;
|
||||
root.nginx_secure_link = capabilities.nginx_secure_link;
|
||||
root.flussonic_tmp_link = capabilities.flussonic_temporary_link;
|
||||
root.wowza_tmp_link = capabilities.wowza_temporary_link;
|
||||
root.use_http_tmp_link = capabilities.use_http_temporary_link;
|
||||
root
|
||||
})
|
||||
.collect();
|
||||
let roots_path = generation_data_path(storage_path, checkpoint.generation, StalkerGenerationData::SeriesRoots);
|
||||
upsert_stalker_items_at(app_config, &roots_path, &roots).await?;
|
||||
checkpoint.processed = checkpoint.processed.saturating_add(roots.len() as u64);
|
||||
checkpoint.phase = match response.next_page {
|
||||
None => {
|
||||
checkpoint.page_signature = None;
|
||||
StalkerRefreshPhase::SeriesDetails { provider_id: None }
|
||||
}
|
||||
Some(next_page) => {
|
||||
checkpoint.page_signature = Some(signature);
|
||||
StalkerRefreshPhase::SeriesRoots { page: next_page }
|
||||
}
|
||||
};
|
||||
checkpoint.retry_count = 0;
|
||||
}
|
||||
StalkerRefreshPhase::SeriesDetails { provider_id } => {
|
||||
let roots_path = generation_data_path(storage_path, checkpoint.generation, StalkerGenerationData::SeriesRoots);
|
||||
let mut roots = load_stalker_items_after(app_config, &roots_path, provider_id, 1).await?;
|
||||
let Some(root) = roots.pop() else {
|
||||
checkpoint.phase = next_phase_after_series(selection);
|
||||
checkpoint.retry_count = 0;
|
||||
save_checkpoint(storage_path, &checkpoint).await?;
|
||||
budget.completed_unit();
|
||||
continue;
|
||||
};
|
||||
let series_id = root.series_id.unwrap_or(root.stream_id);
|
||||
match api_client.get_series_details(handshake, series_id).await {
|
||||
Ok(details) => {
|
||||
let path = generation_data_path(storage_path, checkpoint.generation, StalkerGenerationData::SeriesEpisodes);
|
||||
let used = if let Some(used) = &mut used_episode_ids {
|
||||
used
|
||||
} else {
|
||||
used_episode_ids.insert(
|
||||
prepare_stalker_episode_series_at(app_config, &path, series_id)
|
||||
.await?,
|
||||
)
|
||||
};
|
||||
let episodes = parser::map_stalker_series_details(&details, &root, added_at, used);
|
||||
upsert_stalker_items_at(app_config, &path, &episodes).await?;
|
||||
checkpoint.processed = checkpoint.processed.saturating_add(episodes.len() as u64);
|
||||
checkpoint.phase = StalkerRefreshPhase::SeriesDetails { provider_id: Some(root.stream_id) };
|
||||
checkpoint.retry_count = 0;
|
||||
}
|
||||
Err(err) => {
|
||||
checkpoint.retry_count = checkpoint.retry_count.saturating_add(1);
|
||||
if checkpoint.retry_count < MAX_RETRIES {
|
||||
save_checkpoint(storage_path, &checkpoint).await?;
|
||||
return Ok(StalkerRefreshOutcome::Yielded {
|
||||
phase: checkpoint.phase,
|
||||
processed: checkpoint.processed,
|
||||
skipped: checkpoint.skipped_count,
|
||||
error: Some(provider_error(&err)),
|
||||
});
|
||||
}
|
||||
checkpoint.skipped_count = checkpoint.skipped_count.saturating_add(1);
|
||||
if checkpoint.skipped_sample.len() < SKIPPED_SAMPLE_LIMIT {
|
||||
checkpoint.skipped_sample.push(series_id);
|
||||
}
|
||||
checkpoint.phase = StalkerRefreshPhase::SeriesDetails { provider_id: Some(root.stream_id) };
|
||||
checkpoint.retry_count = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
StalkerRefreshPhase::Epg => {
|
||||
let path = generation_data_path(storage_path, checkpoint.generation, StalkerGenerationData::Epg);
|
||||
let attempt_path = path.with_extension("attempt.db");
|
||||
snapshot_stalker_epg_at(app_config, &attempt_path, &[]).await?;
|
||||
let app_config = Arc::clone(app_config);
|
||||
let sink_app_config = Arc::clone(&app_config);
|
||||
let batch_path = attempt_path.clone();
|
||||
let epg_result = api_client
|
||||
.stream_bulk_epg(handshake, 24, 512, move |batch| {
|
||||
let app_config = Arc::clone(&sink_app_config);
|
||||
let batch_path = batch_path.clone();
|
||||
async move {
|
||||
upsert_stalker_epg_at(&app_config, &batch_path, &batch)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|err| StalkerError::Io(std::io::Error::other(err.to_string())))
|
||||
}
|
||||
})
|
||||
.await;
|
||||
if let Err(err) = epg_result {
|
||||
remove_stalker_file(&app_config, &attempt_path).await?;
|
||||
return yield_after_error(storage_path, checkpoint, provider_error(&err)).await;
|
||||
}
|
||||
promote_stalker_file(&app_config, &attempt_path, &path).await?;
|
||||
checkpoint.phase = StalkerRefreshPhase::Complete;
|
||||
checkpoint.retry_count = 0;
|
||||
}
|
||||
StalkerRefreshPhase::Complete => {
|
||||
finish_completed_refresh(storage_path, identity_fingerprint, &checkpoint).await?;
|
||||
return Ok(StalkerRefreshOutcome::Complete);
|
||||
}
|
||||
StalkerRefreshPhase::Terminal => {
|
||||
return finish_terminal_refresh(storage_path).await;
|
||||
}
|
||||
}
|
||||
save_checkpoint(storage_path, &checkpoint).await?;
|
||||
budget.completed_unit();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_checkpoint_is_cleared_after_restart() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let selection = StalkerClusterSelection { live: true, vod: false, series: false, epg: false };
|
||||
let mut checkpoint = StalkerCheckpoint::new(17, 23, selection.mask(), 123);
|
||||
checkpoint.phase = StalkerRefreshPhase::Terminal;
|
||||
save_checkpoint(temp.path(), &checkpoint).await?;
|
||||
|
||||
let outcome = finish_terminal_refresh(temp.path()).await?;
|
||||
|
||||
assert!(matches!(outcome, StalkerRefreshOutcome::Terminal(_)));
|
||||
assert!(load_checkpoint(temp.path(), 17).await?.is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn complete_checkpoint_is_resumed_without_new_generation() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let selection = StalkerClusterSelection { live: true, vod: true, series: false, epg: false };
|
||||
let mut checkpoint = StalkerCheckpoint::new(17, 23, selection.mask(), 123);
|
||||
checkpoint.phase = StalkerRefreshPhase::Complete;
|
||||
save_checkpoint(temp.path(), &checkpoint).await?;
|
||||
|
||||
let resumed = load_or_start_checkpoint(temp.path(), 17, selection).await?;
|
||||
|
||||
assert_eq!(resumed.generation, 23);
|
||||
assert_eq!(resumed.phase, StalkerRefreshPhase::Complete);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn complete_publication_is_idempotent_after_restart() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let mut checkpoint = StalkerCheckpoint::new(17, 23, 0b0011, 123);
|
||||
checkpoint.phase = StalkerRefreshPhase::Complete;
|
||||
save_checkpoint(temp.path(), &checkpoint).await?;
|
||||
|
||||
finish_completed_refresh(temp.path(), 17, &checkpoint).await?;
|
||||
save_checkpoint(temp.path(), &checkpoint).await?;
|
||||
finish_completed_refresh(temp.path(), 17, &checkpoint).await?;
|
||||
|
||||
let manifest = crate::repository::stalker_generation_repository::load_active_manifest(temp.path(), 17).await?;
|
||||
assert_eq!(manifest.live.as_ref().map(|files| files.generation), Some(23));
|
||||
assert_eq!(manifest.vod.as_ref().map(|files| files.generation), Some(23));
|
||||
assert!(load_checkpoint(temp.path(), 17).await?.is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_budget_yields_after_completed_unit() {
|
||||
let mut budget = StalkerRefreshBudget::units(1);
|
||||
assert!(!budget.should_yield());
|
||||
budget.completed_unit();
|
||||
assert!(budget.should_yield());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_mode_uses_a_bounded_work_unit_budget() {
|
||||
let mut budget = StalkerRefreshMode::Parallel.budget();
|
||||
assert!(!budget.should_yield());
|
||||
for _ in 0..64 {
|
||||
budget.completed_unit();
|
||||
}
|
||||
assert!(budget.should_yield());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_progression_skips_disabled_clusters() {
|
||||
let selection = StalkerClusterSelection { live: false, vod: true, series: false, epg: false };
|
||||
assert_eq!(next_phase_after_live(selection), StalkerRefreshPhase::Vod { page: 1 });
|
||||
assert_eq!(next_phase_after_vod(selection), StalkerRefreshPhase::Complete);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requested_clusters_limit_the_refresh_selection() {
|
||||
let input = ConfigInput::default();
|
||||
let selection = StalkerClusterSelection::requested(&input, &[StalkerCluster::Series]);
|
||||
assert!(!selection.live);
|
||||
assert!(!selection.vod);
|
||||
assert!(selection.series);
|
||||
assert!(!selection.epg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_errors_are_not_silently_downgraded() {
|
||||
let result = category_map_result(Err(StalkerError::BodyDecode { message: "broken".to_string() }));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_items_uses_tv_genre_id_for_live_category() {
|
||||
let raw: StalkerRawItem = serde_json::from_value(serde_json::json!({
|
||||
"id": "590",
|
||||
"name": "News",
|
||||
"tv_genre_id": "10"
|
||||
}))
|
||||
.expect("raw item");
|
||||
let categories = HashMap::from([(
|
||||
10,
|
||||
StalkerCategory { id: "10".to_string(), title: "News".to_string(), alias: None, number: 1 },
|
||||
)]);
|
||||
|
||||
let items = map_items(&[raw], &categories, StalkerStreamKind::Live, 0);
|
||||
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].category_id, 10);
|
||||
assert_eq!(items[0].category_name.as_ref(), "News");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_page_is_rejected_while_more_pages_are_advertised() {
|
||||
assert!(ensure_page_advanced(Some(3), Some(17), 17, 2, "vod").is_err());
|
||||
assert!(ensure_page_advanced(None, Some(17), 17, 2, "vod").is_ok());
|
||||
}
|
||||
}
|
||||
@@ -190,7 +190,8 @@ async fn prepare_generic_stream_metadata(
|
||||
ProbeStorageKind::Xtream,
|
||||
)
|
||||
}
|
||||
InputType::Emby | InputType::Jellyfin | InputType::Plex | InputType::Staged => return Ok(PreparedGenericProbeOutcome::Noop),
|
||||
InputType::Emby | InputType::Jellyfin | InputType::Plex
|
||||
| InputType::Stalker | InputType::StalkerBatch | InputType::Staged => return Ok(PreparedGenericProbeOutcome::Noop),
|
||||
};
|
||||
|
||||
if !db_path.exists() {
|
||||
|
||||
@@ -70,7 +70,7 @@ pub async fn playlist_resolve_series(
|
||||
processed_fpl: &mut FetchedPlaylist<'_>,
|
||||
) {
|
||||
// Skip-flag is the kill-switch: no resolve, no probe, no iteration.
|
||||
if processed_fpl.input.has_flag(ConfigInputFlags::XtreamSkipSeries) {
|
||||
if processed_fpl.input.has_flag(ConfigInputFlags::SkipSeries) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -87,7 +87,9 @@ pub async fn playlist_resolve_series(
|
||||
sync_resolved_series_properties(provider_fpl, processed_fpl);
|
||||
}
|
||||
|
||||
provider_fpl.source.obtain_resources().await;
|
||||
if let Err(err) = provider_fpl.source.obtain_resources().await {
|
||||
errors.push(err);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
|
||||
@@ -690,7 +692,7 @@ pub async fn update_series_metadata(
|
||||
.await
|
||||
.map_err(|e| TuliproxError::Io(format!("Storage path error: {e}")))?;
|
||||
|
||||
if input.has_flag(ConfigInputFlags::XtreamSkipSeries) {
|
||||
if input.has_flag(ConfigInputFlags::SkipSeries) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ pub async fn playlist_resolve_vod(
|
||||
fpl: &mut FetchedPlaylist<'_>,
|
||||
) {
|
||||
// Skip-flag is the kill-switch: no resolve, no probe, no iteration.
|
||||
if fpl.input.has_flag(ConfigInputFlags::XtreamSkipVod) {
|
||||
if fpl.input.has_flag(ConfigInputFlags::SkipVod) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -74,7 +74,9 @@ pub async fn playlist_resolve_vod(
|
||||
sync_resolved_vod_properties(provider_fpl, fpl);
|
||||
}
|
||||
|
||||
provider_fpl.source.obtain_resources().await;
|
||||
if let Err(err) = provider_fpl.source.obtain_resources().await {
|
||||
errors.push(err);
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_resolved_vod_properties(provider_fpl: &mut FetchedPlaylist<'_>, processed_fpl: &mut FetchedPlaylist<'_>) {
|
||||
@@ -582,7 +584,7 @@ pub async fn update_vod_metadata(
|
||||
let storage_path = resolve_input_storage_path(storage_dir, &input.name).await;
|
||||
|
||||
// Check if we should skip based on input options
|
||||
if input.has_flag(ConfigInputFlags::XtreamSkipVod) {
|
||||
if input.has_flag(ConfigInputFlags::SkipVod) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user