1use std::cmp::Ordering;
7
8use crispy_iptv_types::PlaylistEntry;
9
10use crate::resolution::detect_resolution;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum SortCriteria {
15 Name,
17 Number,
19 Group,
21 Resolution,
23 TvgId,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum SortDirection {
34 Ascending,
36 Descending,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub struct SortKey {
43 pub criteria: SortCriteria,
45 pub direction: SortDirection,
47}
48
49pub fn sort_entries(entries: &mut [PlaylistEntry], criteria: &[SortCriteria]) {
54 if criteria.is_empty() {
55 return;
56 }
57 entries.sort_by(|a, b| compare_entries(a, b, criteria));
58}
59
60pub fn sort_entries_multi(entries: &mut [PlaylistEntry], keys: &[SortKey]) {
64 if keys.is_empty() {
65 return;
66 }
67 entries.sort_by(|a, b| {
68 for key in keys {
69 let ord = compare_by(a, b, key.criteria);
70 let ord = match key.direction {
71 SortDirection::Ascending => ord,
72 SortDirection::Descending => ord.reverse(),
73 };
74 if ord != Ordering::Equal {
75 return ord;
76 }
77 }
78 Ordering::Equal
79 });
80}
81
82fn compare_entries(a: &PlaylistEntry, b: &PlaylistEntry, criteria: &[SortCriteria]) -> Ordering {
84 for criterion in criteria {
85 let ord = compare_by(a, b, *criterion);
86 if ord != Ordering::Equal {
87 return ord;
88 }
89 }
90 Ordering::Equal
91}
92
93fn compare_by(a: &PlaylistEntry, b: &PlaylistEntry, criterion: SortCriteria) -> Ordering {
95 match criterion {
96 SortCriteria::Name => {
97 let a_name = a.name.as_deref().unwrap_or("");
98 let b_name = b.name.as_deref().unwrap_or("");
99 a_name.to_lowercase().cmp(&b_name.to_lowercase())
100 }
101 SortCriteria::Number => {
102 let a_num = parse_chno(a.tvg_chno.as_deref());
103 let b_num = parse_chno(b.tvg_chno.as_deref());
104 a_num.cmp(&b_num)
105 }
106 SortCriteria::Group => {
107 let a_group = a.group_title.as_deref().unwrap_or("");
108 let b_group = b.group_title.as_deref().unwrap_or("");
109 a_group.to_lowercase().cmp(&b_group.to_lowercase())
110 }
111 SortCriteria::Resolution => {
112 let a_res = detect_resolution(
113 a.name.as_deref().unwrap_or(""),
114 a.url.as_deref().unwrap_or(""),
115 &a.extras,
116 );
117 let b_res = detect_resolution(
118 b.name.as_deref().unwrap_or(""),
119 b.url.as_deref().unwrap_or(""),
120 &b.extras,
121 );
122 a_res.cmp(&b_res)
123 }
124 SortCriteria::TvgId => {
125 let a_id = parse_tvg_id_numeric(a.tvg_id.as_deref());
126 let b_id = parse_tvg_id_numeric(b.tvg_id.as_deref());
127 match (a_id, b_id) {
128 (Some(an), Some(bn)) => an.cmp(&bn),
129 (Some(_), None) => Ordering::Less,
130 (None, Some(_)) => Ordering::Greater,
131 (None, None) => {
132 let a_str = a.tvg_id.as_deref().unwrap_or("");
133 let b_str = b.tvg_id.as_deref().unwrap_or("");
134 a_str.to_lowercase().cmp(&b_str.to_lowercase())
135 }
136 }
137 }
138 }
139}
140
141fn parse_chno(chno: Option<&str>) -> u64 {
144 chno.and_then(|s| s.trim().parse::<u64>().ok())
145 .unwrap_or(u64::MAX)
146}
147
148fn parse_tvg_id_numeric(id: Option<&str>) -> Option<u64> {
154 let id = id?;
155 let digits: String = id.chars().filter(char::is_ascii_digit).collect();
156 if digits.is_empty() {
157 return None;
158 }
159 digits.parse::<u64>().ok()
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 fn make_entry_with_chno(name: &str, chno: &str, group: &str) -> PlaylistEntry {
167 PlaylistEntry {
168 name: Some(name.to_string()),
169 tvg_chno: if chno.is_empty() {
170 None
171 } else {
172 Some(chno.to_string())
173 },
174 group_title: Some(group.to_string()),
175 ..Default::default()
176 }
177 }
178
179 #[test]
180 fn sort_by_name_alphabetical() {
181 let mut entries = vec![
182 make_entry_with_chno("CNN", "", ""),
183 make_entry_with_chno("ABC", "", ""),
184 make_entry_with_chno("BBC", "", ""),
185 ];
186 sort_entries(&mut entries, &[SortCriteria::Name]);
187 assert_eq!(entries[0].name.as_deref().unwrap(), "ABC");
188 assert_eq!(entries[1].name.as_deref().unwrap(), "BBC");
189 assert_eq!(entries[2].name.as_deref().unwrap(), "CNN");
190 }
191
192 #[test]
193 fn sort_by_name_case_insensitive() {
194 let mut entries = vec![
195 make_entry_with_chno("cnn", "", ""),
196 make_entry_with_chno("ABC", "", ""),
197 make_entry_with_chno("bbc", "", ""),
198 ];
199 sort_entries(&mut entries, &[SortCriteria::Name]);
200 assert_eq!(entries[0].name.as_deref().unwrap(), "ABC");
201 assert_eq!(entries[1].name.as_deref().unwrap(), "bbc");
202 assert_eq!(entries[2].name.as_deref().unwrap(), "cnn");
203 }
204
205 #[test]
206 fn sort_by_number_numeric() {
207 let mut entries = vec![
208 make_entry_with_chno("C", "10", ""),
209 make_entry_with_chno("A", "1", ""),
210 make_entry_with_chno("B", "3", ""),
211 ];
212 sort_entries(&mut entries, &[SortCriteria::Number]);
213 assert_eq!(entries[0].name.as_deref().unwrap(), "A");
214 assert_eq!(entries[1].name.as_deref().unwrap(), "B");
215 assert_eq!(entries[2].name.as_deref().unwrap(), "C");
216 }
217
218 #[test]
219 fn sort_by_number_missing_goes_last() {
220 let mut entries = vec![
221 make_entry_with_chno("NoNum", "", ""),
222 make_entry_with_chno("First", "1", ""),
223 ];
224 sort_entries(&mut entries, &[SortCriteria::Number]);
225 assert_eq!(entries[0].name.as_deref().unwrap(), "First");
226 assert_eq!(entries[1].name.as_deref().unwrap(), "NoNum");
227 }
228
229 #[test]
230 fn sort_by_group() {
231 let mut entries = vec![
232 make_entry_with_chno("A", "", "Sports"),
233 make_entry_with_chno("B", "", "Movies"),
234 make_entry_with_chno("C", "", "News"),
235 ];
236 sort_entries(&mut entries, &[SortCriteria::Group]);
237 assert_eq!(entries[0].group_title.as_deref().unwrap(), "Movies");
238 assert_eq!(entries[1].group_title.as_deref().unwrap(), "News");
239 assert_eq!(entries[2].group_title.as_deref().unwrap(), "Sports");
240 }
241
242 #[test]
243 fn sort_by_resolution() {
244 let mut entries = vec![
245 PlaylistEntry {
246 name: Some("HD Channel".into()),
247 ..Default::default()
248 },
249 PlaylistEntry {
250 name: Some("4K Channel".into()),
251 ..Default::default()
252 },
253 PlaylistEntry {
254 name: Some("SD Channel".into()),
255 ..Default::default()
256 },
257 ];
258 sort_entries(&mut entries, &[SortCriteria::Resolution]);
259 assert_eq!(entries[0].name.as_deref().unwrap(), "SD Channel");
260 assert_eq!(entries[1].name.as_deref().unwrap(), "HD Channel");
261 assert_eq!(entries[2].name.as_deref().unwrap(), "4K Channel");
262 }
263
264 #[test]
265 fn sort_multi_criteria() {
266 let mut entries = vec![
267 make_entry_with_chno("B", "", "Sports"),
268 make_entry_with_chno("A", "", "Sports"),
269 make_entry_with_chno("C", "", "News"),
270 ];
271 sort_entries(&mut entries, &[SortCriteria::Group, SortCriteria::Name]);
272 assert_eq!(entries[0].name.as_deref().unwrap(), "C"); assert_eq!(entries[1].name.as_deref().unwrap(), "A"); assert_eq!(entries[2].name.as_deref().unwrap(), "B");
275 }
276
277 #[test]
278 fn sort_empty_criteria_is_noop() {
279 let mut entries = vec![
280 make_entry_with_chno("B", "", ""),
281 make_entry_with_chno("A", "", ""),
282 ];
283 sort_entries(&mut entries, &[]);
284 assert_eq!(entries[0].name.as_deref().unwrap(), "B");
285 }
286
287 fn make_entry_with_tvg_id(name: &str, tvg_id: &str, group: &str) -> PlaylistEntry {
288 PlaylistEntry {
289 name: Some(name.to_string()),
290 tvg_id: if tvg_id.is_empty() {
291 None
292 } else {
293 Some(tvg_id.to_string())
294 },
295 group_title: Some(group.to_string()),
296 ..Default::default()
297 }
298 }
299
300 #[test]
301 fn sort_by_tvg_id_numeric() {
302 let mut entries = vec![
303 make_entry_with_tvg_id("C", "ch100", ""),
304 make_entry_with_tvg_id("A", "ch3", ""),
305 make_entry_with_tvg_id("B", "ch20", ""),
306 ];
307 sort_entries(&mut entries, &[SortCriteria::TvgId]);
308 assert_eq!(entries[0].name.as_deref().unwrap(), "A"); assert_eq!(entries[1].name.as_deref().unwrap(), "B"); assert_eq!(entries[2].name.as_deref().unwrap(), "C"); }
312
313 #[test]
314 fn sort_by_tvg_id_string_fallback() {
315 let mut entries = vec![
316 make_entry_with_tvg_id("B", "bbc.uk", ""),
317 make_entry_with_tvg_id("A", "abc.us", ""),
318 ];
319 sort_entries(&mut entries, &[SortCriteria::TvgId]);
320 assert_eq!(entries[0].name.as_deref().unwrap(), "A"); assert_eq!(entries[1].name.as_deref().unwrap(), "B");
322 }
323
324 #[test]
325 fn sort_with_descending_direction() {
326 let mut entries = vec![
327 make_entry_with_chno("A", "", ""),
328 make_entry_with_chno("C", "", ""),
329 make_entry_with_chno("B", "", ""),
330 ];
331 sort_entries_multi(
332 &mut entries,
333 &[SortKey {
334 criteria: SortCriteria::Name,
335 direction: SortDirection::Descending,
336 }],
337 );
338 assert_eq!(entries[0].name.as_deref().unwrap(), "C");
339 assert_eq!(entries[1].name.as_deref().unwrap(), "B");
340 assert_eq!(entries[2].name.as_deref().unwrap(), "A");
341 }
342
343 #[test]
344 fn sort_with_mixed_directions() {
345 let mut entries = vec![
346 PlaylistEntry {
347 name: Some("CNN HD".into()),
348 group_title: Some("News".into()),
349 ..Default::default()
350 },
351 PlaylistEntry {
352 name: Some("BBC 4K".into()),
353 group_title: Some("News".into()),
354 ..Default::default()
355 },
356 PlaylistEntry {
357 name: Some("Sky SD".into()),
358 group_title: Some("Sports".into()),
359 ..Default::default()
360 },
361 ];
362 sort_entries_multi(
363 &mut entries,
364 &[
365 SortKey {
366 criteria: SortCriteria::Group,
367 direction: SortDirection::Ascending,
368 },
369 SortKey {
370 criteria: SortCriteria::Resolution,
371 direction: SortDirection::Descending,
372 },
373 ],
374 );
375 assert_eq!(entries[0].name.as_deref().unwrap(), "BBC 4K");
377 assert_eq!(entries[1].name.as_deref().unwrap(), "CNN HD");
378 assert_eq!(entries[2].name.as_deref().unwrap(), "Sky SD");
379 }
380}