mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-05 15:32:31 +02:00
* feat(update): add per-input actions and pipeline transparency Squash feature/cluster-update-quality onto upstream/develop. Include request-local update policies, persisted input and cluster status, library rescan and target rebuild integration, and the update-card view. Align the M3U request regression with the existing Force capability. * del(build): temporary dev changes * fix(update): address playlist review findings * fix(update): rename Shows to Series * Playlist update refactor * Playlist update refactor --------- Co-authored-by: euzu <euzu@proton.me>
234 lines
8.8 KiB
Rust
234 lines
8.8 KiB
Rust
use log::{error, warn};
|
|
use serde::{Deserialize, Serialize};
|
|
use shared::model::{PersistedPlaylistUpdateClusterSnapshot, PersistedPlaylistUpdateInputResult};
|
|
use std::{
|
|
collections::HashMap,
|
|
fs,
|
|
path::{Path, PathBuf},
|
|
time::{SystemTime, UNIX_EPOCH},
|
|
};
|
|
use tuliprox_repository::{build_input_storage_path, get_input_storage_path};
|
|
|
|
pub const STATUS_FILE: &str = "status.json";
|
|
|
|
#[derive(Serialize, Deserialize, Default, Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ClusterState {
|
|
#[default]
|
|
Ok,
|
|
Failed,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
|
pub struct ClusterStatus {
|
|
pub status: ClusterState,
|
|
pub timestamp: u64,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub last_update: Option<PersistedPlaylistUpdateClusterSnapshot>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)]
|
|
pub struct InputStatus {
|
|
#[serde(default)]
|
|
pub clusters: HashMap<String, ClusterStatus>,
|
|
/// Last input completion for every input type; never used to establish cache validity.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub last_input_update: Option<PersistedPlaylistUpdateInputResult>,
|
|
}
|
|
|
|
pub async fn resolve_input_storage_path(storage_dir: &str, input_name: &str) -> PathBuf {
|
|
if let Ok(path) = get_input_storage_path(input_name, storage_dir).await {
|
|
path
|
|
} else {
|
|
build_input_storage_path(input_name, storage_dir)
|
|
}
|
|
}
|
|
|
|
pub fn load_input_status(path: &Path) -> InputStatus {
|
|
let status_path = path.join(STATUS_FILE);
|
|
if status_path.exists() {
|
|
match fs::read_to_string(&status_path) {
|
|
Ok(content) => match serde_json::from_str(&content) {
|
|
Ok(status) => return status,
|
|
Err(e) => warn!("Failed to parse input status file {}: {e}", status_path.display()),
|
|
},
|
|
Err(e) => warn!("Failed to read input status file {}: {e}", status_path.display()),
|
|
}
|
|
}
|
|
InputStatus::default()
|
|
}
|
|
|
|
pub fn save_input_status(path: &Path, status: &InputStatus) {
|
|
let status_path = path.join(STATUS_FILE);
|
|
if let Some(parent) = status_path.parent() {
|
|
if let Err(e) = fs::create_dir_all(parent) {
|
|
error!("Failed to create input storage directory {}: {e}", parent.display());
|
|
return;
|
|
}
|
|
}
|
|
match serde_json::to_string_pretty(status) {
|
|
Ok(content) => {
|
|
if let Err(e) = fs::write(&status_path, content) {
|
|
error!("Failed to write input status file {}: {e}", status_path.display());
|
|
}
|
|
}
|
|
Err(e) => error!("Failed to serialize input status: {e}"),
|
|
}
|
|
}
|
|
|
|
pub fn is_cache_valid(status: &InputStatus, cluster: &str, cache_duration_seconds: u64) -> bool {
|
|
if cache_duration_seconds == 0 {
|
|
return false;
|
|
}
|
|
if let Some(cluster_status) = status.clusters.get(cluster) {
|
|
if cluster_status.status != ClusterState::Ok {
|
|
return false;
|
|
}
|
|
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
|
if now >= cluster_status.timestamp {
|
|
return now - cluster_status.timestamp < cache_duration_seconds;
|
|
}
|
|
// Timestamp in future? Invalid.
|
|
return false;
|
|
}
|
|
false
|
|
}
|
|
|
|
pub fn update_cluster_status(status: &mut InputStatus, cluster: &str, state: ClusterState) {
|
|
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
|
status.clusters.insert(cluster.to_string(), ClusterStatus { status: state, timestamp: now, last_update: None });
|
|
}
|
|
|
|
/// Replaces exactly one cluster's last snapshot while retaining its cache timestamp.
|
|
///
|
|
/// Stalker stores cache validity under `default`; its timestamp and state seed a
|
|
/// per-cluster read projection without changing the cache key used by processing.
|
|
pub fn replace_cluster_snapshot(
|
|
status: &mut InputStatus,
|
|
cluster: &str,
|
|
snapshot: PersistedPlaylistUpdateClusterSnapshot,
|
|
) -> bool {
|
|
if let Some(cluster_status) = status.clusters.get_mut(cluster) {
|
|
if cluster_status.last_update == Some(snapshot) {
|
|
return false;
|
|
}
|
|
cluster_status.last_update = Some(snapshot);
|
|
return true;
|
|
}
|
|
|
|
let Some(default_status) = status.clusters.get("default") else {
|
|
return false;
|
|
};
|
|
status.clusters.insert(
|
|
cluster.to_string(),
|
|
ClusterStatus {
|
|
status: default_status.status,
|
|
timestamp: default_status.timestamp,
|
|
last_update: Some(snapshot),
|
|
},
|
|
);
|
|
true
|
|
}
|
|
|
|
/// Replaces a cluster read projection from the canonical `default` cache status.
|
|
pub fn replace_cluster_snapshot_from_default_status(
|
|
status: &mut InputStatus,
|
|
cluster: &str,
|
|
snapshot: PersistedPlaylistUpdateClusterSnapshot,
|
|
) -> bool {
|
|
let Some(default_status) = status.clusters.get("default") else {
|
|
return false;
|
|
};
|
|
let replacement = ClusterStatus {
|
|
status: default_status.status,
|
|
timestamp: default_status.timestamp,
|
|
last_update: Some(snapshot),
|
|
};
|
|
if status.clusters.get(cluster) == Some(&replacement) {
|
|
return false;
|
|
}
|
|
status.clusters.insert(cluster.to_string(), replacement);
|
|
true
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
load_input_status, replace_cluster_snapshot, replace_cluster_snapshot_from_default_status, save_input_status,
|
|
ClusterState, ClusterStatus, InputStatus,
|
|
};
|
|
use shared::model::{
|
|
PersistedPlaylistUpdateClusterSnapshot, PersistedPlaylistUpdateTechnicalState, PlaylistUpdateDataSource,
|
|
XtreamCluster,
|
|
};
|
|
|
|
#[test]
|
|
fn persisted_cluster_snapshot_reads_legacy_status_json_without_last_update() {
|
|
let temp = tempfile::tempdir().expect("temporary status directory");
|
|
std::fs::write(temp.path().join(super::STATUS_FILE), r#"{"clusters":{"live":{"status":"Ok","timestamp":17}}}"#)
|
|
.expect("legacy status fixture");
|
|
|
|
let status = load_input_status(temp.path());
|
|
|
|
let live = status.clusters.get("live").expect("legacy live status");
|
|
assert_eq!(live.status, ClusterState::Ok);
|
|
assert_eq!(live.timestamp, 17);
|
|
assert_eq!(live.last_update, None);
|
|
assert_eq!(status.last_input_update, None);
|
|
}
|
|
|
|
#[test]
|
|
fn persisted_cluster_snapshot_replaces_one_value_without_history() {
|
|
let temp = tempfile::tempdir().expect("temporary status directory");
|
|
let mut status = InputStatus::default();
|
|
super::update_cluster_status(&mut status, XtreamCluster::Live.as_ref(), ClusterState::Ok);
|
|
let first = PersistedPlaylistUpdateClusterSnapshot {
|
|
source: Some(PlaylistUpdateDataSource::Provider),
|
|
technical_state: Some(PersistedPlaylistUpdateTechnicalState::Succeeded),
|
|
..PersistedPlaylistUpdateClusterSnapshot::default()
|
|
};
|
|
let replacement = PersistedPlaylistUpdateClusterSnapshot {
|
|
source: Some(PlaylistUpdateDataSource::Cache),
|
|
technical_state: Some(PersistedPlaylistUpdateTechnicalState::Succeeded),
|
|
..PersistedPlaylistUpdateClusterSnapshot::default()
|
|
};
|
|
|
|
assert!(replace_cluster_snapshot(&mut status, XtreamCluster::Live.as_ref(), first));
|
|
assert!(replace_cluster_snapshot(&mut status, XtreamCluster::Live.as_ref(), replacement));
|
|
save_input_status(temp.path(), &status);
|
|
|
|
let reloaded = load_input_status(temp.path());
|
|
assert_eq!(reloaded.clusters.len(), 1);
|
|
assert_eq!(
|
|
reloaded.clusters["live"].last_update,
|
|
Some(replacement),
|
|
"the new completion must replace, not append to, the prior snapshot"
|
|
);
|
|
let encoded = std::fs::read_to_string(temp.path().join(super::STATUS_FILE)).expect("persisted status");
|
|
assert_eq!(encoded.matches("last_update").count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn persisted_cluster_snapshot_uses_current_canonical_default_status_for_projection() {
|
|
let mut status = InputStatus::default();
|
|
status.clusters.insert(
|
|
"default".to_string(),
|
|
ClusterStatus { status: ClusterState::Failed, timestamp: 23, last_update: None },
|
|
);
|
|
status.clusters.insert(
|
|
XtreamCluster::Live.as_ref().to_string(),
|
|
ClusterStatus { status: ClusterState::Ok, timestamp: 17, last_update: None },
|
|
);
|
|
let snapshot = PersistedPlaylistUpdateClusterSnapshot {
|
|
source: Some(PlaylistUpdateDataSource::Provider),
|
|
..PersistedPlaylistUpdateClusterSnapshot::default()
|
|
};
|
|
|
|
assert!(replace_cluster_snapshot_from_default_status(&mut status, XtreamCluster::Live.as_ref(), snapshot));
|
|
|
|
let projected = &status.clusters[XtreamCluster::Live.as_ref()];
|
|
assert_eq!(projected.status, ClusterState::Failed);
|
|
assert_eq!(projected.timestamp, 23);
|
|
assert_eq!(projected.last_update, Some(snapshot));
|
|
}
|
|
}
|