2026-01-29 18:08:22 +01:00
use crate ::model ::{ Epg , TVGuide };
2025-05-04 10:59:08 +02:00
use crate ::model ::{ EpgConfig , EpgSmartMatchConfig };
2026-01-08 15:11:12 +01:00
use crate ::model ::FetchedPlaylist ;
2025-04-23 20:21:36 +02:00
use crate ::processing ::parser ::xmltv ::normalize_channel_name ;
2026-01-08 15:11:12 +01:00
use log ::{ debug , trace , warn };
2025-04-23 20:21:36 +02:00
use rphonetic ::{ DoubleMetaphone , Encoder };
use std ::collections ::{ HashMap , HashSet };
2026-01-29 18:08:22 +01:00
use shared ::model ::{ EpgChannel , EpgSmartMatchConfigDto , PlaylistItem , XtreamCluster };
2026-01-04 16:17:47 +01:00
use std ::sync ::Arc ;
2026-01-23 08:02:28 +01:00
use shared ::utils ::Internable ;
2025-06-19 12:45:55 +02:00
2026-01-23 08:02:28 +01:00
pub struct EpgIdCache {
pub channel_epg_id : HashSet < Arc < str >> ,
pub normalized : HashMap < Arc < str > , Option < Arc < str >>> ,
pub phonetics : 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 ,
2025-04-25 16:13:44 +02:00
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
}
2026-01-23 08:02:28 +01: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
///
/// ```
/// 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 (),
2025-04-25 16:13:44 +02:00
phonetics : 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-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
///
/// ```
/// 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")));
/// ```
2026-01-23 08:02:28 +01:00
fn normalize_and_store ( & mut self , name : & str , epg_id : Option <& Arc < str >> ) {
2025-08-18 20:37:47 +02:00
self . insert_normalized ( name );
2025-08-18 20:21:58 +02:00
if let Some ( chan_epg_id ) = epg_id {
2025-08-18 20:37:47 +02:00
self . insert_normalized ( chan_epg_id );
2025-08-18 20:21:58 +02:00
}
2025-04-23 20:21:36 +02:00
}
2025-08-18 20:37:47 +02:00
fn insert_normalized ( & mut self , key : & str ) {
2026-01-23 08:02:28 +01:00
let normalized = self . normalize ( key ). intern ();
2025-08-18 20:37:47 +02:00
let phonetic = self . phonetic ( & normalized );
2025-08-26 13:53:11 +02:00
self . normalized . insert ( normalized . clone (), None );
2025-08-18 20:37:47 +02:00
self . phonetics
2025-08-26 13:53:11 +02:00
. entry ( phonetic . clone ())
2025-08-18 20:37:47 +02:00
. or_default ()
. insert ( normalized );
}
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
///
/// ```
/// let cache = EpgIdCache::new(None);
/// let normalized = cache.normalize("HBO HD");
/// assert!(!normalized.is_empty());
/// ```
2025-04-23 20:21:36 +02:00
fn normalize ( & self , name : & str ) -> String {
normalize_channel_name ( name , & self . smart_match_config )
}
2026-01-23 08:02:28 +01:00
pub ( crate ) fn phonetic ( & self , name : & Arc < str > ) -> Arc < str > {
2025-08-18 20:21:58 +02:00
let result = self . metaphone . encode ( name );
if result . is_empty () {
2026-01-23 08:02:28 +01:00
name . clone ()
2025-08-18 20:21:58 +02:00
} else {
2026-01-23 08:02:28 +01:00
result . intern ()
2025-08-18 20:21:58 +02:00
}
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 ;
2026-01-08 15:11:12 +01:00
// Helper closure to process a single item
// We use a closure here to capture `self` and avoid code duplication
2026-01-23 08:02:28 +01:00
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
2026-01-08 15:11:12 +01:00
if let Some ( id ) = epg_channel_id {
2025-04-23 20:21:36 +02:00
if ! id . is_empty () {
missing_epg_id = false ;
2026-01-23 08:02:28 +01:00
self . channel_epg_id . insert ( Arc ::clone ( 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 );
if needs_normalization {
2026-01-08 15:11:12 +01:00
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 () {
2026-01-23 08:02:28 +01:00
process_item ( & channel . header . name , channel . header . epg_channel_id . as_ref ());
2025-04-23 20:21:36 +02:00
}
}
}
2026-01-23 08:02:28 +01:00
pub fn match_with_normalized ( & mut self , epg_id : & Arc < str > , normalized_epg_ids : & [ Arc < str > ]) -> bool {
2025-04-23 20:21:36 +02:00
for key in normalized_epg_ids {
if let Some ( entry ) = self . normalized . get_mut ( key ) {
2026-01-23 08:02:28 +01:00
entry . replace ( Arc ::clone ( epg_id ));
self . channel_epg_id . insert ( Arc ::clone ( epg_id ));
2025-04-23 20:21:36 +02:00
return true ;
}
}
false
}
}
2025-04-25 14:19:28 +00:00
/// Assigns EPG IDs and logos to live playlist channels by matching them with EPG data.
///
/// 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
///
/// ```
/// 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);
/// ```
2026-01-23 08:02:28 +01:00
async fn assign_channel_epg ( new_epg : & mut Vec < Epg > , fp : & mut FetchedPlaylist < '_ > , id_cache : & mut EpgIdCache ) {
2025-08-18 20:21:58 +02:00
//id_cache.normalized.retain(|_, v| v.is_some());
2025-04-23 20:21:36 +02:00
if let Some ( tv_guide ) = & fp . epg {
2025-05-16 08:59:52 +02:00
let mut processed_epgs = vec! [];
2025-10-01 14:40:44 +02:00
if let Some ( epg_sources ) = tv_guide . filter ( id_cache ). await {
2025-05-16 08:59:52 +02:00
let mut icon_assigned = HashSet ::new ();
for epg_source in epg_sources {
// icon tags
2026-01-29 18:08:22 +01:00
let icon_tags : HashMap <& Arc < str > , & Arc < EpgChannel >> = epg_source . children . iter ()
. filter ( | tag | tag . icon . as_ref (). is_some_and ( | i | ! i . is_empty ()))
. map ( | tag | ( & tag . id , tag ))
2025-07-22 11:32:46 +02:00
. collect ();
2025-04-23 20:21:36 +02:00
2025-05-16 08:59:52 +02:00
let assign_values = | chan : & mut PlaylistItem | {
2025-08-18 20:21:58 +02:00
if id_cache . smart_match_enabled {
// id_cache.processed contains the epg_ids from the xml epg file.
// if the channel has no epg_id or the epg_id is not present in xmltv/tvguide then we need to match one from existing tvguide
let not_found_in_epg = match & chan . header . epg_channel_id {
2025-05-16 08:59:52 +02:00
None => true ,
2026-01-20 10:09:58 +01:00
Some ( epg_id ) => ! id_cache . processed . contains ( &** epg_id ),
2025-05-16 08:59:52 +02:00
};
2025-08-18 20:21:58 +02:00
if not_found_in_epg {
let try_match = | key : & str | {
2026-01-23 08:02:28 +01:00
let normalized = id_cache . normalize ( key ). intern ();
2025-08-18 20:21:58 +02:00
id_cache . normalized . get ( & normalized ). and_then ( | epg_id | {
epg_id . as_ref (). map ( | id | {
trace! ( "Matched channel {} to epg {id:?}" , chan . header . name );
id . clone ()
})
})
};
if let Some ( new_id ) = try_match ( & chan . header . name )
. or_else ( || chan . header . epg_channel_id . as_deref (). and_then ( try_match ))
{
2026-01-23 08:02:28 +01:00
chan . header . epg_channel_id = Some ( new_id );
2025-05-16 08:59:52 +02:00
}
2025-04-23 20:21:36 +02:00
}
}
2025-05-16 08:59:52 +02:00
if let Some ( epg_channel_id ) = chan . header . epg_channel_id . as_ref () {
if ! icon_assigned . contains ( epg_channel_id ) &&
( epg_source . logo_override || chan . header . logo . is_empty () || chan . header . logo_small . is_empty ()) {
2026-01-23 08:02:28 +01:00
if let Some ( icon_tag ) = icon_tags . get ( epg_channel_id ) {
2026-01-29 18:08:22 +01:00
if let Some ( icon ) = icon_tag . icon . as_ref () {
2025-08-26 13:53:11 +02:00
icon_assigned . insert ( epg_channel_id . clone ());
2025-05-16 08:59:52 +02:00
if epg_source . logo_override || chan . header . logo . is_empty () {
2025-05-18 13:03:37 +02:00
trace! ( "Matched channel {} to epg icon {icon}" , chan . header . name );
2026-01-29 18:08:22 +01:00
chan . header . logo = Arc ::clone ( icon );
2025-05-16 08:59:52 +02:00
}
if epg_source . logo_override || chan . header . logo_small . is_empty () {
2026-01-29 18:08:22 +01:00
chan . header . logo_small = Arc ::clone ( icon );
2025-05-16 08:59:52 +02:00
}
}
2025-04-25 16:13:44 +02:00
}
}
}
2025-05-16 08:59:52 +02:00
};
2026-01-08 15:11:12 +01:00
let filter_live = | c : && mut PlaylistItem | c . header . xtream_cluster == XtreamCluster ::Live && c . header . item_type . is_live ();
if fp . is_memory () {
fp . items_mut (). filter ( filter_live ). for_each ( assign_values );
} else {
warn! ( "Disk based playlist modification is not supported!" );
}
2025-05-16 08:59:52 +02:00
processed_epgs . push ( epg_source );
}
}
2025-04-23 20:21:36 +02:00
2025-05-16 08:59:52 +02:00
if let Some ( epg ) = TVGuide ::merge ( processed_epgs ) {
2025-04-23 20:21:36 +02:00
new_epg . push ( epg );
}
}
}
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
///
/// ```
/// let mut playlist = FetchedPlaylist::default();
/// let mut epg_data = Vec::new();
/// process_playlist_epg(&mut playlist, &mut epg_data);
/// ```
2025-10-01 14:40:44 +02:00
pub async fn process_playlist_epg ( fp : & mut FetchedPlaylist < '_ > , epg : & mut Vec < Epg > ) {
2026-01-08 15:11:12 +01:00
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 );
2025-04-25 16:13:44 +02:00
if id_cache . is_empty () && ! id_cache . smart_match_enabled {
2026-01-25 16:32:35 +01:00
debug! ( "No epg ids found for input {}" , & fp . input . name );
2025-04-23 20:21:36 +02:00
} else {
2025-10-01 14:40:44 +02:00
assign_channel_epg ( epg , fp , & mut id_cache ). await ;
2025-04-23 20:21:36 +02:00
}
}
#[cfg(test)]
mod tests {
use rand ::distr ::Alphanumeric ;
use rand ::Rng ;
use rphonetic ::{ DoubleMetaphone , Encoder };
use tokio ::time ::Instant ;
fn random_string () -> String {
rand ::rng ()
. sample_iter ( & Alphanumeric )
. take ( 30 )
. map ( char ::from )
. collect ()
}
#[test]
fn test_phonetic () {
2025-04-25 13:42:13 +02:00
let strings : Vec < String > = ( 0 .. 5_000 )
2025-04-23 20:21:36 +02:00
. map ( | _ | random_string ())
. collect ();
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 ());
}
}