Skip to main content

crispy_iptv_tools/
sort.rs

1//! Playlist entry sorting.
2//!
3//! Sort entries by name, number, group, or resolution.
4//! Supports multi-criteria sorting (primary, secondary, etc.).
5
6use std::cmp::Ordering;
7
8use crispy_iptv_types::PlaylistEntry;
9
10use crate::resolution::detect_resolution;
11
12/// Criteria for sorting playlist entries.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum SortCriteria {
15    /// Alphabetical by display name.
16    Name,
17    /// Numeric by channel number (`tvg_chno`).
18    Number,
19    /// Alphabetical by group title.
20    Group,
21    /// By resolution tier (Unknown < SD < HD < FHD < UHD).
22    Resolution,
23    /// By `tvg_id` — numeric when parseable, string fallback.
24    ///
25    /// Faithfully ported from `iptvtools/models.py::__custom_sort` which
26    /// strips non-digit characters and parses as integer, falling back to
27    /// `sys.maxsize` for non-numeric IDs.
28    TvgId,
29}
30
31/// Sort direction for a single criterion.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum SortDirection {
34    /// Ascending order (A→Z, 0→9, low→high).
35    Ascending,
36    /// Descending order (Z→A, 9→0, high→low).
37    Descending,
38}
39
40/// A sort key combining a criterion with a direction.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub struct SortKey {
43    /// Which field to sort by.
44    pub criteria: SortCriteria,
45    /// Ascending or descending.
46    pub direction: SortDirection,
47}
48
49/// Sort entries in place by the given criteria chain.
50///
51/// The first criterion is primary, the second is the tiebreaker, and so on.
52/// All criteria use ascending direction.
53pub 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
60/// Sort entries in place with multiple keys, each having an independent direction.
61///
62/// The first key is primary, the second is the tiebreaker, and so on.
63pub 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
82/// Compare two entries by a chain of criteria.
83fn 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
93/// Compare two entries by a single criterion.
94fn 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
141/// Parse a channel number string to a sortable integer.
142/// Non-numeric values sort to `u64::MAX` (end of list).
143fn parse_chno(chno: Option<&str>) -> u64 {
144    chno.and_then(|s| s.trim().parse::<u64>().ok())
145        .unwrap_or(u64::MAX)
146}
147
148/// Parse a `tvg_id` as a numeric value for sorting.
149///
150/// Strips all non-digit characters and parses the remainder as `u64`.
151/// Returns `None` if no digits are present, mirroring the Python logic
152/// from `iptvtools/models.py::__custom_sort` which uses `re.sub(r"\D", "")`.
153fn 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"); // News first
273        assert_eq!(entries[1].name.as_deref().unwrap(), "A"); // Sports, A before B
274        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"); // ch3 → 3
309        assert_eq!(entries[1].name.as_deref().unwrap(), "B"); // ch20 → 20
310        assert_eq!(entries[2].name.as_deref().unwrap(), "C"); // ch100 → 100
311    }
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"); // abc < bbc
321        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        // News group first (ascending), then within News: UHD (4K) before HD (descending).
376        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}