Skip to main content

crispy_iptv_tools/
unify.rs

1//! Config-driven title and ID unification.
2//!
3//! Faithfully ported from `iptvtools/utils.py::unify_title_and_id` and
4//! `iptvtools/config.py`. Loads a JSON configuration that maps old titles
5//! and IDs to canonical forms. Entries mapped to `""` are deleted.
6
7use std::collections::HashMap;
8
9use crispy_iptv_types::PlaylistEntry;
10use serde::{Deserialize, Serialize};
11
12use crate::error::ToolsError;
13
14/// Unification configuration loaded from JSON.
15///
16/// Each map entry represents `old_value → canonical_value`. If the
17/// canonical value is `""` (empty string), entries containing that
18/// old value are removed from the playlist.
19#[derive(Debug, Clone, Default, Serialize, Deserialize)]
20pub struct UnifyConfig {
21    /// Old ID substring → canonical ID replacement. `""` = delete entry.
22    #[serde(default)]
23    pub id_unifiers: HashMap<String, String>,
24
25    /// Old title substring → canonical title replacement. `""` = delete entry.
26    #[serde(default)]
27    pub title_unifiers: HashMap<String, String>,
28}
29
30/// Load a [`UnifyConfig`] from a JSON string.
31///
32/// The JSON is expected to have `id_unifiers` and/or `title_unifiers` keys,
33/// each mapping old substrings to canonical replacements.
34///
35/// # Errors
36///
37/// Returns `ToolsError::InvalidConfig` if the JSON is not valid.
38pub fn load_unify_config(json: &str) -> Result<UnifyConfig, ToolsError> {
39    serde_json::from_str(json).map_err(|e| ToolsError::InvalidConfig(e.to_string()))
40}
41
42/// Apply unification rules to a list of entries.
43///
44/// For each entry:
45/// 1. Title unifiers are applied (sorted by key, substring replacement).
46/// 2. If `tvg_name` is set it becomes the working ID; otherwise the
47///    (possibly-unified) title is used.
48/// 3. ID unifiers are applied (sorted by key, substring replacement).
49/// 4. If any replacement mapped to `""`, the entry is deleted.
50///
51/// Faithfully mirrors the Python logic in `unify_title_and_id()`.
52pub fn unify_entries(entries: &[PlaylistEntry], config: &UnifyConfig) -> Vec<PlaylistEntry> {
53    let mut title_keys: Vec<&String> = config.title_unifiers.keys().collect();
54    title_keys.sort();
55
56    let mut id_keys: Vec<&String> = config.id_unifiers.keys().collect();
57    id_keys.sort();
58
59    entries
60        .iter()
61        .filter_map(|entry| {
62            let mut entry = entry.clone();
63
64            // 1. Apply title unifiers.
65            if let Some(ref title) = entry.name {
66                let mut new_title = title.clone();
67                for key in &title_keys {
68                    if new_title.contains(key.as_str()) {
69                        let replacement = &config.title_unifiers[key.as_str()];
70                        new_title = new_title.replace(key.as_str(), replacement);
71                    }
72                }
73                // If any title unifier produced an empty title, delete entry.
74                if new_title.is_empty() && !title.is_empty() {
75                    return None;
76                }
77                entry.name = Some(new_title);
78            }
79
80            // 2. Derive working ID from tvg_name or title (matching Python logic).
81            let working_id = entry
82                .tvg_name
83                .clone()
84                .or_else(|| entry.name.clone())
85                .unwrap_or_default();
86
87            // 3. Apply ID unifiers.
88            let mut new_id = working_id;
89            for key in &id_keys {
90                if new_id.contains(key.as_str()) {
91                    let replacement = &config.id_unifiers[key.as_str()];
92                    new_id = new_id.replace(key.as_str(), replacement);
93                }
94            }
95
96            // If any ID unifier produced an empty ID from a non-empty input, delete entry.
97            if new_id.is_empty() && entry.tvg_id.is_some() {
98                return None;
99            }
100
101            // Update tvg_id with the unified ID.
102            if !new_id.is_empty() {
103                entry.tvg_id = Some(new_id);
104            }
105
106            Some(entry)
107        })
108        .collect()
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    fn make_entry(name: &str, tvg_id: &str, tvg_name: Option<&str>) -> PlaylistEntry {
116        PlaylistEntry {
117            name: Some(name.to_string()),
118            tvg_id: if tvg_id.is_empty() {
119                None
120            } else {
121                Some(tvg_id.to_string())
122            },
123            tvg_name: tvg_name.map(|s| s.to_string()),
124            ..Default::default()
125        }
126    }
127
128    #[test]
129    fn load_config_from_json() {
130        let json = r#"{
131            "id_unifiers": {"old_id": "new_id"},
132            "title_unifiers": {"Old Title": "New Title"}
133        }"#;
134        let config = load_unify_config(json).unwrap();
135        assert_eq!(
136            config.id_unifiers.get("old_id"),
137            Some(&"new_id".to_string())
138        );
139        assert_eq!(
140            config.title_unifiers.get("Old Title"),
141            Some(&"New Title".to_string())
142        );
143    }
144
145    #[test]
146    fn load_config_invalid_json_errors() {
147        assert!(load_unify_config("not json").is_err());
148    }
149
150    #[test]
151    fn unify_renames_ids() {
152        let entries = vec![make_entry("BBC One", "bbc_old", None)];
153        let config = UnifyConfig {
154            id_unifiers: HashMap::from([("bbc_old".to_string(), "bbc_one".to_string())]),
155            ..Default::default()
156        };
157        let result = unify_entries(&entries, &config);
158        assert_eq!(result.len(), 1);
159        // The working ID derives from the name (no tvg_name), so id_unifiers
160        // only apply to the name-derived ID. tvg_id is set from working ID.
161        // But since the working ID is "BBC One" (not "bbc_old"), the id_unifier
162        // won't match. Let's use tvg_name to test proper ID renaming.
163    }
164
165    #[test]
166    fn unify_renames_ids_via_tvg_name() {
167        let entries = vec![make_entry("BBC One", "", Some("bbc_old"))];
168        let config = UnifyConfig {
169            id_unifiers: HashMap::from([("bbc_old".to_string(), "bbc_one".to_string())]),
170            ..Default::default()
171        };
172        let result = unify_entries(&entries, &config);
173        assert_eq!(result.len(), 1);
174        assert_eq!(result[0].tvg_id.as_deref(), Some("bbc_one"));
175    }
176
177    #[test]
178    fn unify_renames_titles() {
179        let entries = vec![make_entry("BBC World News", "", None)];
180        let config = UnifyConfig {
181            title_unifiers: HashMap::from([("World News".to_string(), "Global".to_string())]),
182            ..Default::default()
183        };
184        let result = unify_entries(&entries, &config);
185        assert_eq!(result.len(), 1);
186        assert_eq!(result[0].name.as_deref(), Some("BBC Global"));
187    }
188
189    #[test]
190    fn unify_deletes_entries_mapped_to_empty_title() {
191        let entries = vec![
192            make_entry("Remove Me", "", None),
193            make_entry("Keep Me", "", None),
194        ];
195        let config = UnifyConfig {
196            title_unifiers: HashMap::from([("Remove Me".to_string(), String::new())]),
197            ..Default::default()
198        };
199        let result = unify_entries(&entries, &config);
200        assert_eq!(result.len(), 1);
201        assert_eq!(result[0].name.as_deref(), Some("Keep Me"));
202    }
203
204    #[test]
205    fn unify_empty_config_is_identity() {
206        let entries = vec![make_entry("BBC One", "bbc.uk", None)];
207        let config = UnifyConfig::default();
208        let result = unify_entries(&entries, &config);
209        assert_eq!(result.len(), 1);
210        assert_eq!(result[0].name.as_deref(), Some("BBC One"));
211    }
212}