Skip to main content

crispy_iptv_tools/
filter.rs

1//! Playlist entry filtering.
2//!
3//! Filter entries by resolution, group, name pattern, and adult content.
4
5use std::sync::LazyLock;
6
7use crispy_iptv_types::{PlaylistEntry, Resolution};
8use regex::Regex;
9
10use crate::error::ToolsError;
11use crate::resolution::detect_resolution;
12
13/// Adult content group/name patterns (case-insensitive).
14/// Uses word boundaries where possible; `18+` uses a lookahead-style
15/// anchor since `+` is not a word character.
16static ADULT_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
17    Regex::new(r"(?i)(\bxxx\b|\badult\b|\bporn\b|18\+|\berotic\b|\bsex\b)").unwrap()
18});
19
20/// Configuration for filtering playlist entries.
21#[derive(Debug, Clone, Default)]
22pub struct EntryFilter {
23    /// Minimum resolution tier (entries below this are excluded).
24    pub min_resolution: Option<Resolution>,
25
26    /// Include only entries belonging to these groups.
27    pub groups: Option<Vec<String>>,
28
29    /// Exclude entries belonging to these groups.
30    pub exclude_groups: Option<Vec<String>>,
31
32    /// Regex pattern — only entries whose name matches are kept.
33    pub name_pattern: Option<String>,
34
35    /// If true, entries with adult-content indicators are excluded.
36    pub exclude_adult: bool,
37}
38
39/// Filter entries according to the given filter configuration.
40///
41/// Returns a new `Vec` containing only entries that pass all filter criteria.
42///
43/// # Errors
44///
45/// Returns `ToolsError::InvalidPattern` if `name_pattern` is not a valid regex.
46pub fn filter_entries(
47    entries: &[PlaylistEntry],
48    filter: &EntryFilter,
49) -> Result<Vec<PlaylistEntry>, ToolsError> {
50    let name_regex = filter
51        .name_pattern
52        .as_ref()
53        .map(|p| Regex::new(p))
54        .transpose()?;
55
56    let result = entries
57        .iter()
58        .filter(|entry| passes_filter(entry, filter, name_regex.as_ref()))
59        .cloned()
60        .collect();
61
62    Ok(result)
63}
64
65/// Check whether a single entry passes all filter criteria.
66fn passes_filter(entry: &PlaylistEntry, filter: &EntryFilter, name_regex: Option<&Regex>) -> bool {
67    let name = entry.name.as_deref().unwrap_or("");
68    let url = entry.url.as_deref().unwrap_or("");
69    let group = entry.group_title.as_deref().unwrap_or("");
70
71    // Resolution filter.
72    if let Some(min_res) = filter.min_resolution {
73        let detected = detect_resolution(name, url, &entry.extras);
74        // Unknown resolution passes (we can't confirm it's below minimum).
75        if detected != Resolution::Unknown && detected < min_res {
76            return false;
77        }
78    }
79
80    // Include-groups filter (case-insensitive).
81    if let Some(include) = &filter.groups {
82        let group_lower = group.to_lowercase();
83        if !include.iter().any(|g| g.to_lowercase() == group_lower) {
84            return false;
85        }
86    }
87
88    // Exclude-groups filter (case-insensitive).
89    if let Some(exclude) = &filter.exclude_groups {
90        let group_lower = group.to_lowercase();
91        if exclude.iter().any(|g| g.to_lowercase() == group_lower) {
92            return false;
93        }
94    }
95
96    // Name pattern filter.
97    if let Some(re) = name_regex
98        && !re.is_match(name)
99    {
100        return false;
101    }
102
103    // Adult content filter.
104    if filter.exclude_adult && (ADULT_PATTERN.is_match(name) || ADULT_PATTERN.is_match(group)) {
105        return false;
106    }
107
108    true
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    fn make_entry(name: &str, group: &str, url: &str) -> PlaylistEntry {
116        PlaylistEntry {
117            name: Some(name.to_string()),
118            group_title: Some(group.to_string()),
119            url: Some(url.to_string()),
120            ..Default::default()
121        }
122    }
123
124    #[test]
125    fn filter_by_resolution_keeps_hd_and_above() {
126        let entries = vec![
127            make_entry("BBC SD", "News", "http://a.com/sd"),
128            make_entry("CNN HD", "News", "http://a.com/hd"),
129            make_entry("Sky FHD", "Sports", "http://a.com/fhd"),
130            make_entry("Movie 4K", "Movies", "http://a.com/4k"),
131        ];
132        let filter = EntryFilter {
133            min_resolution: Some(Resolution::HD),
134            ..Default::default()
135        };
136        let result = filter_entries(&entries, &filter).unwrap();
137        assert_eq!(result.len(), 3);
138        assert!(result.iter().all(|e| {
139            let n = e.name.as_deref().unwrap();
140            n != "BBC SD"
141        }));
142    }
143
144    #[test]
145    fn filter_by_group_include() {
146        let entries = vec![
147            make_entry("A", "Sports", "http://a.com/1"),
148            make_entry("B", "News", "http://a.com/2"),
149            make_entry("C", "Sports", "http://a.com/3"),
150        ];
151        let filter = EntryFilter {
152            groups: Some(vec!["Sports".into()]),
153            ..Default::default()
154        };
155        let result = filter_entries(&entries, &filter).unwrap();
156        assert_eq!(result.len(), 2);
157    }
158
159    #[test]
160    fn filter_by_group_exclude() {
161        let entries = vec![
162            make_entry("A", "Sports", "http://a.com/1"),
163            make_entry("B", "News", "http://a.com/2"),
164            make_entry("C", "Movies", "http://a.com/3"),
165        ];
166        let filter = EntryFilter {
167            exclude_groups: Some(vec!["Sports".into()]),
168            ..Default::default()
169        };
170        let result = filter_entries(&entries, &filter).unwrap();
171        assert_eq!(result.len(), 2);
172        assert!(
173            result
174                .iter()
175                .all(|e| e.group_title.as_deref().unwrap() != "Sports")
176        );
177    }
178
179    #[test]
180    fn filter_by_name_pattern() {
181        let entries = vec![
182            make_entry("BBC One", "UK", "http://a.com/1"),
183            make_entry("CNN International", "US", "http://a.com/2"),
184            make_entry("BBC Two", "UK", "http://a.com/3"),
185        ];
186        let filter = EntryFilter {
187            name_pattern: Some("^BBC".into()),
188            ..Default::default()
189        };
190        let result = filter_entries(&entries, &filter).unwrap();
191        assert_eq!(result.len(), 2);
192        assert!(
193            result
194                .iter()
195                .all(|e| e.name.as_deref().unwrap().starts_with("BBC"))
196        );
197    }
198
199    #[test]
200    fn filter_invalid_regex_returns_error() {
201        let filter = EntryFilter {
202            name_pattern: Some("[invalid".into()),
203            ..Default::default()
204        };
205        assert!(filter_entries(&[], &filter).is_err());
206    }
207
208    #[test]
209    fn filter_exclude_adult() {
210        let entries = vec![
211            make_entry("BBC One", "News", "http://a.com/1"),
212            make_entry("XXX Channel", "Adult", "http://a.com/2"),
213            make_entry("Movie", "18+", "http://a.com/3"),
214            make_entry("Sports", "Sports", "http://a.com/4"),
215        ];
216        let filter = EntryFilter {
217            exclude_adult: true,
218            ..Default::default()
219        };
220        let result = filter_entries(&entries, &filter).unwrap();
221        assert_eq!(result.len(), 2);
222    }
223
224    #[test]
225    fn filter_group_case_insensitive() {
226        let entries = vec![make_entry("A", "SPORTS", "http://a.com/1")];
227        let filter = EntryFilter {
228            groups: Some(vec!["sports".into()]),
229            ..Default::default()
230        };
231        let result = filter_entries(&entries, &filter).unwrap();
232        assert_eq!(result.len(), 1);
233    }
234
235    #[test]
236    fn filter_unknown_resolution_passes() {
237        // Entry with no resolution hint should pass min_resolution filter.
238        let entries = vec![make_entry("Plain Channel", "News", "http://a.com/1")];
239        let filter = EntryFilter {
240            min_resolution: Some(Resolution::HD),
241            ..Default::default()
242        };
243        let result = filter_entries(&entries, &filter).unwrap();
244        assert_eq!(result.len(), 1);
245    }
246}