Files
tuliprox/backend/processing/src/processor/epg.rs
T

1879 lines
74 KiB
Rust
Raw Normal View History

2026-08-27 18:36:23 +05:00
use crate::{fetched_playlist::FetchedPlaylist, parser::xmltv::normalize_channel_name};
use log::{debug, trace, warn};
2025-04-23 20:21:36 +02:00
use rphonetic::{DoubleMetaphone, Encoder};
use shared::{
model::{
ConfigTargetOptions, EpgChannel, EpgNamePrefix, EpgSmartMatchConfigDto, PlaylistGroup, PlaylistItem,
XtreamCluster,
},
2026-08-27 18:36:23 +05:00
utils::{Internable, CONSTANTS},
};
use std::{
cmp::Ordering,
collections::{HashMap, HashSet},
sync::Arc,
};
2026-08-27 18:36:23 +05:00
use tuliprox_core::{
model::{Epg, EpgConfig, EpgSmartMatchConfig},
utils::with_folded_epg_id,
};
const MIN_FUZZY_SCORE_MARGIN: u16 = 3;
/// Target options that control how matched guide ids are written back to live channels.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct EpgAssignOptions {
pub clear_invalid_epg_ids: bool,
pub adopt_guide_id_case: bool,
}
impl Default for EpgAssignOptions {
fn default() -> Self { Self { clear_invalid_epg_ids: false, adopt_guide_id_case: true } }
}
impl EpgAssignOptions {
pub fn from_target_options(options: Option<&ConfigTargetOptions>) -> Self {
options.map_or_else(Self::default, |options| Self {
clear_invalid_epg_ids: options.clear_invalid_epg_ids(),
adopt_guide_id_case: options.adopt_guide_epg_id_case(),
})
}
}
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
enum SmartMatchKind {
Fuzzy,
Exact,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct SmartMatchRank {
kind: SmartMatchKind,
score: u16,
source_priority: i16,
source_order: usize,
}
#[derive(Clone, Debug)]
struct RankedEpgMatch {
epg_id: Arc<str>,
rank: SmartMatchRank,
}
impl SmartMatchRank {
fn cmp_preference(&self, other: &Self) -> Ordering {
self.kind
.cmp(&other.kind)
.then_with(|| self.score.cmp(&other.score))
.then_with(|| other.source_priority.cmp(&self.source_priority))
.then_with(|| other.source_order.cmp(&self.source_order))
}
}
#[derive(Debug)]
struct FuzzyCandidate {
key: Arc<str>,
score: u16,
}
#[derive(Debug, Default)]
struct MatchRequirement {
missing_id: bool,
direct_ids: HashSet<Arc<str>>,
}
2025-06-19 12:45:55 +02:00
pub struct EpgIdCache {
/// Membership set of the epg ids collected from playlist channels.
/// Keys are stored ASCII-lowercased so guide-id matching is case-insensitive.
/// This set is used only for membership tests, never to source an emitted/display
/// id, so folding the stored key does not affect output (channels keep their
/// original-case id). Insert via [`Self::insert_channel_epg_id`] and query via
/// [`Self::contains_channel_epg_id`] to keep the fold consistent.
pub channel_epg_id: HashSet<Arc<str>>,
pub normalized: HashMap<Arc<str>, Option<Arc<str>>>,
pub phonetics: HashMap<Arc<str>, HashSet<Arc<str>>>,
prefixes: HashMap<u32, HashSet<Arc<str>>>,
cores: HashMap<Arc<str>, HashSet<Arc<str>>>,
match_ranks: HashMap<Arc<str>, SmartMatchRank>,
match_candidates: HashMap<Arc<str>, HashMap<Arc<str>, RankedEpgMatch>>,
match_requirements: HashMap<Arc<str>, MatchRequirement>,
guide_names: HashMap<Arc<str>, HashSet<Arc<str>>>,
pub processed: HashSet<Arc<str>>,
2025-04-23 20:21:36 +02:00
pub smart_match_config: EpgSmartMatchConfig,
pub metaphone: DoubleMetaphone,
pub smart_match_enabled: bool, // smart match is enabled, normalizing names
pub fuzzy_match_enabled: bool, // fuzzy matching enabled
2025-04-23 20:21:36 +02:00
}
impl EpgIdCache {
2025-04-25 14:19:28 +00:00
/// Creates a new `EpgIdCache` with configuration for smart and fuzzy matching.
///
/// Initializes all internal caches and sets matching options based on the provided EPG configuration. If no configuration is given, defaults are used.
///
/// # Examples
///
2026-08-27 18:36:23 +05:00
/// ```text
2025-04-25 14:19:28 +00:00
/// let cache = EpgIdCache::new(None);
/// assert!(cache.is_empty());
/// ```
2025-04-23 20:21:36 +02:00
pub fn new(epg_config: Option<&EpgConfig>) -> Self {
2025-06-24 01:06:25 +02:00
let normalize_config: EpgSmartMatchConfig = epg_config
.and_then(|cfg| cfg.smart_match.clone())
.unwrap_or_else(|| EpgSmartMatchConfigDto::default().into());
2025-04-23 20:21:36 +02:00
EpgIdCache {
channel_epg_id: HashSet::new(), // contains the epg_ids collected from playlist channels
normalized: HashMap::new(),
phonetics: HashMap::new(),
prefixes: HashMap::new(),
cores: HashMap::new(),
match_ranks: HashMap::new(),
match_candidates: HashMap::new(),
match_requirements: HashMap::new(),
guide_names: HashMap::new(),
2025-04-23 20:21:36 +02:00
processed: HashSet::new(),
metaphone: DoubleMetaphone::default(),
smart_match_enabled: normalize_config.enabled,
fuzzy_match_enabled: normalize_config.enabled && normalize_config.fuzzy_matching,
smart_match_config: normalize_config,
}
}
fn is_empty(&self) -> bool { self.channel_epg_id.is_empty() && self.normalized.is_empty() }
2025-04-23 20:21:36 +02:00
/// Adds an epg id to the case-folded membership set.
///
/// The key is ASCII-lowercased so a `MixedCase` source id matches a guide
/// `<channel id>` of a different case. ASCII folding (not Unicode) avoids
/// Turkish-i/locale issues and is faster; channel ids are ASCII.
pub fn insert_channel_epg_id(&mut self, id: &str) {
with_folded_epg_id(id, |folded| self.channel_epg_id.insert(folded.intern()));
}
/// Case-insensitive (ASCII) membership test against the folded keys stored by
/// [`Self::insert_channel_epg_id`].
pub fn contains_channel_epg_id(&self, id: &str) -> bool {
with_folded_epg_id(id, |folded| self.channel_epg_id.contains(folded))
}
2026-08-27 18:36:23 +05:00
pub fn needs_guide_names(&self, id: &str) -> bool { self.contains_channel_epg_id(id) }
pub fn insert_processed_epg_id(&mut self, id: &str) {
with_folded_epg_id(id, |folded| self.processed.insert(folded.intern()));
}
pub fn contains_processed_epg_id(&self, id: &str) -> bool {
with_folded_epg_id(id, |folded| self.processed.contains(folded))
}
pub fn selected_epg_ids(&self, available_epg_ids: &HashSet<Arc<str>>) -> HashSet<Arc<str>> {
let mut selected = self.channel_epg_id.intersection(available_epg_ids).cloned().collect::<HashSet<_>>();
selected.extend(self.normalized.iter().filter_map(|(key, id)| {
let requirement = self.match_requirements.get(key)?;
let needs_smart_match = requirement.missing_id
|| requirement.direct_ids.iter().any(|direct_id| {
!available_epg_ids.contains(direct_id)
|| self.normalized_id_name_compatible(direct_id, key).is_some_and(|compatible| !compatible)
});
needs_smart_match.then(|| id.as_ref().map(|id| with_folded_epg_id(id, |folded| folded.intern()))).flatten()
}));
selected
}
pub fn replace_processed_epg_ids(&mut self, ids: HashSet<Arc<str>>) { self.processed = ids; }
pub fn register_guide_names<I, S>(&mut self, epg_id: &str, names: I)
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let normalized_names = names
.into_iter()
.map(|name| self.normalize(name.as_ref()).intern())
.filter(|name| !name.is_empty())
.collect::<Vec<_>>();
if normalized_names.is_empty() {
return;
}
let folded_id = with_folded_epg_id(epg_id, |folded| folded.intern());
self.guide_names.entry(folded_id).or_default().extend(normalized_names);
}
pub fn finalize_matches(&mut self, available_epg_ids: &HashSet<Arc<str>>) {
self.match_ranks.clear();
let mut exact = 0usize;
let mut fuzzy = 0usize;
let mut ambiguous = 0usize;
let mut unavailable = 0usize;
for (key, entry) in &mut self.normalized {
let candidates = self.match_candidates.get(key);
let available_count = candidates.map_or(0, |candidates| {
candidates.keys().filter(|folded_id| available_epg_ids.contains(*folded_id)).count()
});
let winner =
candidates.and_then(|candidates| {
best_ranked_match(candidates.iter().filter_map(|(folded_id, candidate)| {
available_epg_ids.contains(folded_id).then_some(candidate)
}))
});
if let Some(winner) = winner {
entry.replace(Arc::clone(&winner.epg_id));
self.match_ranks.insert(Arc::clone(key), winner.rank);
match winner.rank.kind {
SmartMatchKind::Exact => exact += 1,
SmartMatchKind::Fuzzy => fuzzy += 1,
}
} else {
entry.take();
if available_count > 0 {
ambiguous += 1;
} else {
unavailable += 1;
}
}
}
debug!("Smart EPG candidates: exact={exact}, fuzzy={fuzzy}, ambiguous={ambiguous}, unavailable={unavailable}");
}
2025-04-25 14:19:28 +00:00
/// Normalizes a channel name, computes its phonetic encoding, and stores both in the cache for later EPG matching.
///
/// The normalized name is mapped to the provided EPG ID (if any), and the phonetic encoding is added to the phonetics map.
/// This facilitates efficient lookup and fuzzy matching of channel names during EPG assignment.
///
/// # Examples
///
2026-08-27 18:36:23 +05:00
/// ```text
2025-04-25 14:19:28 +00:00
/// let mut cache = EpgIdCache::new(None);
/// cache.normalize_and_store("Discovery Channel", Some(&"discovery.epg".to_string()));
/// assert!(cache.normalized.contains_key(&cache.normalize("Discovery Channel")));
/// ```
fn normalize_and_store(&mut self, name: &str, epg_id: Option<&Arc<str>>) {
self.insert_normalized(name, epg_id);
2025-08-18 20:21:58 +02:00
if let Some(chan_epg_id) = epg_id {
self.insert_normalized(chan_epg_id, epg_id);
2025-08-18 20:21:58 +02:00
}
2025-04-23 20:21:36 +02:00
}
fn insert_normalized(&mut self, key: &str, direct_epg_id: Option<&Arc<str>>) {
let normalized = self.normalize(key).intern();
if normalized.is_empty() {
return;
}
2025-08-18 20:37:47 +02:00
let phonetic = self.phonetic(&normalized);
self.normalized.entry(normalized.clone()).or_insert(None);
let requirement = self.match_requirements.entry(normalized.clone()).or_default();
if let Some(id) = direct_epg_id.filter(|id| !id.is_empty()) {
requirement.direct_ids.insert(with_folded_epg_id(id, |folded| folded.intern()));
} else {
requirement.missing_id = true;
}
self.phonetics.entry(phonetic).or_default().insert(normalized.clone());
if let Some(prefix) = candidate_prefix(&normalized) {
self.prefixes.entry(prefix).or_default().insert(normalized.clone());
}
let (core, _) = self.split_country(&normalized);
self.cores.entry(core.intern()).or_default().insert(normalized);
2025-08-18 20:37:47 +02:00
}
2025-04-23 20:21:36 +02:00
2025-04-25 14:19:28 +00:00
/// Returns the normalized form of a channel name using the configured smart match settings.
///
/// # Examples
///
2026-08-27 18:36:23 +05:00
/// ```text
2025-04-25 14:19:28 +00:00
/// let cache = EpgIdCache::new(None);
/// let normalized = cache.normalize("HBO HD");
/// assert!(!normalized.is_empty());
/// ```
fn normalize(&self, name: &str) -> String { normalize_channel_name(name, &self.smart_match_config) }
2025-04-23 20:21:36 +02:00
2026-08-27 18:36:23 +05:00
pub fn phonetic(&self, name: &Arc<str>) -> Arc<str> {
2025-08-18 20:21:58 +02:00
let result = self.metaphone.encode(name);
2026-08-27 18:36:23 +05:00
if result.is_empty() {
name.clone()
} else {
result.intern()
}
2025-04-23 20:21:36 +02:00
}
pub fn collect_epg_id(&mut self, fp: &mut FetchedPlaylist) {
let smart_match_enabled = self.smart_match_enabled;
let fuzzy_matching = self.fuzzy_match_enabled;
// Helper closure to process a single item
// We use a closure here to capture `self` and avoid code duplication
let mut process_item = |name: &str, epg_channel_id: Option<&Arc<str>>| {
2025-04-23 20:21:36 +02:00
let mut missing_epg_id = true;
// insert epg_id to known channel epg_ids
if let Some(id) = epg_channel_id {
2025-04-23 20:21:36 +02:00
if !id.is_empty() {
missing_epg_id = false;
self.insert_channel_epg_id(id);
2025-04-23 20:21:36 +02:00
}
}
// for fuzzy_matching we need to put the normalized name even if there is an epg_id, because the epg_id
// could not match to the epg file. And then we try to guess it based on normalized name
let needs_normalization =
smart_match_enabled && (fuzzy_matching || missing_epg_id) && !is_decorative_channel_name(name);
2025-04-23 20:21:36 +02:00
if needs_normalization {
self.normalize_and_store(name, epg_channel_id);
}
};
for channel in fp.items() {
if channel.header.xtream_cluster == XtreamCluster::Live && channel.header.item_type.is_live() {
process_item(&channel.header.name, channel.header.epg_channel_id.as_ref());
2025-04-23 20:21:36 +02:00
}
}
}
2026-07-10 14:53:33 +02:00
pub fn normalize_candidates<I, S>(&self, candidates: I) -> Vec<Arc<str>>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
candidates
.into_iter()
.map(|candidate| candidate.as_ref().trim().to_string())
.filter(|candidate| !candidate.is_empty())
.map(|candidate| self.normalize(&candidate).intern())
.filter(|candidate| !candidate.is_empty())
2026-07-10 14:53:33 +02:00
.collect()
}
pub fn match_epg_channel_candidates(
&mut self,
epg_id: &Arc<str>,
normalized_candidates: &[Arc<str>],
source_priority: i16,
source_order: usize,
) -> bool {
let normalized_epg_id = self.normalize(epg_id);
let (_, id_country) = self.split_country(&normalized_epg_id);
let guide_country = id_country.or_else(|| self.common_country(normalized_candidates));
let mut exact_candidate_found = false;
let mut selected = false;
for key in normalized_candidates {
if self.normalized.contains_key(key) {
let (_, playlist_country) = self.split_country(key);
if !countries_compatible(playlist_country, guide_country) {
continue;
}
exact_candidate_found = true;
selected |= self.assign_match(
key,
epg_id,
SmartMatchRank { kind: SmartMatchKind::Exact, score: 100, source_priority, source_order },
);
2026-07-10 14:53:33 +02:00
}
}
let mut relaxed_keys = HashSet::new();
for guide_key in normalized_candidates {
let (guide_core, candidate_country) = self.split_country(guide_key);
let effective_guide_country = guide_country.or(candidate_country);
if let Some(keys) = self.cores.get(guide_core) {
relaxed_keys.extend(
keys.iter()
.filter(|key| {
let (_, playlist_country) = self.split_country(key);
countries_compatible(playlist_country, effective_guide_country)
})
.cloned(),
);
}
}
for key in relaxed_keys {
exact_candidate_found = true;
selected |= self.assign_match(
&key,
epg_id,
SmartMatchRank { kind: SmartMatchKind::Exact, score: 99, source_priority, source_order },
);
}
if exact_candidate_found || !self.fuzzy_match_enabled {
return selected;
}
let Some(candidate) = self.find_best_fuzzy_match(normalized_candidates, guide_country) else {
return false;
};
let rank =
SmartMatchRank { kind: SmartMatchKind::Fuzzy, score: candidate.score, source_priority, source_order };
self.assign_match(&candidate.key, epg_id, rank)
2026-07-10 14:53:33 +02:00
}
fn assign_match(&mut self, key: &Arc<str>, epg_id: &Arc<str>, rank: SmartMatchRank) -> bool {
if !self.normalized.contains_key(key) {
return false;
}
let folded_id = with_folded_epg_id(epg_id, |folded| folded.intern());
let candidates = self.match_candidates.entry(Arc::clone(key)).or_default();
match candidates.entry(folded_id) {
std::collections::hash_map::Entry::Occupied(mut entry) => {
if rank.cmp_preference(&entry.get().rank).is_gt() {
entry.insert(RankedEpgMatch { epg_id: Arc::clone(epg_id), rank });
}
}
std::collections::hash_map::Entry::Vacant(entry) => {
entry.insert(RankedEpgMatch { epg_id: Arc::clone(epg_id), rank });
}
}
2026-07-10 14:53:33 +02:00
let winner = candidates.values().max_by(|left, right| compare_ranked_matches(left, right));
if let Some(winner) = winner {
self.normalized.get_mut(key).expect("normalized key exists").replace(Arc::clone(&winner.epg_id));
self.match_ranks.insert(Arc::clone(key), winner.rank);
}
true
}
fn find_best_fuzzy_match(
&self,
normalized_candidates: &[Arc<str>],
guide_country: Option<&str>,
) -> Option<FuzzyCandidate> {
let mut phonetic_keys = HashSet::new();
for candidate in normalized_candidates {
if let Some(keys) = self.phonetics.get(&self.phonetic(candidate)) {
phonetic_keys.extend(keys.iter().cloned());
}
}
if let Some(best) = self.select_unambiguous_candidate(
normalized_candidates,
phonetic_keys.iter(),
self.smart_match_config.match_threshold,
guide_country,
) {
return Some(best);
}
let mut fallback_keys = HashSet::new();
for candidate in normalized_candidates {
if let Some(prefix) = candidate_prefix(candidate) {
if let Some(keys) = self.prefixes.get(&prefix) {
fallback_keys.extend(keys.iter().cloned());
2026-07-10 14:53:33 +02:00
}
}
}
self.select_unambiguous_candidate(
normalized_candidates,
fallback_keys.iter(),
self.smart_match_config.best_match_threshold,
guide_country,
)
}
2026-07-10 14:53:33 +02:00
fn select_unambiguous_candidate<'a, I>(
&self,
guide_candidates: &[Arc<str>],
playlist_candidates: I,
threshold: u16,
guide_country: Option<&str>,
) -> Option<FuzzyCandidate>
where
I: IntoIterator<Item = &'a Arc<str>>,
{
let mut best: Option<FuzzyCandidate> = None;
let mut runner_up: Option<FuzzyCandidate> = None;
for playlist_key in playlist_candidates {
let (_, playlist_country) = self.split_country(playlist_key);
let Some(score) = guide_candidates
.iter()
.filter(|guide_key| {
numeric_signature_matches(playlist_key, guide_key)
&& countries_compatible(playlist_country, guide_country)
&& self.normalized_countries_compatible(playlist_key, guide_key)
2026-07-10 14:53:33 +02:00
})
.map(|guide_key| similarity_score(playlist_key, guide_key))
.max()
else {
continue;
};
let candidate = FuzzyCandidate { key: Arc::clone(playlist_key), score };
if best.as_ref().is_none_or(|current| candidate_precedes(&candidate, current)) {
runner_up = best.replace(candidate);
} else if runner_up.as_ref().is_none_or(|current| candidate_precedes(&candidate, current)) {
runner_up = Some(candidate);
}
}
let best = best?;
if best.score < threshold {
return None;
}
if let Some(runner_up) = runner_up {
if best.score == runner_up.score {
return None;
}
if best.score < self.smart_match_config.best_match_threshold
&& best.score.saturating_sub(runner_up.score) < MIN_FUZZY_SCORE_MARGIN
{
return None;
}
}
Some(best)
}
fn split_country<'a>(&self, value: &'a str) -> (&'a str, Option<&'a str>) {
let split = match &self.smart_match_config.name_prefix {
EpgNamePrefix::Suffix(separator) if !separator.is_empty() => value.rsplit_once(separator),
EpgNamePrefix::Prefix(separator) if !separator.is_empty() => {
value.split_once(separator).map(|(country, core)| (core, country))
}
EpgNamePrefix::Ignore | EpgNamePrefix::Suffix(_) | EpgNamePrefix::Prefix(_) => None,
};
split
.filter(|(_, country)| CONSTANTS.country_codes.contains(country))
.map_or((value, None), |(core, country)| (core, Some(country)))
}
fn common_country<'a>(&self, candidates: &'a [Arc<str>]) -> Option<&'a str> {
let mut country = None;
for candidate in candidates {
let (_, candidate_country) = self.split_country(candidate);
if let Some(candidate_country) = candidate_country {
if country.is_some_and(|current| current != candidate_country) {
return None;
}
country = Some(candidate_country);
}
}
country
}
fn normalized_countries_compatible(&self, left: &str, right: &str) -> bool {
let (_, left_country) = self.split_country(left);
let (_, right_country) = self.split_country(right);
countries_compatible(left_country, right_country)
}
fn normalized_id_name_compatible(&self, folded_epg_id: &str, playlist_name: &str) -> Option<bool> {
let guide_names = self.guide_names.get(folded_epg_id)?;
let (playlist_core, playlist_country) = self.split_country(playlist_name);
let best_score = guide_names
.iter()
.filter_map(|guide_name| {
let (guide_core, guide_country) = self.split_country(guide_name);
(countries_compatible(playlist_country, guide_country)
&& numeric_signature_matches(playlist_core, guide_core))
.then(|| similarity_score(playlist_core, guide_core))
2026-07-10 14:53:33 +02:00
})
.max();
Some(best_score.is_some_and(|score| score >= self.smart_match_config.match_threshold))
}
fn channel_name_matches_epg_id(&self, epg_id: &str, channel_name: &str) -> Option<bool> {
let folded_id = with_folded_epg_id(epg_id, |folded| folded.intern());
let normalized_name = self.normalize(channel_name);
self.normalized_id_name_compatible(&folded_id, &normalized_name)
}
fn matched_epg_id(&self, key: &str) -> Option<(Arc<str>, SmartMatchKind)> {
let normalized = self.normalize(key).intern();
let epg_id = self.normalized.get(&normalized)?.as_ref()?;
let kind = self.match_ranks.get(&normalized)?.kind;
Some((Arc::clone(epg_id), kind))
}
}
fn candidate_precedes(left: &FuzzyCandidate, right: &FuzzyCandidate) -> bool {
left.score > right.score || (left.score == right.score && left.key < right.key)
}
fn compare_ranked_matches(left: &RankedEpgMatch, right: &RankedEpgMatch) -> Ordering {
left.rank.cmp_preference(&right.rank).then_with(|| right.epg_id.cmp(&left.epg_id))
}
fn best_ranked_match<'a, I>(candidates: I) -> Option<&'a RankedEpgMatch>
where
I: IntoIterator<Item = &'a RankedEpgMatch>,
{
let mut best = None;
let mut ambiguous = false;
for candidate in candidates {
let Some(current) = best else {
best = Some(candidate);
continue;
};
match candidate.rank.cmp_preference(&current.rank) {
Ordering::Greater => {
best = Some(candidate);
ambiguous = false;
}
Ordering::Equal if !candidate.epg_id.eq_ignore_ascii_case(&current.epg_id) => ambiguous = true,
Ordering::Equal | Ordering::Less => {}
}
}
(!ambiguous).then_some(best).flatten()
}
fn countries_compatible(left: Option<&str>, right: Option<&str>) -> bool {
left.zip(right).is_none_or(|(left, right)| left == right)
}
fn is_decorative_channel_name(name: &str) -> bool {
let trimmed = name.trim();
if trimmed.is_empty() {
return false;
}
if !trimmed.chars().any(char::is_alphanumeric) {
return true;
}
let separator = trimmed.chars().next().expect("trimmed name is not empty");
if separator.is_alphanumeric() {
return false;
}
trimmed.chars().take_while(|&character| character == separator).count() >= 2
&& trimmed.chars().rev().take_while(|&character| character == separator).count() >= 2
}
fn candidate_prefix(value: &str) -> Option<u32> {
let mut prefix = 0u32;
let mut len = 0u32;
for byte in value.bytes().filter(u8::is_ascii_alphanumeric).take(3) {
prefix = (prefix << 8) | u32::from(byte.to_ascii_lowercase());
len += 1;
}
(len > 0).then_some((len << 24) | prefix)
}
fn numeric_signature_matches(left: &str, right: &str) -> bool {
left.bytes().filter(u8::is_ascii_digit).eq(right.bytes().filter(u8::is_ascii_digit))
}
fn similarity_score(left: &str, right: &str) -> u16 {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let score = (strsim::jaro_winkler(left, right) * 100.0).round() as u16;
score.min(100)
}
#[derive(Clone, Copy, Debug)]
enum ChannelEpgAssignment {
Existing,
Exact { corrected: bool },
Fuzzy { corrected: bool },
Unresolved,
}
#[derive(Debug, Default)]
struct EpgAssignmentStats {
live: usize,
existing: usize,
exact: usize,
fuzzy: usize,
corrected: usize,
unresolved: usize,
}
impl EpgAssignmentStats {
fn record(&mut self, assignment: ChannelEpgAssignment) {
self.live += 1;
match assignment {
ChannelEpgAssignment::Existing => self.existing += 1,
ChannelEpgAssignment::Exact { corrected } => {
self.exact += 1;
self.corrected += usize::from(corrected);
}
ChannelEpgAssignment::Fuzzy { corrected } => {
self.fuzzy += 1;
self.corrected += usize::from(corrected);
}
ChannelEpgAssignment::Unresolved => self.unresolved += 1,
}
}
}
fn assign_smart_epg_id(chan: &mut PlaylistItem, id_cache: &EpgIdCache) -> ChannelEpgAssignment {
let current_id_is_valid =
chan.header.epg_channel_id.as_deref().is_some_and(|epg_id| id_cache.contains_processed_epg_id(epg_id));
let current_id_is_incompatible = current_id_is_valid
&& chan
.header
.epg_channel_id
.as_deref()
.is_some_and(|epg_id| id_cache.channel_name_matches_epg_id(epg_id, &chan.header.name) == Some(false));
if current_id_is_valid && !current_id_is_incompatible {
return ChannelEpgAssignment::Existing;
}
let matched = id_cache
.matched_epg_id(&chan.header.name)
.or_else(|| chan.header.epg_channel_id.as_deref().and_then(|id| id_cache.matched_epg_id(id)));
let Some((new_id, kind)) = matched else {
return if current_id_is_valid { ChannelEpgAssignment::Existing } else { ChannelEpgAssignment::Unresolved };
};
let changes_id =
chan.header.epg_channel_id.as_deref().is_none_or(|current_id| !current_id.eq_ignore_ascii_case(&new_id));
if !changes_id {
return ChannelEpgAssignment::Existing;
2026-07-10 14:53:33 +02:00
}
trace!("Matched channel {} to epg {new_id:?}", chan.header.name);
chan.header.epg_channel_id = Some(new_id);
match kind {
SmartMatchKind::Exact => ChannelEpgAssignment::Exact { corrected: current_id_is_valid },
SmartMatchKind::Fuzzy => ChannelEpgAssignment::Fuzzy { corrected: current_id_is_valid },
}
}
/// Rewrites the channel's EPG id to the exact spelling of the matched guide channel.
///
/// Guide matching is ASCII case-insensitive, but the EPG db is keyed by the guide channel
/// id and looked up exactly by the playlist id. Adopting the guide spelling keeps the
/// playlist `tvg-id`/`epg_channel_id`, the XMLTV `<channel id>` and the db key identical.
fn adopt_guide_epg_id_case(chan: &mut PlaylistItem, guide_channels: &HashMap<Arc<str>, &EpgChannel>) {
let guide_id = chan.header.epg_channel_id.as_deref().and_then(|current_id| {
with_folded_epg_id(current_id, |folded| guide_channels.get(folded))
.filter(|guide| guide.id.as_ref() != current_id)
.map(|guide| Arc::clone(&guide.id))
});
if let Some(guide_id) = guide_id {
trace!("Adopted guide epg id casing {guide_id} for channel {}", chan.header.name);
chan.header.epg_channel_id = Some(guide_id);
}
}
fn assign_epg_icon(
chan: &mut PlaylistItem,
guide_channels: &HashMap<Arc<str>, &EpgChannel>,
icon_override_channels: &HashSet<Arc<str>>,
icon_assigned: &mut HashSet<Arc<str>>,
) {
let Some(epg_channel_id) = chan.header.epg_channel_id.as_ref() else {
return;
};
with_folded_epg_id(epg_channel_id, |folded_id| {
let needs_icon = icon_override_channels.contains(folded_id)
|| chan.header.logo.is_empty()
|| chan.header.logo_small.is_empty();
if icon_assigned.contains(folded_id) || !needs_icon {
return;
}
let Some(icon) =
guide_channels.get(folded_id).and_then(|guide| guide.icon.as_ref()).filter(|icon| !icon.is_empty())
else {
return;
};
icon_assigned.insert(folded_id.intern());
if icon_override_channels.contains(folded_id) || chan.header.logo.is_empty() {
trace!("Matched channel {} to epg icon {icon}", chan.header.name);
chan.header.logo = Arc::clone(icon);
}
if icon_override_channels.contains(folded_id) || chan.header.logo_small.is_empty() {
chan.header.logo_small = Arc::clone(icon);
}
});
}
fn is_live_epg_item(item: &PlaylistItem) -> bool {
item.header.xtream_cluster == XtreamCluster::Live && item.header.item_type.is_live()
}
fn has_processed_epg(item: &PlaylistItem, id_cache: &EpgIdCache) -> bool {
item.header.epg_channel_id.as_deref().is_some_and(|id| id_cache.contains_processed_epg_id(id))
}
fn assign_live_channel_epg(
channel: &mut PlaylistItem,
id_cache: &EpgIdCache,
guide_channels: &HashMap<Arc<str>, &EpgChannel>,
icon_override_channels: &HashSet<Arc<str>>,
icon_assigned: &mut HashSet<Arc<str>>,
stats: &mut EpgAssignmentStats,
options: EpgAssignOptions,
2026-08-30 21:08:30 +02:00
) {
if id_cache.smart_match_enabled {
stats.record(assign_smart_epg_id(channel, id_cache));
}
let has_epg = has_processed_epg(channel, id_cache);
if has_epg && options.adopt_guide_id_case {
adopt_guide_epg_id_case(channel, guide_channels);
}
assign_epg_icon(channel, guide_channels, icon_override_channels, icon_assigned);
if options.clear_invalid_epg_ids && !has_epg {
2026-08-30 21:08:30 +02:00
channel.header.epg_channel_id = None;
}
}
fn referenced_live_epg_ids(fp: &mut FetchedPlaylist<'_>) -> HashSet<Arc<str>> {
fp.items()
.filter(|channel| is_live_epg_item(channel))
.filter_map(|channel| {
channel.header.epg_channel_id.as_ref().map(|id| with_folded_epg_id(id, |folded| folded.intern()))
})
.collect()
2025-04-23 20:21:36 +02:00
}
2026-08-30 21:08:30 +02:00
pub(crate) fn retain_epg_referenced_by_groups(groups: &[PlaylistGroup], epg: &mut [Epg]) {
let referenced_epg_ids = groups
.iter()
.flat_map(|group| &group.channels)
.filter(|channel| is_live_epg_item(channel))
.filter_map(|channel| {
channel.header.epg_channel_id.as_ref().map(|id| with_folded_epg_id(id, |folded| folded.intern()))
})
.collect::<HashSet<_>>();
for source in epg {
source.children.retain(|channel| with_folded_epg_id(&channel.id, |folded| referenced_epg_ids.contains(folded)));
}
}
pub(crate) fn clear_invalid_live_epg_ids(fp: &mut FetchedPlaylist<'_>, epg: &[Epg]) {
let processed_epg_ids = epg
.iter()
.flat_map(|source| &source.children)
.map(|channel| with_folded_epg_id(&channel.id, |folded| folded.intern()))
.collect::<HashSet<_>>();
2026-08-30 21:08:30 +02:00
let mut cleared = 0usize;
fp.source.retain_memory_items_mut(|item| {
if !is_live_epg_item(item) {
return true;
}
let has_epg = item
.header
.epg_channel_id
.as_deref()
.is_some_and(|id| with_folded_epg_id(id, |folded| processed_epg_ids.contains(folded)));
2026-08-30 21:08:30 +02:00
if !has_epg && item.header.epg_channel_id.take().is_some() {
cleared += 1;
}
2026-08-30 21:08:30 +02:00
true
});
2026-08-30 21:08:30 +02:00
if cleared > 0 {
debug!("Cleared {cleared} unmatched EPG IDs from input '{}'", fp.input.name);
}
}
/// Re-adopts the guide id spelling for live channels whose ids were rewritten after EPG assignment,
/// e.g. by `AfterEpg` mappings, so the playlist id stays identical to the processed guide channel id.
pub(crate) fn adopt_guide_live_epg_id_case(fp: &mut FetchedPlaylist<'_>, epg: &[Epg]) {
if !fp.is_memory() {
return;
}
let guide_channels = epg
.iter()
.flat_map(|source| &source.children)
.map(|guide| (with_folded_epg_id(&guide.id, |folded| folded.intern()), guide.as_ref()))
.collect::<HashMap<Arc<str>, &EpgChannel>>();
if guide_channels.is_empty() {
return;
}
fp.items_mut()
.filter(|item| is_live_epg_item(item))
.for_each(|item| adopt_guide_epg_id_case(item, &guide_channels));
}
2025-04-25 14:19:28 +00:00
/// Assigns EPG IDs and logos to live playlist channels by matching them with EPG data.
2026-08-30 21:08:30 +02:00
/// Invalid EPG IDs are optionally cleared without removing playlist entries.
2025-04-25 14:19:28 +00:00
///
/// For each live channel in the playlist missing an EPG ID, attempts to assign one using normalized name matching if smart matching is enabled. If a channel has an EPG ID but lacks logos, assigns logos from the corresponding EPG icon tags. Adds the matched EPG data to the provided vector.
///
/// # Examples
///
2026-08-27 18:36:23 +05:00
/// ```text
2025-04-25 14:19:28 +00:00
/// let mut new_epg = Vec::new();
/// let mut playlist = FetchedPlaylist::default();
/// let mut id_cache = EpgIdCache::new(None);
/// assign_channel_epg(&mut new_epg, &mut playlist, &mut id_cache, false);
2025-04-25 14:19:28 +00:00
/// ```
async fn assign_channel_epg(
new_epg: &mut Vec<Epg>,
fp: &mut FetchedPlaylist<'_>,
id_cache: &mut EpgIdCache,
options: EpgAssignOptions,
) {
let Some(tv_guide) = &fp.epg else {
return;
};
let mut merged_epg = tv_guide.filter_merged_with_icon_overrides(id_cache).await;
let mut stats = EpgAssignmentStats::default();
if fp.is_memory() {
let guide_channels = merged_epg
.as_ref()
.into_iter()
.flat_map(|(epg_source, _)| &epg_source.children)
.map(|guide| (with_folded_epg_id(&guide.id, |folded| folded.intern()), guide.as_ref()))
.collect::<HashMap<Arc<str>, &EpgChannel>>();
let icon_override_channels = merged_epg
.as_ref()
.into_iter()
.flat_map(|(_, channels)| channels)
.map(|id| with_folded_epg_id(id, |folded| folded.intern()))
.collect::<HashSet<_>>();
let mut icon_assigned = HashSet::with_capacity(guide_channels.len());
let mut process_channel = |channel: &mut PlaylistItem| {
if !is_live_epg_item(channel) {
2026-08-30 21:08:30 +02:00
return;
}
2026-08-30 21:08:30 +02:00
assign_live_channel_epg(
channel,
id_cache,
&guide_channels,
&icon_override_channels,
&mut icon_assigned,
&mut stats,
options,
);
};
2025-04-23 20:21:36 +02:00
2026-08-30 21:08:30 +02:00
fp.items_mut().for_each(&mut process_channel);
} else {
warn!("Disk based playlist modification is not supported!");
}
if id_cache.smart_match_enabled {
debug!(
"Smart EPG summary for input '{}': live={}, existing={}, exact={}, fuzzy={}, corrected={}, unresolved={}",
fp.input.name, stats.live, stats.existing, stats.exact, stats.fuzzy, stats.corrected, stats.unresolved
);
}
if let Some((mut epg_source, _)) = merged_epg.take() {
let referenced_epg_ids = referenced_live_epg_ids(fp);
epg_source
.children
.retain(|channel| with_folded_epg_id(&channel.id, |folded| referenced_epg_ids.contains(folded)));
new_epg.push(epg_source);
2025-04-23 20:21:36 +02:00
}
}
2025-04-25 14:19:28 +00:00
/// Processes a fetched playlist and assigns EPG data to its channels.
///
/// Collects EPG channel IDs from the playlist, initializes an EPG ID cache, and assigns EPG data to channels using normalization and smart matching if enabled. Logs a debug message if no EPG IDs are found and smart matching is disabled.
///
/// # Examples
///
2026-08-27 18:36:23 +05:00
/// ```text
2025-04-25 14:19:28 +00:00
/// let mut playlist = FetchedPlaylist::default();
/// let mut epg_data = Vec::new();
/// process_playlist_epg(&mut playlist, &mut epg_data, false);
2025-04-25 14:19:28 +00:00
/// ```
pub async fn process_playlist_epg(fp: &mut FetchedPlaylist<'_>, epg: &mut Vec<Epg>, options: EpgAssignOptions) {
if fp.input.epg.is_none() {
return;
}
2025-04-23 20:21:36 +02:00
// collect all epg_channel ids
let mut id_cache = EpgIdCache::new(fp.input.epg.as_ref());
id_cache.collect_epg_id(fp);
if id_cache.is_empty() && !id_cache.smart_match_enabled && !options.clear_invalid_epg_ids {
debug!("No epg ids found for input {}", fp.input.name);
2025-04-23 20:21:36 +02:00
} else {
assign_channel_epg(epg, fp, &mut id_cache, options).await;
2025-04-23 20:21:36 +02:00
}
}
#[cfg(test)]
mod tests {
2026-08-27 18:36:23 +05:00
use crate::{fetched_playlist::FetchedPlaylist, parser::xmltv::TVGuide};
use rand::{distr::Alphanumeric, Rng};
2025-04-23 20:21:36 +02:00
use rphonetic::{DoubleMetaphone, Encoder};
use shared::{
model::{ConfigInputDto, PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType},
utils::Internable,
};
2026-08-30 21:08:30 +02:00
use std::{
collections::{HashMap, HashSet},
fs,
sync::Arc,
};
use tempfile::tempdir;
2025-04-23 20:21:36 +02:00
use tokio::time::Instant;
2026-08-27 18:36:23 +05:00
use tuliprox_core::model::{
ConfigInput, EpgConfig, EpgSmartMatchConfig, IcsEpgSourceConfig, PersistedEpgSource, PersistedEpgSourceKind,
};
use tuliprox_repository::MemoryPlaylistSource;
2025-04-23 20:21:36 +02:00
fn random_string() -> String { rand::rng().sample_iter(&Alphanumeric).take(30).map(char::from).collect() }
2025-04-23 20:21:36 +02:00
2026-07-10 14:53:33 +02:00
fn write_ics_file(path: &std::path::Path) {
fs::write(
path,
"BEGIN:VCALENDAR\nBEGIN:VEVENT\nSUMMARY:Practice 1\nDTSTART:20260306T123000Z\nDTEND:20260306T133000Z\nEND:VEVENT\nEND:VCALENDAR",
)
.expect("write ics");
}
fn ics_tv_guide(path: std::path::PathBuf, match_names: Vec<Arc<str>>) -> TVGuide {
TVGuide::new(vec![PersistedEpgSource {
file_path: path,
priority: 0,
logo_override: false,
kind: PersistedEpgSourceKind::Ics {
channel_id: "f1.calendar".intern(),
channel_title: Some("Formula 1".intern()),
match_names,
config: Box::new(IcsEpgSourceConfig::default()),
},
}])
}
fn live_playlist_item(name: &str, epg_channel_id: Option<&str>) -> shared::model::PlaylistItem {
shared::model::PlaylistItem {
header: PlaylistItemHeader {
name: name.intern(),
epg_channel_id: epg_channel_id.map(Internable::intern),
xtream_cluster: super::XtreamCluster::Live,
item_type: PlaylistItemType::Live,
..PlaylistItemHeader::default()
},
}
}
fn smart_cache(match_threshold: u16, best_match_threshold: u16) -> super::EpgIdCache {
let mut dto = shared::model::EpgSmartMatchConfigDto {
enabled: true,
fuzzy_matching: true,
match_threshold,
best_match_threshold,
..shared::model::EpgSmartMatchConfigDto::default()
};
dto.prepare().expect("valid smart-match config");
let config = EpgConfig { sources: vec![], smart_match: Some(EpgSmartMatchConfig::from(dto)) };
super::EpgIdCache::new(Some(&config))
}
fn smart_cache_with_country_suffix() -> super::EpgIdCache {
let mut dto = shared::model::EpgSmartMatchConfigDto {
enabled: true,
fuzzy_matching: true,
name_prefix: shared::model::EpgNamePrefix::Suffix(".".to_string()),
..shared::model::EpgSmartMatchConfigDto::default()
};
dto.prepare().expect("valid smart-match config");
let config = EpgConfig { sources: vec![], smart_match: Some(EpgSmartMatchConfig::from(dto)) };
super::EpgIdCache::new(Some(&config))
}
async fn run_smart_xmltv_match(
xmltv: &str,
channel_name: &str,
current_epg_id: Option<&str>,
2026-08-27 18:36:23 +05:00
) -> (Option<Arc<str>>, Vec<tuliprox_core::model::Epg>) {
let (mut assigned_ids, epg) = run_smart_xmltv_matches(xmltv, &[(channel_name, current_epg_id)]).await;
(assigned_ids.pop().flatten(), epg)
}
async fn run_smart_xmltv_matches(
xmltv: &str,
channels: &[(&str, Option<&str>)],
2026-08-27 18:36:23 +05:00
) -> (Vec<Option<Arc<str>>>, Vec<tuliprox_core::model::Epg>) {
run_xmltv_matches(xmltv, channels, true, false).await
}
async fn run_xmltv_matches(
xmltv: &str,
channels: &[(&str, Option<&str>)],
smart_matching: bool,
2026-08-30 21:08:30 +02:00
clear_invalid_epg_ids: bool,
2026-08-27 18:36:23 +05:00
) -> (Vec<Option<Arc<str>>>, Vec<tuliprox_core::model::Epg>) {
let dir = tempdir().unwrap();
let epg_path = dir.path().join("smart-match.xml");
fs::write(&epg_path, xmltv).unwrap();
let smart_match = smart_matching.then(|| {
let mut dto = shared::model::EpgSmartMatchConfigDto {
enabled: true,
fuzzy_matching: true,
..shared::model::EpgSmartMatchConfigDto::default()
};
dto.prepare().expect("smart config");
EpgSmartMatchConfig::from(dto)
});
let mut input = ConfigInput::from(ConfigInputDto::default());
input.epg = Some(EpgConfig { sources: vec![], smart_match });
let groups = vec![PlaylistGroup {
id: 1,
title: "Live".intern(),
channels: channels.iter().map(|(name, epg_id)| live_playlist_item(name, *epg_id)).collect(),
xtream_cluster: super::XtreamCluster::Live,
}];
let tv_guide = TVGuide::new(vec![PersistedEpgSource {
file_path: epg_path,
priority: 0,
logo_override: false,
kind: PersistedEpgSourceKind::Xmltv,
}]);
let mut playlist = FetchedPlaylist {
input: &input,
source: MemoryPlaylistSource::new(groups).into_source(),
epg: Some(tv_guide),
};
let mut epg = Vec::new();
let options = super::EpgAssignOptions { clear_invalid_epg_ids, ..super::EpgAssignOptions::default() };
super::process_playlist_epg(&mut playlist, &mut epg, options).await;
let assigned_ids = playlist.items_mut().map(|item| item.header.epg_channel_id.clone()).collect();
(assigned_ids, epg)
}
#[test]
fn smart_match_prefers_exact_matches_from_higher_priority_sources() {
let mut cache = smart_cache(80, 95);
cache.insert_normalized("TF1", None);
let candidates = cache.normalize_candidates(["TF1.fr", "TF1"]);
assert!(cache.match_epg_channel_candidates(&"tf1.low".intern(), &candidates, 10, 1));
assert!(cache.match_epg_channel_candidates(&"tf1.high".intern(), &candidates, 0, 2));
assert!(cache.match_epg_channel_candidates(&"tf1.lower".intern(), &candidates, 20, 0));
cache.finalize_matches(&HashSet::from(["tf1.low".intern(), "tf1.high".intern(), "tf1.lower".intern()]));
assert_eq!(cache.normalized.get("tf1").and_then(Option::as_deref), Some("tf1.high"));
}
#[test]
fn smart_match_replaces_an_earlier_fuzzy_candidate_with_a_better_score() {
let mut cache = smart_cache(70, 70);
cache.insert_normalized("RMC Decouverte", None);
let key = cache.normalize("RMC Decouverte").intern();
let weaker = cache.normalize_candidates(["RMC Decouver"]);
let stronger = cache.normalize_candidates(["RMC Decouvert"]);
assert!(super::similarity_score(&key, &weaker[0]) < super::similarity_score(&key, &stronger[0]));
assert!(cache.match_epg_channel_candidates(&"rmc.weak".intern(), &weaker, 0, 0));
assert!(cache.match_epg_channel_candidates(&"rmc.strong".intern(), &stronger, 0, 0));
assert_eq!(cache.normalized.get(&key).and_then(Option::as_deref), Some("rmc.strong"));
}
#[test]
fn smart_match_rejects_conflicting_channel_numbers() {
let mut cache = smart_cache(70, 70);
cache.insert_normalized("TF1", None);
let candidates = cache.normalize_candidates(["TF1+1"]);
assert!(!cache.match_epg_channel_candidates(&"tf1-plus-one.fr".intern(), &candidates, 0, 0));
assert_eq!(cache.normalized.get("tf1").and_then(Option::as_deref), None);
}
#[test]
fn smart_match_rejects_ambiguous_equal_scores() {
let cache = smart_cache(70, 95);
let guide = vec!["abcc".intern()];
let left = "abca".intern();
let right = "abcb".intern();
assert_eq!(super::similarity_score(&left, &guide[0]), super::similarity_score(&right, &guide[0]));
assert!(cache.select_unambiguous_candidate(&guide, [&left, &right], 70, None).is_none());
}
#[test]
fn smart_match_country_fallback_prefers_the_full_country_match() {
let mut cache = smart_cache_with_country_suffix();
cache.insert_normalized("FR|TF1", None);
let french = cache.normalize_candidates(["TF1.fr", "TF1"]);
let belgian = cache.normalize_candidates(["TF1.be", "TF1"]);
assert!(cache.match_epg_channel_candidates(&"TF1.fr".intern(), &french, 0, 0));
assert!(!cache.match_epg_channel_candidates(&"TF1.be".intern(), &belgian, 0, 0));
cache.finalize_matches(&HashSet::from(["tf1.fr".intern(), "tf1.be".intern()]));
assert_eq!(cache.normalized.get("tf1.fr").and_then(Option::as_deref), Some("TF1.fr"));
}
#[test]
fn smart_match_rejects_a_display_country_conflicting_with_the_epg_id() {
let mut cache = smart_cache_with_country_suffix();
cache.insert_normalized("FR|COMEDIE+", None);
let candidates = cache.normalize_candidates(["ComediePlus.mu", "FR|COMEDIE+"]);
assert!(!cache.match_epg_channel_candidates(&"ComediePlus.mu".intern(), &candidates, 0, 0));
assert_eq!(cache.normalized.get("comedie.fr").and_then(Option::as_deref), None);
}
#[test]
fn smart_match_rejects_ambiguous_countryless_fallbacks() {
let mut cache = smart_cache_with_country_suffix();
cache.insert_normalized("FR|TF1", None);
let display_name = cache.normalize_candidates(["TF1"]);
assert!(cache.match_epg_channel_candidates(&"opaque-a".intern(), &display_name, 0, 0));
assert!(cache.match_epg_channel_candidates(&"opaque-b".intern(), &display_name, 0, 0));
cache.finalize_matches(&HashSet::from(["opaque-a".intern(), "opaque-b".intern()]));
assert_eq!(cache.normalized.get("tf1.fr").and_then(Option::as_deref), None);
}
#[test]
fn selected_epg_ids_exclude_unused_smart_matches() {
let mut cache = smart_cache(80, 95);
let valid_id = "tf1.fr".intern();
cache.insert_channel_epg_id(&valid_id);
cache.normalize_and_store("TF1", Some(&valid_id));
cache.normalize_and_store("France 2", None);
let tf1_candidates = cache.normalize_candidates(["TF1"]);
let france2_candidates = cache.normalize_candidates(["France 2"]);
assert!(cache.match_epg_channel_candidates(&"tf1.unused".intern(), &tf1_candidates, 0, 0));
assert!(cache.match_epg_channel_candidates(&"france2.fr".intern(), &france2_candidates, 0, 0));
let available = HashSet::from([valid_id.clone(), "tf1.unused".intern(), "france2.fr".intern()]);
cache.finalize_matches(&available);
let selected = cache.selected_epg_ids(&available);
assert!(selected.contains(&valid_id));
assert!(selected.contains("france2.fr"));
assert!(!selected.contains("tf1.unused"));
}
#[test]
fn guide_names_ignore_empty_normalized_values_and_unreferenced_ids() {
let mut cache = smart_cache(80, 95);
cache.insert_channel_epg_id("direct.fr");
assert!(cache.needs_guide_names("DIRECT.FR"));
assert!(!cache.needs_guide_names("unreferenced.fr"));
cache.register_guide_names("direct.fr", [" "]);
assert_eq!(cache.channel_name_matches_epg_id("direct.fr", "Direct"), None);
}
#[test]
fn smart_match_ignores_decorative_playlist_entries() {
let mut cache = smart_cache(80, 95);
let input = ConfigInput::from(ConfigInputDto::default());
let groups = vec![PlaylistGroup {
id: 1,
title: "Live".intern(),
channels: vec![live_playlist_item("▬▬ Manga ▬▬", None)],
xtream_cluster: super::XtreamCluster::Live,
}];
let mut playlist =
FetchedPlaylist { input: &input, source: MemoryPlaylistSource::new(groups).into_source(), epg: None };
cache.collect_epg_id(&mut playlist);
assert!(cache.normalized.is_empty());
assert!(super::is_decorative_channel_name("▬▬ Manga ▬▬"));
assert!(super::is_decorative_channel_name("----"));
assert!(!super::is_decorative_channel_name("|Channel|"));
assert!(!super::is_decorative_channel_name(""));
}
#[test]
fn channel_epg_id_membership_is_case_insensitive_ascii() {
let mut cache = super::EpgIdCache::new(None);
// Playlist epg ids stored MixedCase from different origins (Xtream source /
// mapper literal). Both go through the folding insert.
cache.insert_channel_epg_id("CNN.us");
cache.insert_channel_epg_id("BBC.One.UK");
// A guide <channel id> in any case matches the folded membership key.
assert!(cache.contains_channel_epg_id("cnn.US"));
assert!(cache.contains_channel_epg_id("CNN.US"));
assert!(cache.contains_channel_epg_id("cnn.us"));
assert!(cache.contains_channel_epg_id("bbc.one.uk"));
assert!(!cache.contains_channel_epg_id("unknown.tv"));
}
#[test]
fn assign_channel_epg_uses_case_insensitive_processed_and_icon_lookup() {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async move {
let dir = tempdir().unwrap();
let epg_path = dir.path().join("mixed-case-icon.xml");
fs::write(
&epg_path,
r#"<?xml version="1.0" encoding="UTF-8"?>
<tv>
<channel id="Demo.Channel">
<display-name>Demo</display-name>
<icon src="http://guide/icon.png" />
</channel>
<programme start="20260425000000 +0000" stop="20260425010000 +0000" channel="Demo.Channel">
<title>Demo Show</title>
</programme>
</tv>"#,
)
.unwrap();
let mut input = ConfigInput::from(ConfigInputDto::default());
input.epg = Some(EpgConfig { sources: vec![], smart_match: None });
let channel = shared::model::PlaylistItem {
header: PlaylistItemHeader {
name: "Demo".intern(),
epg_channel_id: Some("demo.channel".intern()),
logo: "http://old/icon.png".intern(),
logo_small: "".intern(),
xtream_cluster: super::XtreamCluster::Live,
item_type: PlaylistItemType::Live,
..PlaylistItemHeader::default()
},
};
let groups = vec![PlaylistGroup {
id: 1,
title: "Live".intern(),
channels: vec![channel],
xtream_cluster: super::XtreamCluster::Live,
}];
let tv_guide = TVGuide::new(vec![PersistedEpgSource {
file_path: epg_path,
priority: 0,
logo_override: true,
2026-07-10 14:53:33 +02:00
kind: PersistedEpgSourceKind::Xmltv,
}]);
let mut playlist = FetchedPlaylist {
input: &input,
source: MemoryPlaylistSource::new(groups).into_source(),
epg: Some(tv_guide),
};
let mut epg = Vec::new();
super::process_playlist_epg(
&mut playlist,
&mut epg,
super::EpgAssignOptions { clear_invalid_epg_ids: false, ..super::EpgAssignOptions::default() },
)
.await;
let updated = playlist.items_mut().next().unwrap();
assert_eq!(updated.header.epg_channel_id.as_deref(), Some("Demo.Channel"));
2026-09-26 17:03:09 +02:00
assert_eq!(updated.header.logo.as_ref(), "http://guide/icon.png");
assert_eq!(updated.header.logo_small.as_ref(), "http://guide/icon.png");
assert_eq!(epg[0].children[0].id.as_ref(), "Demo.Channel");
});
}
#[test]
fn matched_epg_ids_adopt_guide_channel_casing() {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async move {
let xmltv = r#"<tv>
<channel id="USA4K.us"><display-name>USA 4K</display-name></channel>
<programme start="20260425000000 +0000" stop="20260425010000 +0000" channel="USA4K.us">
<title>Show</title>
</programme>
</tv>"#;
let channels = [
("USA 4K", Some("usa4k.us")),
("USA 4K HD", Some("USA4K.US")),
("Already exact", Some("USA4K.us")),
("Unknown", Some("unknown.id")),
];
for smart_matching in [false, true] {
let (assigned_ids, epg) = run_xmltv_matches(xmltv, &channels, smart_matching, false).await;
assert_eq!(epg[0].children[0].id.as_ref(), "USA4K.us");
assert_eq!(
assigned_ids.iter().map(Option::as_deref).collect::<Vec<_>>(),
vec![Some("USA4K.us"), Some("USA4K.us"), Some("USA4K.us"), Some("unknown.id")],
"smart_matching={smart_matching}"
);
}
});
}
#[test]
fn post_mapping_adoption_restores_guide_channel_casing() {
let input = ConfigInput::from(ConfigInputDto::default());
let groups = vec![PlaylistGroup {
id: 1,
title: "Live".intern(),
channels: vec![
live_playlist_item("USA 4K", Some("usa4k.us")),
live_playlist_item("Unknown", Some("unknown.id")),
],
xtream_cluster: super::XtreamCluster::Live,
}];
let mut playlist =
FetchedPlaylist { input: &input, source: MemoryPlaylistSource::new(groups).into_source(), epg: None };
let epg = vec![super::Epg {
priority: 0,
logo_override: false,
attributes: None,
children: vec![Arc::new(shared::model::EpgChannel::new("USA4K.us".intern()))],
}];
super::adopt_guide_live_epg_id_case(&mut playlist, &epg);
let ids = playlist.items_mut().map(|item| item.header.epg_channel_id.clone()).collect::<Vec<_>>();
assert_eq!(ids.iter().map(Option::as_deref).collect::<Vec<_>>(), vec![Some("USA4K.us"), Some("unknown.id")]);
}
#[test]
fn disabled_guide_casing_adoption_keeps_playlist_epg_ids() {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async move {
let dir = tempdir().unwrap();
let epg_path = dir.path().join("keep-case.xml");
fs::write(
&epg_path,
r#"<tv>
<channel id="USA4K.us"><display-name>USA 4K</display-name></channel>
<programme start="20260425000000 +0000" stop="20260425010000 +0000" channel="USA4K.us">
<title>Show</title>
</programme>
</tv>"#,
)
.unwrap();
let mut input = ConfigInput::from(ConfigInputDto::default());
input.epg = Some(EpgConfig { sources: vec![], smart_match: None });
let groups = vec![PlaylistGroup {
id: 1,
title: "Live".intern(),
channels: vec![live_playlist_item("USA 4K", Some("usa4k.us"))],
xtream_cluster: super::XtreamCluster::Live,
}];
let mut playlist = FetchedPlaylist {
input: &input,
source: MemoryPlaylistSource::new(groups).into_source(),
epg: Some(TVGuide::new(vec![PersistedEpgSource {
file_path: epg_path,
priority: 0,
logo_override: false,
kind: PersistedEpgSourceKind::Xmltv,
}])),
};
let mut epg = Vec::new();
let options = super::EpgAssignOptions { adopt_guide_id_case: false, ..super::EpgAssignOptions::default() };
super::process_playlist_epg(&mut playlist, &mut epg, options).await;
let updated = playlist.items_mut().next().unwrap();
assert_eq!(updated.header.epg_channel_id.as_deref(), Some("usa4k.us"));
assert_eq!(epg[0].children[0].id.as_ref(), "USA4K.us");
});
}
#[test]
2026-08-30 21:08:30 +02:00
fn clear_invalid_epg_ids_preserves_items_and_clears_only_unmatched_live_ids() {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async move {
let dir = tempdir().unwrap();
let epg_path = dir.path().join("required-epg.xml");
fs::write(
&epg_path,
r#"<tv>
<channel id="matched.live"><display-name>Matched Live</display-name></channel>
<programme start="20260425000000 +0000" stop="20260425010000 +0000" channel="matched.live">
<title>Programme</title>
</programme>
</tv>"#,
)
.unwrap();
let mut input = ConfigInput::from(ConfigInputDto::default());
input.epg = Some(EpgConfig { sources: vec![], smart_match: None });
let item = |name: &str, epg_id: Option<&str>, item_type: PlaylistItemType| PlaylistItem {
header: PlaylistItemHeader {
name: name.intern(),
epg_channel_id: epg_id.map(Internable::intern),
xtream_cluster: item_type.cluster(),
item_type,
..PlaylistItemHeader::default()
},
};
let groups = vec![
PlaylistGroup {
id: 1,
title: "Live".intern(),
channels: vec![
item("Matched Live", Some("matched.live"), PlaylistItemType::Live),
item("Unmatched Live", Some("missing.live"), PlaylistItemType::LiveHls),
],
xtream_cluster: super::XtreamCluster::Live,
},
PlaylistGroup {
id: 2,
title: "Non-Live".intern(),
channels: vec![
item("VOD", None, PlaylistItemType::Video),
item("Series", None, PlaylistItemType::Series),
item("Local VOD", None, PlaylistItemType::LocalVideo),
item("Local Series", None, PlaylistItemType::LocalSeries),
],
xtream_cluster: super::XtreamCluster::Video,
},
];
let tv_guide = TVGuide::new(vec![PersistedEpgSource {
file_path: epg_path,
priority: 0,
logo_override: false,
kind: PersistedEpgSourceKind::Xmltv,
}]);
let mut playlist = FetchedPlaylist {
input: &input,
source: MemoryPlaylistSource::new(groups).into_source(),
epg: Some(tv_guide),
};
let mut epg = Vec::new();
super::process_playlist_epg(
&mut playlist,
&mut epg,
super::EpgAssignOptions { clear_invalid_epg_ids: true, ..super::EpgAssignOptions::default() },
)
.await;
2026-08-30 21:08:30 +02:00
let items = playlist
.items_mut()
.map(|item| (item.header.name.clone(), item.header.epg_channel_id.clone()))
.collect::<HashMap<_, _>>();
assert_eq!(items.get("Matched Live").and_then(Option::as_deref), Some("matched.live"));
assert_eq!(items.get("Unmatched Live"), Some(&None));
assert!(items.contains_key("VOD"));
assert!(items.contains_key("Series"));
assert!(items.contains_key("Local VOD"));
assert!(items.contains_key("Local Series"));
});
}
#[test]
2026-08-30 21:08:30 +02:00
fn default_epg_processing_preserves_unmatched_existing_id() {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async move {
let (assigned_ids, _) = run_xmltv_matches(
r#"<tv>
<channel id="matched.live"><display-name>Matched Live</display-name></channel>
<programme start="20260425000000 +0000" stop="20260425010000 +0000" channel="matched.live">
<title>Programme</title>
</programme>
</tv>"#,
&[("Unmatched Live", Some("missing.live"))],
false,
false,
)
.await;
assert_eq!(assigned_ids, vec![Some("missing.live".intern())]);
});
}
#[test]
fn clear_invalid_epg_ids_is_ignored_without_a_materialized_epg_source() {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async move {
let mut input = ConfigInput::from(ConfigInputDto::default());
input.epg = Some(EpgConfig { sources: vec![], smart_match: None });
let groups = vec![PlaylistGroup {
id: 1,
title: "Live".intern(),
channels: vec![live_playlist_item("Unmatched Live", Some("missing.live"))],
xtream_cluster: super::XtreamCluster::Live,
}];
let mut playlist =
FetchedPlaylist { input: &input, source: MemoryPlaylistSource::new(groups).into_source(), epg: None };
super::process_playlist_epg(
&mut playlist,
&mut Vec::new(),
super::EpgAssignOptions { clear_invalid_epg_ids: true, ..super::EpgAssignOptions::default() },
)
.await;
assert_eq!(playlist.items_mut().count(), 1);
2026-08-30 21:08:30 +02:00
assert_eq!(
playlist.items_mut().next().and_then(|item| item.header.epg_channel_id.as_deref()),
Some("missing.live")
);
});
}
#[test]
fn optional_epg_preserves_existing_empty_groups() {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async move {
let dir = tempdir().unwrap();
let epg_path = dir.path().join("optional-epg-empty-group.xml");
fs::write(
&epg_path,
r#"<tv>
<channel id="matched.live"><display-name>Matched Live</display-name></channel>
<programme start="20260425000000 +0000" stop="20260425010000 +0000" channel="matched.live">
<title>Programme</title>
</programme>
</tv>"#,
)
.unwrap();
let mut input = ConfigInput::from(ConfigInputDto::default());
input.epg = Some(EpgConfig { sources: vec![], smart_match: None });
let groups = vec![
PlaylistGroup {
id: 1,
title: "Empty".intern(),
channels: vec![],
xtream_cluster: super::XtreamCluster::Live,
},
PlaylistGroup {
id: 2,
title: "Live".intern(),
channels: vec![live_playlist_item("Matched Live", Some("matched.live"))],
xtream_cluster: super::XtreamCluster::Live,
},
];
let tv_guide = TVGuide::new(vec![PersistedEpgSource {
file_path: epg_path,
priority: 0,
logo_override: false,
kind: PersistedEpgSourceKind::Xmltv,
}]);
let mut playlist = FetchedPlaylist {
input: &input,
source: MemoryPlaylistSource::new(groups).into_source(),
epg: Some(tv_guide),
};
super::process_playlist_epg(
&mut playlist,
&mut Vec::new(),
super::EpgAssignOptions { clear_invalid_epg_ids: false, ..super::EpgAssignOptions::default() },
)
.await;
assert_eq!(playlist.get_group_count(), 2);
});
}
#[test]
2026-08-30 21:08:30 +02:00
fn clear_invalid_epg_ids_preserves_live_items_without_ids() {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async move {
let dir = tempdir().unwrap();
let epg_path = dir.path().join("required-epg-no-ids.xml");
fs::write(&epg_path, "<tv></tv>").unwrap();
let mut input = ConfigInput::from(ConfigInputDto::default());
input.epg = Some(EpgConfig { sources: vec![], smart_match: None });
let groups = vec![PlaylistGroup {
id: 1,
title: "Live".intern(),
channels: vec![live_playlist_item("Live without ID", None)],
xtream_cluster: super::XtreamCluster::Live,
}];
let tv_guide = TVGuide::new(vec![PersistedEpgSource {
file_path: epg_path,
priority: 0,
logo_override: false,
kind: PersistedEpgSourceKind::Xmltv,
}]);
let mut playlist = FetchedPlaylist {
input: &input,
source: MemoryPlaylistSource::new(groups).into_source(),
epg: Some(tv_guide),
};
super::process_playlist_epg(
&mut playlist,
&mut Vec::new(),
super::EpgAssignOptions { clear_invalid_epg_ids: true, ..super::EpgAssignOptions::default() },
)
.await;
2026-08-30 21:08:30 +02:00
assert_eq!(playlist.items_mut().count(), 1);
assert_eq!(playlist.get_group_count(), 1);
});
}
#[test]
2026-08-30 21:08:30 +02:00
fn clear_invalid_epg_ids_keeps_ids_assigned_by_smart_matching() {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async move {
let (assigned_ids, epg) = run_xmltv_matches(
r#"<tv>
<channel id="tf1.fr"><display-name>TF1</display-name></channel>
<programme start="20260425000000 +0000" stop="20260425010000 +0000" channel="tf1.fr">
<title>Programme TF1</title>
</programme>
</tv>"#,
&[("TF1", None)],
true,
true,
)
.await;
assert_eq!(assigned_ids, vec![Some("tf1.fr".intern())]);
assert_eq!(epg[0].children.len(), 1);
assert_eq!(epg[0].children[0].id.as_ref(), "tf1.fr");
});
}
#[test]
fn smart_match_replaces_an_existing_id_that_has_no_programmes() {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async move {
let (assigned_id, epg) = run_smart_xmltv_match(
r#"<tv>
<channel id="empty.fr"><display-name>Unused feed</display-name></channel>
<channel id="tf1.fr"><display-name>TF1</display-name></channel>
<programme start="20260425000000 +0000" stop="20260425010000 +0000" channel="tf1.fr">
<title>Programme TF1</title>
</programme>
</tv>"#,
"TF1",
Some("empty.fr"),
)
.await;
assert_eq!(assigned_id.as_deref(), Some("tf1.fr"));
assert_eq!(epg[0].children.len(), 1);
assert_eq!(epg[0].children[0].id.as_ref(), "tf1.fr");
assert_eq!(epg[0].children[0].programmes.len(), 1);
});
}
#[test]
fn directly_referenced_empty_guides_are_retained_with_or_without_smart_matching() {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async move {
let xmltv = r#"<tv>
<channel id="empty.fr"><display-name>Empty channel</display-name></channel>
</tv>"#;
for smart_matching in [false, true] {
let (assigned_ids, epg) =
run_xmltv_matches(xmltv, &[("Empty channel", Some("empty.fr"))], smart_matching, false).await;
assert_eq!(assigned_ids, vec![Some("empty.fr".intern())]);
assert_eq!(epg[0].children.len(), 1);
assert_eq!(epg[0].children[0].id.as_ref(), "empty.fr");
assert!(epg[0].children[0].programmes.is_empty());
}
});
}
#[test]
fn smart_match_assigns_variants_when_a_direct_id_is_already_known() {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async move {
let (assigned_ids, epg) = run_smart_xmltv_matches(
r#"<tv>
<channel id="tmc.fr"><display-name>TMC HD</display-name></channel>
<programme start="20260425000000 +0000" stop="20260425010000 +0000" channel="tmc.fr">
<title>Programme TMC</title>
</programme>
</tv>"#,
&[("TMC HD", Some("tmc.fr")), ("TMC FHD", None), ("TMC SD", None)],
)
.await;
assert_eq!(assigned_ids, vec![Some("tmc.fr".intern()); 3]);
assert_eq!(epg[0].children.len(), 1);
assert_eq!(epg[0].children[0].programmes.len(), 1);
});
}
#[test]
fn smart_match_corrects_a_populated_but_semantically_wrong_id() {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async move {
let (assigned_id, epg) = run_smart_xmltv_match(
r#"<tv>
<channel id="cherie25.fr"><display-name>Cherie 25</display-name></channel>
<channel id="rmclife.fr"><display-name>RMC Life</display-name></channel>
<programme start="20260425000000 +0000" stop="20260425010000 +0000" channel="cherie25.fr">
<title>Programme Cherie 25</title>
</programme>
<programme start="20260425000000 +0000" stop="20260425010000 +0000" channel="rmclife.fr">
<title>Programme RMC Life</title>
</programme>
</tv>"#,
"RMC LIFE",
Some("cherie25.fr"),
)
.await;
assert_eq!(assigned_id.as_deref(), Some("rmclife.fr"));
assert_eq!(epg[0].children.len(), 1);
assert_eq!(epg[0].children[0].id.as_ref(), "rmclife.fr");
});
}
2026-07-10 14:53:33 +02:00
#[test]
fn playlist_item_with_ics_epg_channel_id_gets_ics_programmes() {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async move {
let dir = tempdir().unwrap();
let ics_path = dir.path().join("f1.ics");
write_ics_file(&ics_path);
let mut input = ConfigInput::from(ConfigInputDto::default());
input.epg = Some(EpgConfig { sources: vec![], smart_match: None });
let groups = vec![PlaylistGroup {
id: 1,
title: "Live".intern(),
channels: vec![live_playlist_item("Formula 1", Some("f1.calendar"))],
xtream_cluster: super::XtreamCluster::Live,
}];
let mut playlist = FetchedPlaylist {
input: &input,
source: MemoryPlaylistSource::new(groups).into_source(),
epg: Some(ics_tv_guide(ics_path, Vec::new())),
};
let mut epg = Vec::new();
super::process_playlist_epg(
&mut playlist,
&mut epg,
super::EpgAssignOptions { clear_invalid_epg_ids: false, ..super::EpgAssignOptions::default() },
)
.await;
2026-07-10 14:53:33 +02:00
assert_eq!(epg.len(), 1);
assert_eq!(epg[0].children[0].id.as_ref(), "f1.calendar");
assert_eq!(epg[0].children[0].programmes.len(), 1);
assert_eq!(epg[0].children[0].programmes[0].title.as_deref(), Some("Practice 1"));
});
}
#[test]
fn smart_match_uses_ics_channel_title_and_match_names() {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async move {
let dir = tempdir().unwrap();
let ics_path = dir.path().join("f1.ics");
write_ics_file(&ics_path);
let mut smart_dto = shared::model::EpgSmartMatchConfigDto {
enabled: true,
fuzzy_matching: false,
..shared::model::EpgSmartMatchConfigDto::default()
};
smart_dto.prepare().expect("smart config");
let mut input = ConfigInput::from(ConfigInputDto::default());
input.epg = Some(EpgConfig { sources: vec![], smart_match: Some(EpgSmartMatchConfig::from(smart_dto)) });
2026-07-10 14:53:33 +02:00
let groups = vec![PlaylistGroup {
id: 1,
title: "Live".intern(),
channels: vec![live_playlist_item("F1", None)],
xtream_cluster: super::XtreamCluster::Live,
}];
let mut playlist = FetchedPlaylist {
input: &input,
source: MemoryPlaylistSource::new(groups).into_source(),
epg: Some(ics_tv_guide(ics_path, vec!["F1".intern()])),
};
let mut epg = Vec::new();
super::process_playlist_epg(
&mut playlist,
&mut epg,
super::EpgAssignOptions { clear_invalid_epg_ids: false, ..super::EpgAssignOptions::default() },
)
.await;
2026-07-10 14:53:33 +02:00
let updated = playlist.items_mut().next().unwrap();
assert_eq!(updated.header.epg_channel_id.as_deref(), Some("f1.calendar"));
assert_eq!(epg[0].children[0].id.as_ref(), "f1.calendar");
assert_eq!(epg[0].children[0].programmes.len(), 1);
});
}
2025-04-23 20:21:36 +02:00
#[test]
fn test_phonetic() {
let strings: Vec<String> = (0..5_000).map(|_| random_string()).collect();
2025-04-23 20:21:36 +02:00
let phonetic = DoubleMetaphone::new(Some(6));
let now = Instant::now();
for value in &strings {
let _ = phonetic.encode(value);
}
let elapsed = now.elapsed();
println!("Elapsed time: {}.{:03} secs", elapsed.as_secs(), elapsed.subsec_millis());
}
2026-05-01 20:06:29 +02:00
}