mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-18 22:03:01 +02:00
New Features
Added separate processing and persistence filters, with empty/non-empty checks and expanded filter fields.
Added persistence-stage filter controls in target settings.
Added clear_invalid_epg_ids, preserving playlist entries while clearing unresolved EPG IDs; the legacy setting remains supported.
Added safer, atomic source configuration updates that preserve comments and formatting.
Bug Fixes
Null-like Xtream and Stalker values are now handled correctly.
Duplicate credential warnings provide clearer details.
177 lines
6.6 KiB
Rust
177 lines
6.6 KiB
Rust
//! Where a [`ProviderCapabilities`] snapshot survives a restart.
|
|
//!
|
|
//! Split from the value type so the knowledge is useful before anyone decides where it
|
|
//! lives: the in-memory store is enough for a single run, and the JSON store makes it
|
|
//! outlast one. Both are held as `impl CapabilityStore` rather than a trait object.
|
|
|
|
use crate::capabilities::ProviderCapabilities;
|
|
use log::warn;
|
|
use parking_lot::RwLock;
|
|
use std::{
|
|
collections::HashMap,
|
|
future::Future,
|
|
path::{Path, PathBuf},
|
|
};
|
|
|
|
pub trait CapabilityStore: Send + Sync {
|
|
/// The snapshot for `input`, or the default when nothing is stored. A store that
|
|
/// cannot be read reports the default rather than failing: capability knowledge is an
|
|
/// optimisation, and losing it must never stop a refresh.
|
|
fn load(&self, input: &str) -> impl Future<Output = ProviderCapabilities> + Send;
|
|
|
|
/// Persist the snapshot for `input`.
|
|
fn save(&self, input: &str, capabilities: &ProviderCapabilities) -> impl Future<Output = ()> + Send;
|
|
}
|
|
|
|
/// Keeps snapshots for the lifetime of the process. What the client falls back to when no
|
|
/// storage directory is configured.
|
|
#[derive(Debug, Default)]
|
|
pub struct InMemoryCapabilityStore {
|
|
entries: RwLock<HashMap<String, ProviderCapabilities>>,
|
|
}
|
|
|
|
impl InMemoryCapabilityStore {
|
|
#[must_use]
|
|
pub fn new() -> Self { Self::default() }
|
|
}
|
|
|
|
impl CapabilityStore for InMemoryCapabilityStore {
|
|
fn load(&self, input: &str) -> impl Future<Output = ProviderCapabilities> + Send {
|
|
let stored = self.entries.read().get(input).cloned().unwrap_or_default();
|
|
async move { stored }
|
|
}
|
|
|
|
fn save(&self, input: &str, capabilities: &ProviderCapabilities) -> impl Future<Output = ()> + Send {
|
|
self.entries.write().insert(input.to_string(), capabilities.clone());
|
|
async {}
|
|
}
|
|
}
|
|
|
|
/// One JSON file per input, written atomically through the workspace's existing helper so
|
|
/// a crash mid-write cannot leave a half-file that fails to parse on the next start.
|
|
#[derive(Debug, Clone)]
|
|
pub struct JsonCapabilityStore {
|
|
dir: PathBuf,
|
|
}
|
|
|
|
impl JsonCapabilityStore {
|
|
#[must_use]
|
|
pub fn new(dir: impl Into<PathBuf>) -> Self { Self { dir: dir.into() } }
|
|
|
|
/// The file for `input`. The name is derived rather than taken verbatim: input names
|
|
/// come from user config and can contain separators.
|
|
#[must_use]
|
|
pub fn path_for(&self, input: &str) -> PathBuf {
|
|
self.dir.join(format!("{}.capabilities.json", sanitize_component(input)))
|
|
}
|
|
}
|
|
|
|
impl CapabilityStore for JsonCapabilityStore {
|
|
fn load(&self, input: &str) -> impl Future<Output = ProviderCapabilities> + Send {
|
|
let path = self.path_for(input);
|
|
async move { read_snapshot(&path).await }
|
|
}
|
|
|
|
fn save(&self, input: &str, capabilities: &ProviderCapabilities) -> impl Future<Output = ()> + Send {
|
|
let path = self.path_for(input);
|
|
let dir = self.dir.clone();
|
|
let encoded = serde_json::to_vec(capabilities);
|
|
async move {
|
|
let encoded = match encoded {
|
|
Ok(encoded) => encoded,
|
|
Err(err) => {
|
|
warn!("Could not encode provider capabilities for {}: {err}", path.display());
|
|
return;
|
|
}
|
|
};
|
|
if let Err(err) = tokio::fs::create_dir_all(&dir).await {
|
|
warn!("Could not create capability directory {}: {err}", dir.display());
|
|
return;
|
|
}
|
|
if let Err(err) = tuliprox_core::utils::write_json_atomic(&path, &encoded).await {
|
|
warn!("Could not persist provider capabilities to {}: {err}", path.display());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn read_snapshot(path: &Path) -> ProviderCapabilities {
|
|
let Ok(bytes) = tokio::fs::read(path).await else {
|
|
// A missing file is the normal first-run case, not a problem worth logging.
|
|
return ProviderCapabilities::default();
|
|
};
|
|
match serde_json::from_slice(&bytes) {
|
|
Ok(capabilities) => capabilities,
|
|
Err(err) => {
|
|
// Corrupt state must not be fatal: re-probing is always available.
|
|
warn!("Ignoring unreadable provider capabilities at {}: {err}", path.display());
|
|
ProviderCapabilities::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
#[inline]
|
|
fn sanitize_component(value: &str) -> String { crate::redaction::sanitize_path_component(value, false) }
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{CapabilityStore, InMemoryCapabilityStore, JsonCapabilityStore};
|
|
use crate::capabilities::ProviderCapabilities;
|
|
|
|
const NOW: u64 = 1_700_000_000;
|
|
|
|
fn observed() -> ProviderCapabilities {
|
|
let mut capabilities = ProviderCapabilities::default();
|
|
capabilities.record_unsupported("get_all_channels", NOW);
|
|
capabilities.record_handshake("StrictMag", "portal.php", NOW);
|
|
capabilities
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_unknown_input_loads_the_default_rather_than_failing() {
|
|
let store = InMemoryCapabilityStore::new();
|
|
assert_eq!(store.load("never-seen").await, ProviderCapabilities::default());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn in_memory_snapshots_survive_within_the_run() {
|
|
let store = InMemoryCapabilityStore::new();
|
|
store.save("provider", &observed()).await;
|
|
assert_eq!(store.load("provider").await, observed());
|
|
assert_eq!(store.load("other").await, ProviderCapabilities::default(), "inputs must not share state");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_json_snapshot_survives_a_restart() -> std::io::Result<()> {
|
|
let dir = tempfile::tempdir()?;
|
|
let store = JsonCapabilityStore::new(dir.path());
|
|
|
|
store.save("provider one", &observed()).await;
|
|
// A second store over the same directory stands in for the next process.
|
|
assert_eq!(JsonCapabilityStore::new(dir.path()).load("provider one").await, observed());
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_input_name_with_separators_cannot_escape_the_directory() -> std::io::Result<()> {
|
|
let dir = tempfile::tempdir()?;
|
|
let store = JsonCapabilityStore::new(dir.path());
|
|
|
|
let path = store.path_for("../../etc/passwd");
|
|
|
|
assert_eq!(path.parent(), Some(dir.path()));
|
|
assert!(!path.to_string_lossy().contains(".."));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_corrupt_snapshot_is_ignored_rather_than_fatal() -> std::io::Result<()> {
|
|
let dir = tempfile::tempdir()?;
|
|
let store = JsonCapabilityStore::new(dir.path());
|
|
tokio::fs::write(store.path_for("provider"), b"{ this is not json").await?;
|
|
|
|
assert_eq!(store.load("provider").await, ProviderCapabilities::default());
|
|
Ok(())
|
|
}
|
|
}
|