1use std::sync::LazyLock;
6
7use crispy_iptv_types::{PlaylistEntry, Resolution};
8use regex::Regex;
9
10use crate::error::ToolsError;
11use crate::resolution::detect_resolution;
12
13static 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#[derive(Debug, Clone, Default)]
22pub struct EntryFilter {
23 pub min_resolution: Option<Resolution>,
25
26 pub groups: Option<Vec<String>>,
28
29 pub exclude_groups: Option<Vec<String>>,
31
32 pub name_pattern: Option<String>,
34
35 pub exclude_adult: bool,
37}
38
39pub 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
65fn 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 if let Some(min_res) = filter.min_resolution {
73 let detected = detect_resolution(name, url, &entry.extras);
74 if detected != Resolution::Unknown && detected < min_res {
76 return false;
77 }
78 }
79
80 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 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 if let Some(re) = name_regex
98 && !re.is_match(name)
99 {
100 return false;
101 }
102
103 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 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}